Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d982bef3c5 | ||
|
|
2463f0900d | ||
|
|
23ba727b9a | ||
|
|
6c625fb76b | ||
|
|
0aafbbbdb5 | ||
|
|
8e6e0fac47 |
+10
-13
@@ -2,15 +2,12 @@
|
||||
|
||||
## Local Development
|
||||
|
||||
To set up the project GitHub-Stats-Extended locally, run the following commands:
|
||||
To run and test GitHub-Stats-Extended, you need to follow a few simple steps:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
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).
|
||||
1. create [your own deployment](../docs/deploy.md)
|
||||
2. optional: add an SQL database; by using e.g. the ["Nile" integration](https://vercel.com/marketplace/nile) or by manually setting the environment variable `POSTGRES_URL`
|
||||
3. optional: [create your own OAuth App](https://github.com/settings/developers) and set environment variables `OAUTH_REDIRECT_URI`, `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` on Vercel accordingly
|
||||
4. optional: in addition to the Vercel project based on the `backend` folder, create a second project based on the `frontend/frontend` folder. No environment variables needed.
|
||||
|
||||
## Themes Contribution
|
||||
|
||||
@@ -40,9 +37,9 @@ We use GitHub issues to track public bugs. Report a bug by [opening a new issue]
|
||||
|
||||
> **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-665164174>
|
||||
>
|
||||
> - <https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665172181>
|
||||
> - <https://github.com/anuraghazra/github-readme-stats/issues/136#issuecomment-665172181>
|
||||
|
||||
**Q:** How to count private stats?
|
||||
|
||||
@@ -52,6 +49,6 @@ We use GitHub issues to track public bugs. Report a bug by [opening a new issue]
|
||||
|
||||
**Great Feature Requests** tend to have:
|
||||
|
||||
- A quick idea summary
|
||||
- What & why do you want to add the specific feature
|
||||
- Additional context like images, links to resources to implement the feature, etc.
|
||||
- A quick idea summary
|
||||
- What & why do you want to add the specific feature
|
||||
- Additional context like images, links to resources to implement the feature, etc.
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Setup Node.js + PNPM and install Dependencies
|
||||
description: |
|
||||
This is a composite GitHub Action that:
|
||||
- Sets up pnpm package manager
|
||||
- Configures Node.js environment
|
||||
- Installs project dependencies with caching
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: "Explicit node version. Otherwise fallback reading `.nvmrc`"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
|
||||
- name: Setup Node.js (via input)
|
||||
if: ${{ inputs.node-version }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: "pnpm"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Setup Node.js (via .nvmrc)
|
||||
if: ${{ !inputs.node-version }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install Dependencies
|
||||
shell: bash
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -1,83 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain dependencies for npm
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 20
|
||||
commit-message:
|
||||
prefix: "build(deps)"
|
||||
prefix-development: "build(deps-dev)"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
ignore:
|
||||
- dependency-name: "@types/node"
|
||||
update-types: ["version-update:semver-major"]
|
||||
groups:
|
||||
react:
|
||||
patterns:
|
||||
- "react"
|
||||
- "react-dom"
|
||||
- "react-*"
|
||||
- "@types/react"
|
||||
- "@types/react-dom"
|
||||
redux:
|
||||
patterns:
|
||||
- "redux"
|
||||
- "@reduxjs/*"
|
||||
- "react-redux"
|
||||
vite:
|
||||
patterns:
|
||||
- "vite"
|
||||
- "@vitejs/*"
|
||||
tailwind:
|
||||
patterns:
|
||||
- "tailwindcss"
|
||||
- "@tailwindcss/*"
|
||||
- "daisyui"
|
||||
vitest:
|
||||
patterns:
|
||||
- "vitest"
|
||||
- "@vitest/*"
|
||||
eslint:
|
||||
patterns:
|
||||
- "eslint"
|
||||
- "@eslint/*"
|
||||
- "eslint-*"
|
||||
- "typescript-eslint"
|
||||
typescript:
|
||||
patterns:
|
||||
- "typescript"
|
||||
- "@types/*"
|
||||
playwright:
|
||||
patterns:
|
||||
- "@playwright/*"
|
||||
axios:
|
||||
patterns:
|
||||
- "axios"
|
||||
- "axios-*"
|
||||
|
||||
# Maintain dependencies for GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 20
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
prefix-development: "ci(deps-dev)"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
# Maintain dependencies for Devcontainers
|
||||
- package-ecosystem: devcontainers
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 20
|
||||
commit-message:
|
||||
prefix: "build(deps)"
|
||||
prefix-development: "build(deps-dev)"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
@@ -1,124 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.head_ref }}"
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build and test ${{ matrix.node }} on ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [24]
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm run build:packages
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm run build:frontend
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test
|
||||
|
||||
frontend-test-e2e:
|
||||
name: Frontend E2E test
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
timeout-minutes: 60
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm run build:packages
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm --filter ./apps/frontend/ run test:e2e
|
||||
|
||||
backend-test-e2e:
|
||||
name: Backend E2E test
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm run build:packages
|
||||
|
||||
- name: Run backend end-to-end tests
|
||||
run: pnpm --filter ./apps/backend/ run test:e2e
|
||||
|
||||
code-checks:
|
||||
name: Code checks
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Format
|
||||
run: pnpm run format:check
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Lint (knip)
|
||||
run: pnpm run lint:knip
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Generate Theme Readme
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "packages/core/src/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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Generate readme
|
||||
run: |
|
||||
pnpm --filter ./packages/core/ run theme-readme-gen
|
||||
|
||||
- name: Create Pull Request if themes README has changed
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # 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"
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Publish core package to npm
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install Dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
|
||||
- name: Verify core package
|
||||
run: |
|
||||
pnpm --filter @stats-organization/github-readme-stats-core run typecheck
|
||||
pnpm test
|
||||
pnpm build:packages
|
||||
pnpm --filter @stats-organization/github-readme-stats-core pack --dry-run
|
||||
|
||||
- name: Publish core package to npm
|
||||
run: pnpm --filter @stats-organization/github-readme-stats-core publish --provenance --access public --no-git-checks
|
||||
@@ -15,16 +15,12 @@ on:
|
||||
|
||||
jobs:
|
||||
triggerRepeatRecent:
|
||||
if: |
|
||||
github.repository == 'anuraghazra/github-readme-stats' ||
|
||||
github.repository == 'stats-organization/github-readme-stats' ||
|
||||
github.repository == 'stats-organization/github-stats-extended'
|
||||
name: Trigger server to update cached data.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Make Request
|
||||
id: myRequest
|
||||
uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2
|
||||
uses: fjogeleit/http-request-action@v1
|
||||
with:
|
||||
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
|
||||
method: "POST"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Update version tags
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- "**"
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
update-semver:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: haya14busa/action-update-semver@7d2c558640ea49e798d46539536190aff8c18715 # v1.5.1
|
||||
+9
-31
@@ -1,27 +1,12 @@
|
||||
node_modules
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Project
|
||||
coverage
|
||||
|
||||
apps/backend/.vercel
|
||||
apps/backend/.env
|
||||
apps/backend/node_modules
|
||||
apps/backend/*.lock
|
||||
apps/backend/coverage
|
||||
apps/backend/benchmarks
|
||||
apps/backend/vercel_token
|
||||
apps/backend-copy
|
||||
|
||||
apps/frontend/.env
|
||||
|
||||
|
||||
.turbo
|
||||
build
|
||||
build-ts
|
||||
*.tsbuildinfo
|
||||
backend/.vercel
|
||||
backend/.env
|
||||
backend/node_modules
|
||||
backend/*.lock
|
||||
backend/coverage
|
||||
backend/benchmarks
|
||||
backend/vercel_token
|
||||
frontend/frontend/.env
|
||||
frontend/frontend/src/backend
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -29,10 +14,3 @@ build-ts
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
*.code-workspace
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
playwright/.cache/
|
||||
playwright/.auth/
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
pnpm lint-staged
|
||||
pnpm run lint
|
||||
pnpm test
|
||||
@@ -1,5 +0,0 @@
|
||||
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
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.jsonc"],
|
||||
"options": {
|
||||
"trailingComma": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"yzhang.markdown-all-in-one",
|
||||
"prettier.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"github.vscode-github-actions"
|
||||
]
|
||||
}
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"markdown.extension.toc.levels": "1..3",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[javascript]": {
|
||||
"editor.tabSize": 2,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"cSpell.words": ["Wakatime"]
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
<div align="center">
|
||||
<img src="docs/appIcon.svg" width="100px" alt="GitHub Stats Extended Logo" />
|
||||
<img src="docs/appIcon.svg" width="100px" alt="GitHub Readme Stats" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
This project is the [extended, actively maintained successor](docs/fork.md) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types), e.g. about your GitHub contributions, your top languages, etc. You can [customize](#advanced-customization) the cards via multiple parameters.
|
||||
This project is an [extended version](docs/fork.md) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types), e.g. about your GitHub contributions, your top languages, etc. You can [customize](#advanced-customization) the cards via multiple parameters.
|
||||
|
||||
# Table of Contents
|
||||
- [Quick Start](#quick-start)
|
||||
- [Migration from github-readme-stats](#migration-from-github-readme-stats)
|
||||
- [Card Types](#card-types)
|
||||
- [Advanced Customization](#advanced-customization)
|
||||
- [Run It Yourself](#run-it-yourself)
|
||||
- [Self-Hosting](#self-hosting)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
@@ -28,16 +27,6 @@ This project is the [extended, actively maintained successor](docs/fork.md) of [
|
||||
|
||||
As more comfortable alternative, use the [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) to create your custom stats card. Copy the generated markdown code and paste it into your [GitHub profile README](https://docs.github.com/en/account-and-profile/how-tos/profile-customization/managing-your-profile-readme#adding-a-profile-readme). Done!
|
||||
|
||||
# Migration from github-readme-stats
|
||||
To migrate from [github-readme-stats](https://github.com/anuraghazra/github-readme-stats) you only need to change the domain from `github-readme-stats.vercel.app` to `github-stats-extended.vercel.app`:
|
||||
|
||||
```diff
|
||||
- https://github-readme-stats.vercel.app/api?username=octocat&theme=radical
|
||||
+ https://github-stats-extended.vercel.app/api?username=octocat&theme=radical
|
||||
```
|
||||
|
||||
GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For more details see [Compatibility Notes](docs/fork.md#compatibility-notes).
|
||||
|
||||
# Card Types
|
||||
- Show your GitHub statistics:
|
||||
|
||||
@@ -49,7 +38,7 @@ GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For
|
||||
|
||||
- ...and development time:
|
||||
|
||||
[](https://wakatime.com/@alan)
|
||||
[](https://wakatime.com/@ffflabs)
|
||||
|
||||
- Pin more than 6 repos in your GitHub profile:
|
||||
|
||||
@@ -66,13 +55,13 @@ GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For
|
||||
# Advanced Customization
|
||||
The [GitHub-Stats-Extended Wizard](https://github-stats-extended.vercel.app/frontend) offers some essential customization options. For more advanced customization check out the [advanced documentation](docs/advanced_documentation.md).
|
||||
|
||||
# Run It Yourself
|
||||
If you want to run GitHub-Stats-Extended on your own, there are two main deployment options: you can use [github-readme-stats-action](https://github.com/stats-organization/github-readme-stats-action) to generate cards in your own GitHub Actions workflow. Or you can self-host GitHub-Stats-Extended on Vercel.
|
||||
|
||||
See [Run It Yourself](docs/deploy.md) for detailed instructions.
|
||||
|
||||
# Acknowledgements
|
||||
This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of that project's functionality GitHub-Stats-Extended adds several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend added to GitHub-Stats-Extended is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️
|
||||
This project is based on [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). On top of their functionality I added several new features and improvements. See [Fork Information](docs/fork.md) for a list of changes. The frontend I added to the project is based on [GitHub Trends](https://github.com/avgupta456/github-trends). Big thanks to [@anuraghazra](https://github.com/anuraghazra), [@avgupta456](https://github.com/avgupta456), [@rickstaa](https://github.com/rickstaa), [@qwerty541](https://github.com/qwerty541) and everyone else who worked on these projects! ❤️
|
||||
|
||||
# Self-Hosting
|
||||
Since the GitHub API only allows a limited number of requests per hour, the public instance of GitHub-Stats-Extended at https://github-stats-extended.vercel.app/api could possibly hit the rate limiter. If you host your own instance you do not have to worry about anything. Also, if you don't want to give my GitHub-Stats-Extended instance access to your private contributions but still want to include these contributions in your stats, you can simply host your own instance.
|
||||
|
||||
See [Deploy on your own](docs/deploy.md) for various deployment options.
|
||||
|
||||
# Contributing
|
||||
Contributions are welcome!
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "GitHub Stats Extended Dev",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": { "version": "24" }
|
||||
},
|
||||
"forwardPorts": [3000],
|
||||
"portsAttributes": {
|
||||
"3000": { "label": "HTTP" }
|
||||
},
|
||||
"appPort": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "npm install -g vercel",
|
||||
|
||||
// Use 'postStartCommand' to run commands after the container is started.
|
||||
"postStartCommand": "hostname dev && npm install",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"yzhang.markdown-all-in-one",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"github.vscode-github-actions"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"remoteUser": "root",
|
||||
"privileged": true
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import express from "express";
|
||||
|
||||
import router from "./router.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(router);
|
||||
|
||||
const port = process.env.PORT || process.env.port || 9000;
|
||||
app.listen(port, "0.0.0.0", () => {
|
||||
console.log(`Server running on port ${port}`);
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { default as router } from "./router.js";
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "@stats-organization/github-readme-stats-backend",
|
||||
"type": "module",
|
||||
"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",
|
||||
"bench": "vitest bench --run --config vitest.config.bench.ts",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc -p tsconfig.typecheck.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"express": "5.2.1",
|
||||
"jsdom": "catalog:default",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "catalog:default",
|
||||
"@stats-organization/github-readme-stats-core": "workspace:^",
|
||||
"pg": "^8.21.0"
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import {
|
||||
api,
|
||||
gist,
|
||||
pin,
|
||||
topLangs,
|
||||
wakatime,
|
||||
} from "@stats-organization/github-readme-stats-core";
|
||||
|
||||
import { default as authenticate } from "./api-renamed/authenticate.js";
|
||||
import { default as deleteUser } from "./api-renamed/delete-user.js";
|
||||
import { default as downgrade } from "./api-renamed/downgrade.js";
|
||||
import { default as repeatRecent } from "./api-renamed/repeat-recent.js";
|
||||
import { default as patInfo } from "./api-renamed/status/pat-info.js";
|
||||
import { default as statusUp } from "./api-renamed/status/up.js";
|
||||
import { default as userAccess } from "./api-renamed/user-access.js";
|
||||
import { default as wakatimeProxy } from "./api-renamed/wakatime-proxy.js";
|
||||
import { guardAccess } from "./src/common/access.js";
|
||||
import {
|
||||
CACHE_TTL,
|
||||
resolveCacheSeconds,
|
||||
setCacheHeaders,
|
||||
setErrorCacheHeaders,
|
||||
} from "./src/common/cache.js";
|
||||
import { getUserAccessByName, storeRequest } from "./src/common/database.js";
|
||||
|
||||
const getGuardResult = (query, type, id) => {
|
||||
const access = guardAccess({
|
||||
id,
|
||||
type,
|
||||
colors: {
|
||||
title_color: query.title_color,
|
||||
text_color: query.text_color,
|
||||
bg_color: query.bg_color,
|
||||
border_color: query.border_color,
|
||||
theme: query.theme,
|
||||
},
|
||||
});
|
||||
|
||||
if (access.isPassed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error - permanent",
|
||||
content: access.result,
|
||||
};
|
||||
};
|
||||
|
||||
const getUserPat = async (username) => {
|
||||
if (!username) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userAccess = await getUserAccessByName(username);
|
||||
if (!userAccess?.token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return userAccess.token;
|
||||
};
|
||||
|
||||
export default async (req, res) => {
|
||||
const url = new URL(req.url, "https://localhost");
|
||||
if (res.send === undefined) {
|
||||
// remaining code expects express.js-like request and response objects
|
||||
res.send = function (data) {
|
||||
if (typeof data === "object") {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(data));
|
||||
} else if (typeof data === "string") {
|
||||
res.end(data);
|
||||
} else {
|
||||
res.end(String(data));
|
||||
}
|
||||
};
|
||||
req.query = Object.fromEntries(url.searchParams.entries());
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
switch (url.pathname) {
|
||||
case "/api": {
|
||||
result = getGuardResult(req.query, "username", req.query.username);
|
||||
if (!result) {
|
||||
const userPat = await getUserPat(req.query.username);
|
||||
result = await api(req.query, userPat);
|
||||
}
|
||||
if (result.status === "error - temporary") {
|
||||
setErrorCacheHeaders(res);
|
||||
} else {
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(req.query.cache_seconds, 10),
|
||||
def: CACHE_TTL.STATS_CARD.DEFAULT,
|
||||
min: CACHE_TTL.STATS_CARD.MIN,
|
||||
max: CACHE_TTL.STATS_CARD.MAX,
|
||||
});
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
}
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
res.end(result.content);
|
||||
if (result.status !== "error - permanent") {
|
||||
await storeRequest(req);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "/api/gist":
|
||||
result =
|
||||
getGuardResult(req.query, "gist", req.query.id) ??
|
||||
(await gist(req.query));
|
||||
if (result.status === "error - temporary") {
|
||||
setErrorCacheHeaders(res);
|
||||
} else {
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(req.query.cache_seconds, 10),
|
||||
def: CACHE_TTL.GIST_CARD.DEFAULT,
|
||||
min: CACHE_TTL.GIST_CARD.MIN,
|
||||
max: CACHE_TTL.GIST_CARD.MAX,
|
||||
});
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
}
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
res.end(result.content);
|
||||
if (result.status !== "error - permanent") {
|
||||
await storeRequest(req);
|
||||
}
|
||||
break;
|
||||
case "/api/pin": {
|
||||
result = getGuardResult(req.query, "username", req.query.username);
|
||||
if (!result) {
|
||||
const userPat = await getUserPat(req.query.username);
|
||||
result = await pin(req.query, userPat);
|
||||
}
|
||||
if (result.status === "error - temporary") {
|
||||
setErrorCacheHeaders(res);
|
||||
} else {
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(req.query.cache_seconds, 10),
|
||||
def: CACHE_TTL.PIN_CARD.DEFAULT,
|
||||
min: CACHE_TTL.PIN_CARD.MIN,
|
||||
max: CACHE_TTL.PIN_CARD.MAX,
|
||||
});
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
}
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
res.end(result.content);
|
||||
if (result.status !== "error - permanent") {
|
||||
await storeRequest(req);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "/api/top-langs": {
|
||||
result = getGuardResult(req.query, "username", req.query.username);
|
||||
if (!result) {
|
||||
const userPat = await getUserPat(req.query.username);
|
||||
result = await topLangs(req.query, userPat);
|
||||
}
|
||||
if (result.status === "error - temporary") {
|
||||
setErrorCacheHeaders(res);
|
||||
} else {
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(req.query.cache_seconds, 10),
|
||||
def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT,
|
||||
min: CACHE_TTL.TOP_LANGS_CARD.MIN,
|
||||
max: CACHE_TTL.TOP_LANGS_CARD.MAX,
|
||||
});
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
}
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
res.end(result.content);
|
||||
if (result.status !== "error - permanent") {
|
||||
await storeRequest(req);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "/api/wakatime":
|
||||
result =
|
||||
getGuardResult(req.query, "wakatime", req.query.username) ??
|
||||
(await wakatime(req.query));
|
||||
if (result.status === "error - temporary") {
|
||||
setErrorCacheHeaders(res);
|
||||
} else {
|
||||
const cacheSeconds = resolveCacheSeconds({
|
||||
requested: parseInt(req.query.cache_seconds, 10),
|
||||
def: CACHE_TTL.WAKATIME_CARD.DEFAULT,
|
||||
min: CACHE_TTL.WAKATIME_CARD.MIN,
|
||||
max: CACHE_TTL.WAKATIME_CARD.MAX,
|
||||
});
|
||||
setCacheHeaders(res, cacheSeconds);
|
||||
}
|
||||
res.setHeader("Content-Type", "image/svg+xml");
|
||||
res.end(result.content);
|
||||
if (result.status !== "error - permanent") {
|
||||
await storeRequest(req);
|
||||
}
|
||||
break;
|
||||
case "/api/wakatime-proxy":
|
||||
await wakatimeProxy(req, res);
|
||||
break;
|
||||
case "/api/repeat-recent":
|
||||
await repeatRecent(req, res);
|
||||
break;
|
||||
case "/api/status/pat-info":
|
||||
await patInfo(req, res);
|
||||
break;
|
||||
case "/api/status/up":
|
||||
await statusUp(req, res);
|
||||
break;
|
||||
case "/api/authenticate":
|
||||
await authenticate(req, res);
|
||||
break;
|
||||
case "/api/delete-user":
|
||||
await deleteUser(req, res);
|
||||
break;
|
||||
case "/api/user-access":
|
||||
await userAccess(req, res);
|
||||
break;
|
||||
case "/api/downgrade":
|
||||
await downgrade(req, res);
|
||||
break;
|
||||
default:
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { TextDecoder, TextEncoder } from "util";
|
||||
|
||||
//https://stackoverflow.com/a/68468204/1643179
|
||||
Object.assign(global, { TextDecoder, TextEncoder });
|
||||
|
||||
process.env.PAT_1 = "dummyPAT1";
|
||||
process.env.PAT_2 = "dummyPAT2";
|
||||
@@ -1,181 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { api, getConfig } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import router from "../router.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
|
||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
});
|
||||
|
||||
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
storeRequest: vi.fn(),
|
||||
getUserAccessByName: vi.fn(),
|
||||
}));
|
||||
|
||||
const apiMock = vi.mocked(api);
|
||||
const getConfigMock = vi.mocked(getConfig);
|
||||
const storeRequestMock = vi.mocked(storeRequest);
|
||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
||||
|
||||
const createRequest = (search = "") => ({
|
||||
headers: {},
|
||||
url: `/api?${search}`,
|
||||
});
|
||||
|
||||
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(() => {
|
||||
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;
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
const req = createRequest(
|
||||
"username=anuraghazra&theme=dark&hide=issues,prs,contribs",
|
||||
);
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
{
|
||||
username: "anuraghazra",
|
||||
theme: "dark",
|
||||
hide: "issues,prs,contribs",
|
||||
},
|
||||
"user-pat",
|
||||
);
|
||||
expect(req.query).toEqual({
|
||||
username: "anuraghazra",
|
||||
theme: "dark",
|
||||
hide: "issues,prs,contribs",
|
||||
});
|
||||
expect(res.setHeader.mock.calls).toEqual([
|
||||
["Cache-Control", defaultCacheHeader],
|
||||
["Content-Type", "image/svg+xml"],
|
||||
]);
|
||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-stats-svg");
|
||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
||||
});
|
||||
|
||||
it("should use the shorter error cache for temporary stats errors", async () => {
|
||||
apiMock.mockResolvedValue({
|
||||
status: "error - temporary",
|
||||
content: "temporary-error-svg",
|
||||
});
|
||||
|
||||
const req = createRequest("username=anuraghazra");
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
{
|
||||
username: "anuraghazra",
|
||||
},
|
||||
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 not persist permanent stats errors returned by core", async () => {
|
||||
apiMock.mockResolvedValue({
|
||||
status: "error - permanent",
|
||||
content: "permanent-error-svg",
|
||||
});
|
||||
|
||||
const req = createRequest("username=anuraghazra");
|
||||
const res = createResponse();
|
||||
|
||||
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 reject blacklisted usernames before calling core logic", async () => {
|
||||
const req = createRequest("username=renovate-bot");
|
||||
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 blacklisted",
|
||||
);
|
||||
expect(storeRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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,41 +0,0 @@
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { beforeAll, bench, describe, vi } from "vitest";
|
||||
|
||||
import { data_stats } from "../utils.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
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_stats);
|
||||
});
|
||||
|
||||
describe("bench /api", () => {
|
||||
bench(
|
||||
"base",
|
||||
async () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api?username=anuraghazra",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
},
|
||||
{ warmupIterations: 50 },
|
||||
);
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { beforeAll, bench, describe, vi } from "vitest";
|
||||
|
||||
import { happy_path_gist_data } from "../utils.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
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 = {
|
||||
headers: {},
|
||||
url: "/api/gist?id=happy-gist-id",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
},
|
||||
{ warmupIterations: 50 },
|
||||
);
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import { beforeAll, bench, describe, vi } from "vitest";
|
||||
|
||||
import { data_user } from "../utils.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
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 = {
|
||||
headers: {},
|
||||
url: "/api/pin?username=anuraghazra&repo=convoychat",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
},
|
||||
{ warmupIterations: 50 },
|
||||
);
|
||||
});
|
||||
@@ -1,345 +0,0 @@
|
||||
/**
|
||||
* @file Contains end-to-end tests for the Vercel preview instance.
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
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_MOCK_RESPONSE = {
|
||||
data: {
|
||||
user: {
|
||||
name: "CodeNinja",
|
||||
login: STATS_CARD_USER,
|
||||
repositoriesContributedTo: { totalCount: 0 },
|
||||
commits: {
|
||||
totalCommitContributions: 3,
|
||||
},
|
||||
reviews: {
|
||||
totalPullRequestReviewContributions: 0,
|
||||
},
|
||||
pullRequests: { totalCount: 1 },
|
||||
openIssues: { totalCount: 1 },
|
||||
closedIssues: { totalCount: 0 },
|
||||
followers: { totalCount: 0 },
|
||||
repositories: {
|
||||
totalCount: 1,
|
||||
nodes: [{ name: REPO, stargazers: { totalCount: 1 } }],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
endCursor: "cursor",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const 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_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 REPO_MOCK_RESPONSE = {
|
||||
data: {
|
||||
user: {
|
||||
repository: {
|
||||
name: REPO,
|
||||
nameWithOwner: `${USER}/cra-test`,
|
||||
isPrivate: false,
|
||||
isArchived: false,
|
||||
isTemplate: false,
|
||||
stargazers: {
|
||||
totalCount: 1,
|
||||
},
|
||||
description: "Simple cra test repo.",
|
||||
primaryLanguage: {
|
||||
color: "#f1e05a",
|
||||
id: "MDg6TGFuZ3VhZ2UxNDA=",
|
||||
name: "JavaScript",
|
||||
},
|
||||
forkCount: 0,
|
||||
},
|
||||
},
|
||||
organization: null,
|
||||
},
|
||||
};
|
||||
|
||||
const 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()}`;
|
||||
|
||||
const mock = new MockAdapter(axios, { onNoMatch: "passthrough" });
|
||||
|
||||
const createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
|
||||
let router;
|
||||
|
||||
/**
|
||||
* Renders a card locally through the backend router.
|
||||
* @param {string} url Card URL to render through the router.
|
||||
* @returns {Promise<string>} Rendered SVG markup.
|
||||
*/
|
||||
async function getLocalSvg(url) {
|
||||
const req = {
|
||||
headers: {},
|
||||
url,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
return res.end.mock.calls[0][0];
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv("CACHE_SECONDS", "");
|
||||
vi.stubEnv("GIST_WHITELIST", "");
|
||||
vi.stubEnv("POSTGRES_URL", "");
|
||||
vi.stubEnv("WHITELIST", "");
|
||||
|
||||
vi.stubEnv("PAT_1", "dummyPAT1");
|
||||
vi.stubEnv("PAT_2", "dummyPAT2");
|
||||
|
||||
({ default: router } = await import("../../router.js"));
|
||||
|
||||
mock.onPost("https://api.github.com/graphql").reply((config) => {
|
||||
const { query, variables } = JSON.parse(config.data);
|
||||
|
||||
if (
|
||||
query.includes("query userInfo") &&
|
||||
variables?.login === STATS_CARD_USER
|
||||
) {
|
||||
return [200, STATS_MOCK_RESPONSE];
|
||||
}
|
||||
|
||||
if (query.includes("query userInfo") && variables?.login === USER) {
|
||||
return [200, TOP_LANGS_MOCK_RESPONSE];
|
||||
}
|
||||
|
||||
if (query.includes("query getRepo")) {
|
||||
return [200, REPO_MOCK_RESPONSE];
|
||||
}
|
||||
|
||||
if (query.includes("query gistInfo")) {
|
||||
return [200, GIST_MOCK_RESPONSE];
|
||||
}
|
||||
|
||||
return [500, { error: "Unhandled GraphQL request in e2e test" }];
|
||||
});
|
||||
|
||||
mock
|
||||
.onGet(
|
||||
`https://api.github.com/search/commits?per_page=1&q=author:${STATS_CARD_USER}`,
|
||||
)
|
||||
.reply(200, COMMITS_SEARCH_MOCK_RESPONSE);
|
||||
|
||||
mock
|
||||
.onGet(
|
||||
`https://wakatime.com/api/v1/users/${USER}/stats?is_including_today=true`,
|
||||
)
|
||||
.reply(200, WAKATIME_MOCK_RESPONSE);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("Fetch Cards", () => {
|
||||
const VERCEL_PREVIEW_URL = "https://github-stats-extended-preview.vercel.app";
|
||||
|
||||
test("retrieve stats card", async () => {
|
||||
expect(VERCEL_PREVIEW_URL).toBeDefined();
|
||||
|
||||
const cardPath = `/api?username=${STATS_CARD_USER}&include_all_commits=true&${CACHE_BURST_STRING}`;
|
||||
|
||||
// Check if the Vercel preview instance stats card function is up and running.
|
||||
await expect(
|
||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local stats card.
|
||||
const localStatsCardSVG = await getLocalSvg(cardPath);
|
||||
|
||||
// Get the Vercel preview stats card response.
|
||||
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);
|
||||
}, 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.
|
||||
await expect(
|
||||
axios.get(`${VERCEL_PREVIEW_URL}${cardPath}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local language card.
|
||||
const localLanguageCardSVG = await getLocalSvg(cardPath);
|
||||
|
||||
// Get the Vercel preview language card response.
|
||||
const serverLanguageSVG = await axios.get(
|
||||
`${VERCEL_PREVIEW_URL}${cardPath}`,
|
||||
);
|
||||
|
||||
// Check if language card from deployment matches the local language card.
|
||||
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}${cardPath}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local WakaTime card.
|
||||
const localWakaCardSVG = await getLocalSvg(cardPath);
|
||||
|
||||
// Get the Vercel preview WakaTime card response.
|
||||
const serverWakaTimeSvg = await axios.get(
|
||||
`${VERCEL_PREVIEW_URL}${cardPath}`,
|
||||
);
|
||||
|
||||
// Check if WakaTime card from deployment matches the local WakaTime card.
|
||||
expect(serverWakaTimeSvg.data).toEqual(localWakaCardSVG);
|
||||
}, 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}${cardPath}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local repo card.
|
||||
const localRepoCardSVG = await getLocalSvg(cardPath);
|
||||
|
||||
// Get the Vercel preview repo card response.
|
||||
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);
|
||||
}, 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}${cardPath}`),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Get local gist card.
|
||||
const localGistCardSVG = await getLocalSvg(cardPath);
|
||||
|
||||
// Get the Vercel preview gist card response.
|
||||
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);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { getConfig, gist } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import router from "../router.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
|
||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
});
|
||||
|
||||
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
storeRequest: vi.fn(),
|
||||
getUserAccessByName: vi.fn(),
|
||||
}));
|
||||
|
||||
const gistMock = vi.mocked(gist);
|
||||
const getConfigMock = vi.mocked(getConfig);
|
||||
const storeRequestMock = vi.mocked(storeRequest);
|
||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
||||
|
||||
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 use the shorter error cache for temporary gist errors", async () => {
|
||||
gistMock.mockResolvedValue({
|
||||
status: "error - temporary",
|
||||
content: "temporary-error-svg",
|
||||
});
|
||||
|
||||
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d");
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(gistMock).toHaveBeenCalledWith({
|
||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||
});
|
||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
||||
expect(res.setHeader.mock.calls).toEqual([
|
||||
["Cache-Control", errorCacheHeader],
|
||||
["Content-Type", "image/svg+xml"],
|
||||
]);
|
||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("temporary-error-svg");
|
||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
||||
});
|
||||
|
||||
it("should not persist permanent gist errors returned by core", async () => {
|
||||
gistMock.mockResolvedValue({
|
||||
status: "error - permanent",
|
||||
content: "permanent-error-svg",
|
||||
});
|
||||
|
||||
const req = createRequest("id=bbfce31e0217a3689c8d961a356cb10d");
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(gistMock).toHaveBeenCalledWith({
|
||||
id: "bbfce31e0217a3689c8d961a356cb10d",
|
||||
});
|
||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
||||
expect(res.setHeader.mock.calls).toEqual([
|
||||
["Cache-Control", defaultCacheHeader],
|
||||
["Content-Type", "image/svg+xml"],
|
||||
]);
|
||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("permanent-error-svg");
|
||||
expect(storeRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reject non-whitelisted gist ids before calling core logic", async () => {
|
||||
getConfigMock.mockReturnValue({ gistWhitelist: ["allowed-gist-id"] });
|
||||
|
||||
const req = createRequest("id=blocked-gist-id");
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(gistMock).not.toHaveBeenCalled();
|
||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
||||
expect(res.setHeader.mock.calls).toEqual([
|
||||
["Cache-Control", defaultCacheHeader],
|
||||
["Content-Type", "image/svg+xml"],
|
||||
]);
|
||||
expect(res.end).toHaveBeenCalledExactlyOnceWith(
|
||||
"render-error:This gist ID is not whitelisted",
|
||||
);
|
||||
expect(storeRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { pin } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import router from "../router.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
|
||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
"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,514 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test /api contract > should match the private missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api contract > should match the public blacklisted-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is blacklisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api contract > should match the public happy-path response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="450" height="195" viewBox="0 0 450 195" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId">Anurag Hazra's GitHub Stats, Rank: A+</title>
|
||||
<desc id="descId">Total Stars Earned: 14099, Total Commits (last year) : 200, Total PRs: 4000, Total Issues: 340, Contributed to (last year): 51</desc>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #2f80ed;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-text {
|
||||
font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58;
|
||||
animation: scaleInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-percentile-header {
|
||||
font-size: 14px;
|
||||
}
|
||||
.rank-percentile-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #4c71f2;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rank-circle-rim {
|
||||
stroke: #2f80ed;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.rank-circle {
|
||||
stroke: #2f80ed;
|
||||
stroke-dasharray: 250;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.8;
|
||||
transform-origin: -10px 8px;
|
||||
transform: rotate(-90deg);
|
||||
animation: rankAnimation 1s forwards ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes rankAnimation {
|
||||
from {
|
||||
stroke-dashoffset: 251.32741228718345;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 25.665014275610705;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="449" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">Anurag Hazra's GitHub Stats</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<g data-testid="rank-circle" transform="translate(365, 47.5)">
|
||||
<circle class="rank-circle-rim" cx="-10" cy="8" r="40"/>
|
||||
<circle class="rank-circle" cx="-10" cy="8" r="40"/>
|
||||
<g class="rank-text">
|
||||
|
||||
<text x="-5" y="3" alignment-baseline="central" dominant-baseline="central" text-anchor="middle" data-testid="level-rank-icon">
|
||||
A+
|
||||
</text>
|
||||
|
||||
</g>
|
||||
</g>
|
||||
<svg x="0" y="0">
|
||||
<g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Total Stars Earned:</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="stars">14.1k</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 25)">
|
||||
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Total Commits (last year):</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="commits">200</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 50)">
|
||||
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Total PRs:</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="prs">4k</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 75)">
|
||||
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Total Issues:</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="issues">340</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 100)">
|
||||
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)">
|
||||
|
||||
<text class="stat bold" y="12.5">Contributed to (last year):</text>
|
||||
<text class="stat bold" x="204.01" y="12.5" data-testid="contribs">51</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api contract > should match the public many-params response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="540" height="365" viewBox="0 0 540 365" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId">a custom title, Rank: A+</title>
|
||||
<desc id="descId">कुल अर्जित सितारे: 4100, कुल commits (2024) : 200, कुल PR: 4000, कुल PR का विलय: 3200, मर्ज किए गए PRs प्रतिशत: 80.0, कुल PRs की समीक्षा की गई: 1234, कुल चर्चाएँ शुरू हुईं: 222, कुल चर्चाओं के उत्तर: 111, (पिछले वर्ष) में योगदान दिया: 51</desc>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #123456;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #abcdef;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-text {
|
||||
font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef;
|
||||
animation: scaleInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.rank-percentile-header {
|
||||
font-size: 14px;
|
||||
}
|
||||
.rank-percentile-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #ff00aa;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rank-circle-rim {
|
||||
stroke: #654321;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.rank-circle {
|
||||
stroke: #654321;
|
||||
stroke-dasharray: 250;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.8;
|
||||
transform-origin: -10px 8px;
|
||||
transform: rotate(-90deg);
|
||||
animation: rankAnimation 1s forwards ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes rankAnimation {
|
||||
from {
|
||||
stroke-dashoffset: 251.32741228718345;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 26.378312814443284;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
* { animation-duration: 0s !important; animation-delay: 0s !important; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="539" fill="#0f172a" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">a custom title</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<g data-testid="rank-circle" transform="translate(463.5, 132.5)">
|
||||
<circle class="rank-circle-rim" cx="-10" cy="8" r="40"/>
|
||||
<circle class="rank-circle" cx="-10" cy="8" r="40"/>
|
||||
<g class="rank-text">
|
||||
|
||||
<svg x="-38" y="-30" height="66" width="66" aria-hidden="true" viewBox="0 0 16 16" version="1.1" data-view-component="true" data-testid="github-rank-icon">
|
||||
<path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g>
|
||||
<svg x="0" y="0">
|
||||
<g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल अर्जित सितारे:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="stars">4100</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 32)">
|
||||
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल commits (2024):</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="commits">200</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 64)">
|
||||
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल PR:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="prs">4000</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 96)">
|
||||
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल PR का विलय:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="prs_merged">3200</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 128)">
|
||||
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M13.442 2.558a.625.625 0 0 1 0 .884l-10 10a.625.625 0 1 1-.884-.884l10-10a.625.625 0 0 1 .884 0zM4.5 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm7 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">मर्ज किए गए PRs प्रतिशत:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="prs_merged_percentage">80.0 %</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 160)">
|
||||
<g class="stagger" style="animation-delay: 1200ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल PRs की समीक्षा की गई:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="reviews">1234</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 192)">
|
||||
<g class="stagger" style="animation-delay: 1350ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल चर्चाएँ शुरू हुईं:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="discussions_started">222</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 224)">
|
||||
<g class="stagger" style="animation-delay: 1500ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">कुल चर्चाओं के उत्तर:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="discussions_answered">111</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 256)">
|
||||
<g class="stagger" style="animation-delay: 1650ms" transform="translate(25, 0)">
|
||||
|
||||
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>
|
||||
</svg>
|
||||
|
||||
<text class="stat not_bold" x="25" y="12.5">(पिछले वर्ष) में योगदान दिया:</text>
|
||||
<text class="stat not_bold" x="219.01" y="12.5" data-testid="contribs">51</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\n query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n name\\n login\\n commits: contributionsCollection (from: $startTime) {\\n totalCommitContributions,\\n }\\n reviews: contributionsCollection {\\n totalPullRequestReviewContributions\\n }\\n repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {\\n totalCount\\n }\\n pullRequests(first: 1) {\\n totalCount\\n }\\n mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {\\n totalCount\\n }\\n openIssues: issues(states: OPEN) {\\n totalCount\\n }\\n closedIssues: issues(states: CLOSED) {\\n totalCount\\n }\\n followers {\\n totalCount\\n }\\n repositoryDiscussions @include(if: $includeDiscussions) {\\n totalCount\\n }\\n repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {\\n totalCount\\n }\\n \\n repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {\\n totalCount\\n nodes {\\n name\\n stargazers {\\n totalCount\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n\\n }\\n }\\n","variables":{"login":"anuraghazra","first":100,"after":null,"includeMergedPullRequests":true,"includeDiscussions":true,"includeDiscussionsAnswers":true,"startTime":"2024-01-01T00:00:00Z","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api contract > should match the public missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray"/>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api contract > should render error card in same theme as requested card 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#0a0f0b" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abd200 }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #68b587 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#0a0f0b" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray"/>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -1,258 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test /api/gist contract > should match the private missing-id response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This gist ID is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/gist contract > should match the public happy-path response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="400" height="180" viewBox="0 0 400 180" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #2f80ed;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
.description {
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #434d58;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.icon { fill: #586069 }
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="399" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(25, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">countries.json</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<text class="description" x="25" y="-5">
|
||||
<tspan dy="1.2em" x="25">List of countries and territories in English and Spanish: name,</tspan><tspan dy="1.2em" x="25">continent, capital, dial code, country codes, TLD, and area in</tspan><tspan dy="1.2em" x="25">sq km. Lista de países y territorios en Inglés y Español:</tspan><tspan dy="1.2em" x="25">nombre, continente, capital, código de teléfono, códigos de</tspan><tspan dy="1.2em" x="25">país, dominio y área en km cuadrados. Updated 2023</tspan>
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, 105)">
|
||||
<g transform="translate(0, 0)">
|
||||
<g data-testid="primary-lang">
|
||||
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#292929"/>
|
||||
<text data-testid="lang-name" class="gray" x="15">JSON</text>
|
||||
</g>
|
||||
</g><g transform="translate(53.67999954223633, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="starsCount" class="gray">33</text></g></g><g transform="translate(107.59999923706056, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="forksCount" class="gray">11</text></g></g>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/gist contract > should match the public many-params response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="400" height="180" viewBox="0 0 400 180" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #123456;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
.description {
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #abcdef;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
|
||||
.icon { fill: #ff00aa }
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="399" fill="#0f172a" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(25, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">Yizack/countries.json</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<text class="description" x="25" y="-5">
|
||||
<tspan dy="1.2em" x="25">List of countries and territories in English and Spanish: name,</tspan><tspan dy="1.2em" x="25">continent, capital, dial code, country codes, TLD, and area in</tspan><tspan dy="1.2em" x="25">sq km. Lista de países y territorios en Inglés y Español:</tspan><tspan dy="1.2em" x="25">nombre, continente, capital, código de teléfono, códigos de</tspan><tspan dy="1.2em" x="25">país, dominio y área en km cuadrados. Updated 2023</tspan>
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, 105)">
|
||||
<g transform="translate(0, 0)">
|
||||
<g data-testid="primary-lang">
|
||||
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#292929"/>
|
||||
<text data-testid="lang-name" class="gray" x="15">JSON</text>
|
||||
</g>
|
||||
</g><g transform="translate(53.67999954223633, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="starsCount" class="gray">33</text></g></g><g transform="translate(107.59999923706056, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="forksCount" class="gray">11</text></g></g>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\nquery gistInfo($gistName: String!) {\\n viewer {\\n gist(name: $gistName) {\\n description\\n owner {\\n login\\n }\\n stargazerCount\\n forks {\\n totalCount\\n }\\n files {\\n name\\n language {\\n name\\n }\\n size\\n }\\n }\\n }\\n}\\n","variables":{"gistName":"happy-gist-id"}}",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/gist contract > should match the public missing-id response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "id" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray">/api/gist?id=GIST_ID</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -1,412 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test /api/pin contract > should match the private missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should match the private non-whitelisted username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should match the public blacklisted-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is blacklisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should match the public happy-path response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="400" height="150" viewBox="0 0 400 150" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #2f80ed;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
.description {
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #434d58;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
||||
.badge rect { opacity: 0.2 }
|
||||
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #586069;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
* { animation-duration: 0s !important; animation-delay: 0s !important; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="399" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(25, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">convoychat</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
|
||||
|
||||
<text class="description" x="25" y="-5">
|
||||
<tspan dy="1.2em" x="25">Help us take over the world with a deeply customizable</tspan><tspan dy="1.2em" x="25">React, TypeScript and GraphQL chat app that has enough</tspan><tspan dy="1.2em" x="25">text to wrap across multiple lines in the repository card.</tspan>
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, 75)">
|
||||
<g transform="translate(0, 0)">
|
||||
<g data-testid="primary-lang">
|
||||
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#2b7489"/>
|
||||
<text data-testid="lang-name" class="gray" x="15">TypeScript</text>
|
||||
</g>
|
||||
</g><g transform="translate(80.53999967575072, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="stargazers" class="gray">38k</text></g></g><g transform="translate(140.4199994087219, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="forkcount" class="gray">100</text></g></g>
|
||||
</g>
|
||||
|
||||
<svg x="0" y="0"><g transform="translate(-3, 98)">
|
||||
|
||||
</g></svg>
|
||||
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should match the public many-params response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="560" height="233" viewBox="0 0 560 233" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #123456;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
.description {
|
||||
font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #abcdef;
|
||||
|
||||
}
|
||||
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
|
||||
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
|
||||
.badge rect { opacity: 0.2 }
|
||||
|
||||
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
.icon {
|
||||
fill: #ff00aa;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
* { animation-duration: 0s !important; animation-delay: 0s !important; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="559" fill="#0f172a" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<svg class="icon" x="0" y="-13" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(25, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">anuraghazra/convoychat</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
|
||||
|
||||
<text class="description" x="25" y="-5">
|
||||
<tspan dy="1.2em" x="25">Help us take over the world with a deeply customizable React, TypeScript and GraphQL...</tspan>
|
||||
</text>
|
||||
|
||||
<g transform="translate(30, 45)">
|
||||
<g transform="translate(0, 0)">
|
||||
<g data-testid="primary-lang">
|
||||
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="#2b7489"/>
|
||||
<text data-testid="lang-name" class="gray" x="15">TypeScript</text>
|
||||
</g>
|
||||
</g><g transform="translate(80.53999967575072, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="stargazers" class="gray">38k</text></g></g><g transform="translate(140.4199994087219, 0)"><g transform="translate(0, 0)">
|
||||
<svg class="icon" y="-12" viewBox="0 0 16 16" version="1.1" width="16" height="16">
|
||||
<path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
|
||||
</svg>
|
||||
</g><g transform="translate(20, 0)"><text data-testid="forkcount" class="gray">100</text></g></g>
|
||||
</g>
|
||||
|
||||
<svg x="0" y="0"><g transform="translate(-3, 68)">
|
||||
<g transform="translate(0, 0)"><g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20author%3Aanuraghazra&type=pullrequests">
|
||||
|
||||
<text class="stat bold" y="12.5">my created PRs:</text>
|
||||
<text class="stat bold" x="139.01" y="12.5" data-testid="prs_authored">1234</text></a>
|
||||
</g>
|
||||
</g><g transform="translate(210, 0)">
|
||||
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20commenter%3Aanuraghazra+-author%3Aanuraghazra&type=pullrequests">
|
||||
|
||||
<text class="stat bold" y="12.5">my commented PRs:</text>
|
||||
<text class="stat bold" x="139.01" y="12.5" data-testid="prs_commented">2345</text></a>
|
||||
</g>
|
||||
</g></g><g transform="translate(0, 30)"><g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20reviewed-by%3Aanuraghazra+-author%3Aanuraghazra&type=pullrequests">
|
||||
|
||||
<text class="stat bold" y="12.5">my reviewed PRs:</text>
|
||||
<text class="stat bold" x="139.01" y="12.5" data-testid="prs_reviewed">3456</text></a>
|
||||
</g>
|
||||
</g><g transform="translate(210, 0)">
|
||||
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20author%3Aanuraghazra&type=issues">
|
||||
|
||||
<text class="stat bold" y="12.5">my created issues:</text>
|
||||
<text class="stat bold" x="139.01" y="12.5" data-testid="issues_authored">4567</text></a>
|
||||
</g>
|
||||
</g></g><g transform="translate(0, 60)"><g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)"><a href="https://github.com/search?q=repo%3Aanuraghazra%2Fconvoychat%20commenter%3Aanuraghazra+-author%3Aanuraghazra&type=issues">
|
||||
|
||||
<text class="stat bold" y="12.5">my commented issues:</text>
|
||||
<text class="stat bold" x="139.01" y="12.5" data-testid="issues_commented">5678</text></a>
|
||||
</g>
|
||||
</g></g>
|
||||
</g></svg>
|
||||
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\n fragment RepoInfo on Repository {\\n name\\n nameWithOwner\\n isPrivate\\n isArchived\\n isTemplate\\n stargazers {\\n totalCount\\n }\\n description\\n primaryLanguage {\\n color\\n id\\n name\\n }\\n forkCount\\n }\\n query getRepo($login: String!, $repo: String!) {\\n user(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n organization(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","repo":"convoychat"}}",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should match the public missing-params response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username", "repo" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray">/api/pin?username=USERNAME&repo=REPO_NAME</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/pin contract > should render error card in same theme as requested card 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#0a0f0b" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abd200 }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #68b587 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#0a0f0b" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username", "repo" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray">/api/pin?username=USERNAME&repo=REPO_NAME</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -1,460 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test /api/top-langs contract > should match the private missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/top-langs contract > should match the private non-whitelisted username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/top-langs contract > should match the public blacklisted-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is blacklisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/top-langs contract > should match the public happy-path response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="300" height="285" viewBox="0 0 300 285" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #2f80ed;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
@keyframes slideInAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: calc(100%-100px);
|
||||
}
|
||||
}
|
||||
@keyframes growWidthAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.bold { font-weight: 700 }
|
||||
.lang-name {
|
||||
font: 400 11px "Segoe UI", Ubuntu, Sans-Serif;
|
||||
fill: #434d58;
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="299" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">Most Used Languages</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<svg data-testid="lang-items" x="25">
|
||||
<g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">Rust</text>
|
||||
<text x="215" y="34" class="lang-name">70.92%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<svg data-testid="lang-progress" width="70.92198581560284%">
|
||||
<rect height="8" fill="#dea584" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 750ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 40)">
|
||||
<g class="stagger" style="animation-delay: 600ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">HTML</text>
|
||||
<text x="215" y="34" class="lang-name">12.77%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<svg data-testid="lang-progress" width="12.76595744680851%">
|
||||
<rect height="8" fill="#0f0" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 900ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 80)">
|
||||
<g class="stagger" style="animation-delay: 750ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">JavaScript</text>
|
||||
<text x="215" y="34" class="lang-name">8.51%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<svg data-testid="lang-progress" width="8.51063829787234%">
|
||||
<rect height="8" fill="#0ff" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1050ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 120)">
|
||||
<g class="stagger" style="animation-delay: 900ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">TypeScript</text>
|
||||
<text x="215" y="34" class="lang-name">6.38%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<svg data-testid="lang-progress" width="6.382978723404255%">
|
||||
<rect height="8" fill="#3178c6" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1200ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 160)">
|
||||
<g class="stagger" style="animation-delay: 1050ms">
|
||||
<text data-testid="lang-name" x="2" y="15" class="lang-name">Java</text>
|
||||
<text x="215" y="34" class="lang-name">1.42%</text>
|
||||
|
||||
<svg width="205" x="0" y="25">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="205" height="8" fill="#ddd"/>
|
||||
<svg data-testid="lang-progress" width="2%">
|
||||
<rect height="8" fill="#f00" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1350ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/top-langs contract > should match the public many-params response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="420" height="110" viewBox="0 0 420 110" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #123456;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
@keyframes slideInAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: calc(100%-100px);
|
||||
}
|
||||
}
|
||||
@keyframes growWidthAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #abcdef;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.bold { font-weight: 700 }
|
||||
.lang-name {
|
||||
font: 400 11px "Segoe UI", Ubuntu, Sans-Serif;
|
||||
fill: #abcdef;
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
* { animation-duration: 0s !important; animation-delay: 0s !important; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="419" fill="#0f172a" stroke-opacity="0"/>
|
||||
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 25)">
|
||||
|
||||
<svg data-testid="lang-items" x="25">
|
||||
|
||||
|
||||
<mask id="rect-mask">
|
||||
<rect x="0" y="0" width="370" height="8" fill="white" rx="5"/>
|
||||
</mask>
|
||||
|
||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="0" y="0" width="256.98" height="8" fill="#3178c6"/>
|
||||
|
||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="256.98" y="0" width="60.57" height="8" fill="#f00"/>
|
||||
|
||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="317.55" y="0" width="52.45" height="8" fill="#00f"/>
|
||||
|
||||
|
||||
<g transform="translate(0, 25)">
|
||||
<g transform="translate(0, 0)"><g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms">
|
||||
<circle cx="5" cy="6" r="5" fill="#3178c6"/>
|
||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
||||
TypeScript 19.0 B
|
||||
</text>
|
||||
</g>
|
||||
</g><g transform="translate(0, 25)">
|
||||
<g class="stagger" style="animation-delay: 600ms">
|
||||
<circle cx="5" cy="6" r="5" fill="#f00"/>
|
||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
||||
Java 4.5 B
|
||||
</text>
|
||||
</g>
|
||||
</g></g><g transform="translate(150, 0)"><g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms">
|
||||
<circle cx="5" cy="6" r="5" fill="#00f"/>
|
||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
||||
Python 3.9 B
|
||||
</text>
|
||||
</g>
|
||||
</g></g>
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"graphqlRequest": "{"query":"\\n query userInfo($login: String!, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n # do not fetch forks\\n repositories(ownerAffiliations: $ownerAffiliations, isFork: false, first: 100) {\\n nodes {\\n name\\n languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {\\n edges {\\n size\\n node {\\n color\\n name\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","ownerAffiliations":["OWNER","COLLABORATOR"]}}",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/top-langs contract > should match the public missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray"/>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/top-langs contract > should render error card in same theme as requested card 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#0a0f0b" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abd200 }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #68b587 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#0a0f0b" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray"/>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -1,546 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test /api/wakatime contract > should match the private missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/wakatime contract > should match the private non-whitelisted username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">This username is not whitelisted</tspan>
|
||||
<tspan x="25" dy="18" class="gray">Please deploy your own instance</tspan>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/wakatime contract > should match the public happy-path response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="495" height="195" viewBox="0 0 495 195" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #2f80ed;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
|
||||
@keyframes slideInAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: calc(100%-100px);
|
||||
}
|
||||
}
|
||||
@keyframes growWidthAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="494" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">WakaTime Stats (last 7 days)</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<svg x="0" y="0" width="100%">
|
||||
<g transform="translate(0, 0)">
|
||||
<g class="stagger" style="animation-delay: 450ms" transform="translate(25, 0)">
|
||||
<text class="stat bold" y="12.5" data-testid="TypeScript">TypeScript:</text>
|
||||
<text class="stat" x="350" y="12.5">12 hrs</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<svg data-testid="lang-progress" width="52%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 750ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 25)">
|
||||
<g class="stagger" style="animation-delay: 600ms" transform="translate(25, 0)">
|
||||
<text class="stat bold" y="12.5" data-testid="JavaScript">JavaScript:</text>
|
||||
<text class="stat" x="350" y="12.5">6 hrs 30 mins</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<svg data-testid="lang-progress" width="28%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 900ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 50)">
|
||||
<g class="stagger" style="animation-delay: 750ms" transform="translate(25, 0)">
|
||||
<text class="stat bold" y="12.5" data-testid="Other">Other:</text>
|
||||
<text class="stat" x="350" y="12.5">3 hrs</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<svg data-testid="lang-progress" width="13%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1050ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 75)">
|
||||
<g class="stagger" style="animation-delay: 900ms" transform="translate(25, 0)">
|
||||
<text class="stat bold" y="12.5" data-testid="YAML">YAML:</text>
|
||||
<text class="stat" x="350" y="12.5">1 hr</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<svg data-testid="lang-progress" width="4%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1200ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g><g transform="translate(0, 100)">
|
||||
<g class="stagger" style="animation-delay: 1050ms" transform="translate(25, 0)">
|
||||
<text class="stat bold" y="12.5" data-testid="JSON">JSON:</text>
|
||||
<text class="stat" x="350" y="12.5">30 mins</text>
|
||||
|
||||
<svg width="220" x="110" y="4">
|
||||
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="220" height="8" fill="#434d58"/>
|
||||
<svg data-testid="lang-progress" width="3%">
|
||||
<rect height="8" fill="#2f80ed" rx="5" ry="5" x="0" y="0" class="lang-progress" style="animation-delay: 1350ms;"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/wakatime contract > should match the public inaccessible-profile response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="495" height="150" viewBox="0 0 495 150" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #2f80ed;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #434d58;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
|
||||
@keyframes slideInAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: calc(100%-100px);
|
||||
}
|
||||
}
|
||||
@keyframes growWidthAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="4.5" height="99%" stroke="#e4e2e2" width="494" fill="#fffefe" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">WakaTime Stats</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<svg x="0" y="0" width="100%">
|
||||
<g transform="translate(0, 0)">
|
||||
<text x="25" y="11" class="stat bold" fill="#434d58">WakaTime user profile not public</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/wakatime contract > should match the public many-params response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="620" height="140" viewBox="0 0 620 140" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="descId">
|
||||
<title id="titleId"/>
|
||||
<desc id="descId"/>
|
||||
<style>
|
||||
.header {
|
||||
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
||||
fill: #123456;
|
||||
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.header { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
|
||||
.stat {
|
||||
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: #abcdef;
|
||||
}
|
||||
@supports(-moz-appearance: auto) {
|
||||
/* Selector detects Firefox */
|
||||
.stat { font-size:12px; }
|
||||
}
|
||||
.stagger {
|
||||
opacity: 0;
|
||||
animation: fadeInAnimation 0.3s ease-in-out forwards;
|
||||
}
|
||||
.not_bold { font-weight: 400 }
|
||||
.bold { font-weight: 700 }
|
||||
|
||||
@keyframes slideInAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: calc(100%-100px);
|
||||
}
|
||||
}
|
||||
@keyframes growWidthAnimation {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef }
|
||||
#rect-mask rect{
|
||||
animation: slideInAnimation 1s ease-in-out forwards;
|
||||
}
|
||||
.lang-progress{
|
||||
animation: growWidthAnimation 0.6s ease-in-out forwards;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Animations */
|
||||
@keyframes scaleInAnimation {
|
||||
from {
|
||||
transform: translate(-5px, 5px) scale(0);
|
||||
}
|
||||
to {
|
||||
transform: translate(-5px, 5px) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInAnimation {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
* { animation-duration: 0s !important; animation-delay: 0s !important; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<rect data-testid="card-bg" x="0.5" y="0.5" rx="12" height="99%" stroke="#fedcba" width="619" fill="#0f172a" stroke-opacity="1"/>
|
||||
|
||||
|
||||
<g data-testid="card-title" transform="translate(25, 35)">
|
||||
<g transform="translate(0, 0)">
|
||||
<text x="0" y="0" class="header" data-testid="header">a custom title</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<g data-testid="main-card-body" transform="translate(0, 55)">
|
||||
|
||||
<svg x="0" y="0" width="100%">
|
||||
|
||||
<mask id="rect-mask">
|
||||
<rect x="25" y="0" width="565" height="8" fill="white" rx="5"/>
|
||||
</mask>
|
||||
|
||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="0" y="0" width="365.09200000000004" height="8" fill="#3178c6"/>
|
||||
|
||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="365.09200000000004" y="0" width="196.588" height="8" fill="#f1e05a"/>
|
||||
|
||||
<rect mask="url(#rect-mask)" data-testid="lang-progress" x="561.6800000000001" y="0" width="28.084" height="8" fill="#cb171e"/>
|
||||
|
||||
|
||||
<g transform="translate(25, 25)">
|
||||
<circle cx="5" cy="6" r="5" fill="#3178c6"/>
|
||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
||||
TypeScript - 61.88 %
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(292.5, 25)">
|
||||
<circle cx="5" cy="6" r="5" fill="#f1e05a"/>
|
||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
||||
JavaScript - 33.32 %
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(25, 50)">
|
||||
<circle cx="5" cy="6" r="5" fill="#cb171e"/>
|
||||
<text data-testid="lang-name" x="15" y="10" class="lang-name">
|
||||
YAML - 4.76 %
|
||||
</text>
|
||||
</g>
|
||||
|
||||
|
||||
</svg>
|
||||
|
||||
</g>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=36000, s-maxage=36000, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/wakatime contract > should match the public missing-username response snapshot 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#fffefe" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2f80ed }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#fffefe" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray"/>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Test /api/wakatime contract > should render error card in same theme as requested card 1`] = `
|
||||
{
|
||||
"content": "<svg width="576.5" height="120" viewBox="0 0 576.5 120" fill="#0a0f0b" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abd200 }
|
||||
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #68b587 }
|
||||
.gray { fill: #858585 }
|
||||
</style>
|
||||
<rect x="0.5" y="0.5" width="575.5" height="99%" rx="4.5" fill="#0a0f0b" stroke="#e4e2e2"/>
|
||||
<text x="25" y="45" class="text">Something went wrong!</text>
|
||||
<text data-testid="message" x="25" y="55" class="text small">
|
||||
<tspan x="25" dy="18">Missing params "username" make sure you pass the parameters in URL</tspan>
|
||||
<tspan x="25" dy="18" class="gray"/>
|
||||
</text>
|
||||
</svg>",
|
||||
"headers": [
|
||||
[
|
||||
"Cache-Control",
|
||||
"max-age=600, s-maxage=600, stale-while-revalidate=86400",
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"image/svg+xml",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -1,196 +0,0 @@
|
||||
// @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 { 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();
|
||||
});
|
||||
|
||||
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 = {
|
||||
headers: {},
|
||||
url: "/api?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_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()}`,
|
||||
};
|
||||
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",
|
||||
};
|
||||
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,143 +0,0 @@
|
||||
// @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 { 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();
|
||||
});
|
||||
|
||||
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 = {
|
||||
headers: {},
|
||||
url: "/api/gist?id=happy-gist-id",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public many-params response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
id: "happy-gist-id",
|
||||
title_color: "123456",
|
||||
icon_color: "ff00aa",
|
||||
text_color: "abcdef",
|
||||
bg_color: "0f172a",
|
||||
border_radius: "12",
|
||||
border_color: "fedcba",
|
||||
show_owner: "true",
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: `/api/gist?${params.toString()}`,
|
||||
};
|
||||
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-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,235 +0,0 @@
|
||||
// @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 { 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();
|
||||
});
|
||||
|
||||
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 = {
|
||||
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 });
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
username: "anuraghazra",
|
||||
repo: "convoychat",
|
||||
title_color: "123456",
|
||||
icon_color: "ff00aa",
|
||||
text_color: "abcdef",
|
||||
bg_color: "0f172a",
|
||||
card_width: "560",
|
||||
show_owner: "true",
|
||||
show: "prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented",
|
||||
show_icons: "false",
|
||||
number_format: "long",
|
||||
text_bold: "true",
|
||||
line_height: "30",
|
||||
border_radius: "12",
|
||||
border_color: "fedcba",
|
||||
description_lines_count: "1",
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: `/api/pin?${params.toString()}`,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
graphqlRequest: mock.history.post[0].data,
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public missing-params response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/pin",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render error card in same theme as requested card", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/pin?theme=merko",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public blacklisted-username response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/pin?username=renovate-bot&repo=convoychat",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private non-whitelisted username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/pin?username=martin-mfg",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private missing-username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/pin",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
// @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 { 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();
|
||||
});
|
||||
|
||||
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 = {
|
||||
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);
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
username: "anuraghazra",
|
||||
hide: "javascript,HTML",
|
||||
hide_title: "true",
|
||||
hide_border: "true",
|
||||
card_width: "420",
|
||||
title_color: "123456",
|
||||
text_color: "abcdef",
|
||||
bg_color: "0f172a",
|
||||
layout: "compact",
|
||||
langs_count: "3",
|
||||
exclude_repo: "repo-hidden",
|
||||
size_weight: "0.5",
|
||||
count_weight: "1",
|
||||
role: "OWNER,COLLABORATOR",
|
||||
disable_animations: "true",
|
||||
stats_format: "bytes",
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: `/api/top-langs?${params.toString()}`,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
graphqlRequest: mock.history.post[0].data,
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public missing-username response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/top-langs",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render error card in same theme as requested card", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/top-langs?theme=merko",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public blacklisted-username response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/top-langs?username=renovate-bot",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private non-whitelisted username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/top-langs?username=martin-mfg",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private missing-username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/top-langs",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,231 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { logger } from "@stats-organization/github-readme-stats-core";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { normalizeSvg, wakaTimeData } from "../utils.js";
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
|
||||
const wakaTimeProfileNotPublicData = {
|
||||
data: {
|
||||
viewer: {
|
||||
gist: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CACHE_SECONDS", "");
|
||||
vi.stubEnv("GIST_WHITELIST", "");
|
||||
vi.stubEnv("POSTGRES_URL", "");
|
||||
vi.stubEnv("WHITELIST", "");
|
||||
|
||||
mock
|
||||
.onGet(
|
||||
"https://wakatime.com/api/v1/users/anuraghazra/stats?is_including_today=true",
|
||||
)
|
||||
.reply(200, wakaTimeData);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vi.spyOn(logger, "log").mockImplementation(() => {});
|
||||
vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
vi.unstubAllEnvs();
|
||||
// modules may cache environment variables, so we need to reset them
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Test /api/wakatime contract", () => {
|
||||
it("should match the public happy-path response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/wakatime?username=anuraghazra",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public many-params response snapshot", async () => {
|
||||
mock.reset(); // to verify api_domain param is working
|
||||
mock
|
||||
.onGet(
|
||||
"https://wakatime.local/api/v1/users/anuraghazra/stats?is_including_today=true",
|
||||
)
|
||||
.reply(200, wakaTimeData);
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
username: "anuraghazra",
|
||||
title_color: "123456",
|
||||
text_color: "abcdef",
|
||||
bg_color: "0f172a",
|
||||
card_width: "620",
|
||||
custom_title: "a custom title",
|
||||
layout: "compact",
|
||||
langs_count: "3",
|
||||
hide: "other",
|
||||
api_domain: "wakatime.local",
|
||||
border_radius: "12",
|
||||
border_color: "fedcba",
|
||||
display_format: "percent",
|
||||
disable_animations: "true",
|
||||
});
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: `/api/wakatime?${params.toString()}`,
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public missing-username response snapshot", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/wakatime",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render error card in same theme as requested card", async () => {
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/wakatime?theme=merko",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the public inaccessible-profile response snapshot", async () => {
|
||||
mock.reset();
|
||||
mock
|
||||
.onGet(
|
||||
"https://wakatime.com/api/v1/users/anuraghazra/stats?is_including_today=true",
|
||||
)
|
||||
.reply(200, wakaTimeProfileNotPublicData);
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/wakatime?username=anuraghazra",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private non-whitelisted username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/wakatime?username=martin-mfg",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should match the private missing-username response snapshot", async () => {
|
||||
vi.stubEnv("WHITELIST", "anuraghazra");
|
||||
|
||||
const { default: router } = await import("../../router.js");
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
url: "/api/wakatime",
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(res.end).toHaveBeenCalledOnce();
|
||||
|
||||
expect({
|
||||
headers: res.setHeader.mock.calls,
|
||||
content: normalizeSvg(res.end.mock.calls[0][0]),
|
||||
}).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { topLangs } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import router from "../router.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
|
||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
});
|
||||
|
||||
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
storeRequest: vi.fn(),
|
||||
getUserAccessByName: vi.fn(),
|
||||
}));
|
||||
|
||||
const topLangsMock = vi.mocked(topLangs);
|
||||
const storeRequestMock = vi.mocked(storeRequest);
|
||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
||||
|
||||
const createRequest = (search = "") => ({
|
||||
headers: {},
|
||||
url: `/api/top-langs?${search}`,
|
||||
});
|
||||
|
||||
const createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
|
||||
const defaultCacheHeader =
|
||||
`max-age=${CACHE_TTL.TOP_LANGS_CARD.DEFAULT}, ` +
|
||||
`s-maxage=${CACHE_TTL.TOP_LANGS_CARD.DEFAULT}, ` +
|
||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
|
||||
|
||||
beforeEach(() => {
|
||||
topLangsMock.mockReset();
|
||||
storeRequestMock.mockReset().mockResolvedValue(undefined);
|
||||
getUserAccessByNameMock.mockReset().mockResolvedValue(null);
|
||||
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
|
||||
delete process.env.CACHE_SECONDS;
|
||||
});
|
||||
|
||||
describe("Test /api/top-langs backend routing", () => {
|
||||
it("happy path should pass query params and user PAT, respond with top languages content and persist request", async () => {
|
||||
getUserAccessByNameMock.mockResolvedValue({ token: "user-pat" });
|
||||
topLangsMock.mockResolvedValue({
|
||||
status: "success",
|
||||
content: "mock-top-langs-svg",
|
||||
});
|
||||
|
||||
const req = createRequest(
|
||||
"username=anuraghazra&layout=compact&langs_count=5",
|
||||
);
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra");
|
||||
expect(topLangsMock).toHaveBeenCalledWith(
|
||||
{
|
||||
username: "anuraghazra",
|
||||
layout: "compact",
|
||||
langs_count: "5",
|
||||
},
|
||||
"user-pat",
|
||||
);
|
||||
expect(req.query).toEqual({
|
||||
username: "anuraghazra",
|
||||
layout: "compact",
|
||||
langs_count: "5",
|
||||
});
|
||||
expect(res.setHeader.mock.calls).toEqual([
|
||||
["Cache-Control", defaultCacheHeader],
|
||||
["Content-Type", "image/svg+xml"],
|
||||
]);
|
||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-top-langs-svg");
|
||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
||||
});
|
||||
});
|
||||
@@ -1,299 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const data_stats = {
|
||||
data: {
|
||||
user: {
|
||||
name: "Anurag Hazra",
|
||||
login: "anuraghazra",
|
||||
repositoriesContributedTo: { totalCount: 51 },
|
||||
commits: {
|
||||
totalCommitContributions: 200,
|
||||
},
|
||||
reviews: {
|
||||
totalPullRequestReviewContributions: 1234,
|
||||
},
|
||||
pullRequests: { totalCount: 4000 },
|
||||
mergedPullRequests: { totalCount: 3200 },
|
||||
openIssues: { totalCount: 300 },
|
||||
closedIssues: { totalCount: 40 },
|
||||
followers: { totalCount: 150 },
|
||||
repositoryDiscussions: { totalCount: 222 },
|
||||
repositoryDiscussionComments: {
|
||||
totalCount: 111,
|
||||
},
|
||||
repositories: {
|
||||
totalCount: 3,
|
||||
nodes: [
|
||||
{ name: "repo-keep-1", stargazers: { totalCount: 1500 } },
|
||||
{ name: "repo-exclude-me", stargazers: { totalCount: 9999 } },
|
||||
{ name: "repo-keep-2", stargazers: { totalCount: 2600 } },
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
endCursor: "cursor",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const happy_path_gist_data = {
|
||||
data: {
|
||||
viewer: {
|
||||
gist: {
|
||||
description:
|
||||
"List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023",
|
||||
owner: {
|
||||
login: "Yizack",
|
||||
},
|
||||
stargazerCount: 33,
|
||||
forks: {
|
||||
totalCount: 11,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
name: "countries.json",
|
||||
language: {
|
||||
name: "JSON",
|
||||
},
|
||||
size: 85858,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const data_user = {
|
||||
data: {
|
||||
user: {
|
||||
repository: {
|
||||
username: "anuraghazra",
|
||||
name: "convoychat",
|
||||
nameWithOwner: "anuraghazra/convoychat",
|
||||
stargazers: {
|
||||
totalCount: 38000,
|
||||
},
|
||||
description:
|
||||
"Help us take over the world with a deeply customizable React, TypeScript and GraphQL chat app that has enough text to wrap across multiple lines in the repository card.",
|
||||
primaryLanguage: {
|
||||
color: "#2b7489",
|
||||
id: "MDg6TGFuZ3VhZ2UyODc=",
|
||||
name: "TypeScript",
|
||||
},
|
||||
forkCount: 100,
|
||||
isTemplate: false,
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
organization: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const data_langs = {
|
||||
data: {
|
||||
user: {
|
||||
repositories: {
|
||||
nodes: [
|
||||
{
|
||||
name: "repo-html",
|
||||
languages: {
|
||||
edges: [{ size: 180, node: { color: "#0f0", name: "HTML" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repo-javascript",
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 120, node: { color: "#0ff", name: "JavaScript" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repo-ts-1",
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 45, node: { color: "#3178c6", name: "TypeScript" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repo-ts-2",
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 45, node: { color: "#3178c6", name: "TypeScript" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repo-other-langs",
|
||||
languages: {
|
||||
edges: [
|
||||
{ size: 20, node: { color: "#f00", name: "Java" } },
|
||||
{ size: 10, node: { color: "#0f0", name: "Cobol" } },
|
||||
{ size: 15, node: { color: "#00f", name: "Python" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "repo-hidden",
|
||||
languages: {
|
||||
edges: [{ size: 1000, node: { color: "#dea584", name: "Rust" } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const wakaTimeData = {
|
||||
data: {
|
||||
categories: [
|
||||
{
|
||||
digital: "22:40",
|
||||
hours: 22,
|
||||
minutes: 40,
|
||||
name: "Coding",
|
||||
percent: 100,
|
||||
text: "22 hrs 40 mins",
|
||||
total_seconds: 81643.570077,
|
||||
},
|
||||
],
|
||||
daily_average: 16095,
|
||||
daily_average_including_other_language: 16329,
|
||||
days_including_holidays: 7,
|
||||
days_minus_holidays: 5,
|
||||
editors: [
|
||||
{
|
||||
digital: "22:40",
|
||||
hours: 22,
|
||||
minutes: 40,
|
||||
name: "VS Code",
|
||||
percent: 100,
|
||||
text: "22 hrs 40 mins",
|
||||
total_seconds: 81643.570077,
|
||||
},
|
||||
],
|
||||
holidays: 2,
|
||||
human_readable_daily_average: "4 hrs 28 mins",
|
||||
human_readable_daily_average_including_other_language: "4 hrs 32 mins",
|
||||
human_readable_total: "22 hrs 21 mins",
|
||||
human_readable_total_including_other_language: "22 hrs 40 mins",
|
||||
id: "random hash",
|
||||
is_already_updating: false,
|
||||
is_coding_activity_visible: true,
|
||||
is_including_today: false,
|
||||
is_other_usage_visible: true,
|
||||
is_stuck: false,
|
||||
is_up_to_date: true,
|
||||
languages: [
|
||||
{
|
||||
digital: "12:00",
|
||||
hours: 12,
|
||||
minutes: 0,
|
||||
name: "TypeScript",
|
||||
percent: 52,
|
||||
text: "12 hrs",
|
||||
total_seconds: 43200,
|
||||
},
|
||||
{
|
||||
digital: "6:30",
|
||||
hours: 6,
|
||||
minutes: 30,
|
||||
name: "JavaScript",
|
||||
percent: 28,
|
||||
text: "6 hrs 30 mins",
|
||||
total_seconds: 23400,
|
||||
},
|
||||
{
|
||||
digital: "3:00",
|
||||
hours: 3,
|
||||
minutes: 0,
|
||||
name: "Other",
|
||||
percent: 13,
|
||||
text: "3 hrs",
|
||||
total_seconds: 10800,
|
||||
},
|
||||
{
|
||||
digital: "1:00",
|
||||
hours: 1,
|
||||
minutes: 0,
|
||||
name: "YAML",
|
||||
percent: 4,
|
||||
text: "1 hr",
|
||||
total_seconds: 3600,
|
||||
},
|
||||
{
|
||||
digital: "0:30",
|
||||
hours: 0,
|
||||
minutes: 30,
|
||||
name: "JSON",
|
||||
percent: 3,
|
||||
text: "30 mins",
|
||||
total_seconds: 1800,
|
||||
},
|
||||
],
|
||||
operating_systems: [
|
||||
{
|
||||
digital: "22:40",
|
||||
hours: 22,
|
||||
minutes: 40,
|
||||
name: "Mac",
|
||||
percent: 100,
|
||||
text: "22 hrs 40 mins",
|
||||
total_seconds: 81643.570077,
|
||||
},
|
||||
],
|
||||
percent_calculated: 100,
|
||||
range: "last_7_days",
|
||||
status: "ok",
|
||||
timeout: 15,
|
||||
total_seconds: 80473.135716,
|
||||
total_seconds_including_other_language: 81643.570077,
|
||||
user_id: "random hash",
|
||||
username: "anuraghazra",
|
||||
writes_only: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** @typedef {import('@stats-organization/github-readme-stats-core')} CoreModule */
|
||||
|
||||
/**
|
||||
* Creates a mock module for @stats-organization/github-readme-stats-core.
|
||||
* @returns {CoreModule} Mocked core module.
|
||||
*/
|
||||
export function mockCore() {
|
||||
return {
|
||||
// @ts-expect-error no need to mock themes at the moment
|
||||
themes: {},
|
||||
request: vi.fn(),
|
||||
fetchWakatimeStats: vi.fn(),
|
||||
retryer: vi.fn(),
|
||||
dateDiff: vi.fn(),
|
||||
api: vi.fn(),
|
||||
gist: vi.fn(),
|
||||
pin: vi.fn(),
|
||||
topLangs: vi.fn(),
|
||||
wakatime: vi.fn(),
|
||||
getConfig: vi.fn().mockReturnValue({}),
|
||||
renderError: ({ message }) => `render-error:${message}`,
|
||||
clampValue: (value, min, max) => Math.min(Math.max(value, min), max),
|
||||
logger: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes SVG code for stable test comparisons.
|
||||
* @param {string} svg SVG code to normalize.
|
||||
* @returns {string} Normalized SVG code.
|
||||
*/
|
||||
export function normalizeSvg(svg) {
|
||||
const document = new DOMParser().parseFromString(svg, "image/svg+xml");
|
||||
return new XMLSerializer().serializeToString(document);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { wakatime } from "@stats-organization/github-readme-stats-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import router from "../router.js";
|
||||
import { CACHE_TTL, DURATIONS } from "../src/common/cache.js";
|
||||
import { getUserAccessByName, storeRequest } from "../src/common/database.js";
|
||||
|
||||
vi.mock(import("@stats-organization/github-readme-stats-core"), async () => {
|
||||
const { mockCore } = await import("./utils.js");
|
||||
return mockCore();
|
||||
});
|
||||
|
||||
vi.mock(import("../src/common/database.js"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
storeRequest: vi.fn(),
|
||||
getUserAccessByName: vi.fn(),
|
||||
}));
|
||||
|
||||
const wakatimeMock = vi.mocked(wakatime);
|
||||
const storeRequestMock = vi.mocked(storeRequest);
|
||||
const getUserAccessByNameMock = vi.mocked(getUserAccessByName);
|
||||
|
||||
const createRequest = (search = "") => ({
|
||||
headers: {},
|
||||
url: `/api/wakatime?${search}`,
|
||||
});
|
||||
|
||||
const createResponse = () => ({
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
});
|
||||
|
||||
const defaultCacheHeader =
|
||||
`max-age=${CACHE_TTL.WAKATIME_CARD.DEFAULT}, ` +
|
||||
`s-maxage=${CACHE_TTL.WAKATIME_CARD.DEFAULT}, ` +
|
||||
`stale-while-revalidate=${DURATIONS.ONE_DAY}`;
|
||||
|
||||
beforeEach(() => {
|
||||
wakatimeMock.mockReset();
|
||||
storeRequestMock.mockReset().mockResolvedValue(undefined);
|
||||
getUserAccessByNameMock.mockReset().mockResolvedValue(null);
|
||||
// CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally
|
||||
delete process.env.CACHE_SECONDS;
|
||||
});
|
||||
|
||||
describe("Test /api/wakatime backend routing", () => {
|
||||
it("happy path should pass query params, respond with wakatime content and persist request", async () => {
|
||||
wakatimeMock.mockResolvedValue({
|
||||
status: "success",
|
||||
content: "mock-wakatime-svg",
|
||||
});
|
||||
|
||||
const req = createRequest("username=anuraghazra&theme=dark&layout=compact");
|
||||
const res = createResponse();
|
||||
|
||||
await router(req, res);
|
||||
|
||||
expect(wakatimeMock).toHaveBeenCalledWith({
|
||||
username: "anuraghazra",
|
||||
theme: "dark",
|
||||
layout: "compact",
|
||||
});
|
||||
expect(getUserAccessByNameMock).not.toHaveBeenCalled();
|
||||
expect(req.query).toEqual({
|
||||
username: "anuraghazra",
|
||||
theme: "dark",
|
||||
layout: "compact",
|
||||
});
|
||||
expect(res.setHeader.mock.calls).toEqual([
|
||||
["Cache-Control", defaultCacheHeader],
|
||||
["Content-Type", "image/svg+xml"],
|
||||
]);
|
||||
expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-wakatime-svg");
|
||||
expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": ["../../tsconfig.base.json"],
|
||||
"include": [
|
||||
"src",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.bench.ts",
|
||||
"vitest.config.e2e.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": ["./tsconfig.json"],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"customConditions": []
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"buildCommand": "cd ../../ && git clean ./apps -fx && pnpm run build:packages && pnpm --filter ./apps/backend/ --legacy --prod deploy ./apps/deployment/ && mv ./apps/deployment/node_modules/ ./apps/backend/node_modules/ && ./vercel-preparation.sh",
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/",
|
||||
"destination": "https://github.com/stats-organization/github-stats-extended"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ["@stats/source"],
|
||||
},
|
||||
test: {
|
||||
dir: "./tests/bench",
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./tests/_setup.js"],
|
||||
},
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ["@stats/source"],
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
dir: "tests/e2e",
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineProject } from "vitest/config";
|
||||
|
||||
export default defineProject({
|
||||
resolve: {
|
||||
conditions: ["@stats/source"],
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: [
|
||||
"./tests/*.test.{ts,js}",
|
||||
"./tests/public-instance/*.test.{ts,js}",
|
||||
],
|
||||
setupFiles: ["./tests/_setup.js"],
|
||||
},
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("load initial page correctly", async ({ page }) => {
|
||||
await page.goto("");
|
||||
|
||||
// Expect a title "to contain" a substring.
|
||||
await expect(page).toHaveTitle(/GitHub Stats Extended/);
|
||||
|
||||
// Page title / header branding
|
||||
await expect(page.getByText("GitHub Stats Extended")).toBeVisible();
|
||||
|
||||
// Logo exists
|
||||
await expect(page.locator('img[alt="logo"]')).toBeVisible();
|
||||
|
||||
// Star on GitHub button
|
||||
const starButton = page.getByRole("button", { name: /star on/i });
|
||||
await expect(starButton).toBeVisible();
|
||||
|
||||
const githubLink = page.locator(
|
||||
'a[href*="github.com/stats-organization/github-stats-extended"]',
|
||||
);
|
||||
await expect(githubLink).toHaveAttribute("target", "_blank");
|
||||
|
||||
// Login buttons
|
||||
const publicAccessBtn = page.getByRole("button", {
|
||||
name: /github public access/i,
|
||||
});
|
||||
await expect(publicAccessBtn).toBeVisible();
|
||||
await expect(publicAccessBtn).toBeEnabled();
|
||||
|
||||
const privateAccessBtn = page.getByRole("button", {
|
||||
name: /github private access/i,
|
||||
});
|
||||
await expect(privateAccessBtn).toBeVisible();
|
||||
await expect(privateAccessBtn).toBeEnabled();
|
||||
|
||||
const guestBtn = page.getByRole("button", { name: /continue as guest/i });
|
||||
await expect(guestBtn).toBeVisible();
|
||||
await expect(guestBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test("navigates between steps", async ({ page }) => {
|
||||
await page.goto("");
|
||||
|
||||
// We are at stage 1
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText("Login");
|
||||
|
||||
// Go to stage 2
|
||||
await page.getByRole("button", { name: "Select card" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText(
|
||||
"Select a Card",
|
||||
);
|
||||
|
||||
// Go to stage 3
|
||||
await page.getByRole("button", { name: "Modify parameters" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText(
|
||||
"Modify Card Parameters",
|
||||
);
|
||||
|
||||
// Go to stage 4
|
||||
await page.getByRole("button", { name: "Select theme" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText(
|
||||
"Choose a Theme",
|
||||
);
|
||||
|
||||
// Go to stage 5
|
||||
await page.getByRole("button", { name: "Display card" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText(
|
||||
"Display your Card",
|
||||
);
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"name": "@stats-organization/github-readme-stats-frontend",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc -p tsconfig.typecheck.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "catalog:default",
|
||||
"axios-cache-interceptor": "^1",
|
||||
"daisyui": "^5.5.23",
|
||||
"@stats-organization/github-readme-stats-backend": "workspace:^",
|
||||
"@stats-organization/github-readme-stats-core": "workspace:^",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-spinners": "^0.17.0",
|
||||
"react-toastify": "^11.1.0",
|
||||
"redux": "^5.0.1",
|
||||
"save-svg-as-png": "^1.4.17",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"clsx": "2.1.1",
|
||||
"tailwindcss": "4.3.0",
|
||||
"vite": "catalog:default",
|
||||
"vitest": "catalog:default"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
const baseURL = "http://localhost:5173/frontend/";
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env["CI"],
|
||||
/* Retry on CI only */
|
||||
retries: process.env["CI"] ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('')`. */
|
||||
baseURL,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
|
||||
// {
|
||||
// name: "firefox",
|
||||
// use: { ...devices["Desktop Firefox"] },
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: "webkit",
|
||||
// use: { ...devices["Desktop Safari"] },
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "pnpm run dev",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env["CI"],
|
||||
},
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import axios, { getAdapter } from "axios";
|
||||
import { setupCache } from "axios-cache-interceptor";
|
||||
|
||||
import {
|
||||
DEMO_GIST,
|
||||
DEMO_REPO,
|
||||
DEMO_USER,
|
||||
DEMO_WAKATIME_USER,
|
||||
HOST,
|
||||
} from "./constants";
|
||||
import additionalUserStars from "./mockData/additional_user_stars.json" with { type: "json" };
|
||||
import commentedIssues from "./mockData/commented_issues.json" with { type: "json" };
|
||||
import commentedPrs from "./mockData/commented_prs.json" with { type: "json" };
|
||||
import commits from "./mockData/commits.json" with { type: "json" };
|
||||
import gist_graphql from "./mockData/gist-graphql.json" with { type: "json" };
|
||||
import gist_rest from "./mockData/gist-rest.json" with { type: "json" };
|
||||
import repository from "./mockData/repository.json" with { type: "json" };
|
||||
import reviewedPrs from "./mockData/reviewed_prs.json" with { type: "json" };
|
||||
import topLanguages from "./mockData/top_languages.json" with { type: "json" };
|
||||
import userStats from "./mockData/user_stats.json" with { type: "json" };
|
||||
import wakatimeProxy from "./mockData/wakatime_proxy.json" with { type: "json" };
|
||||
|
||||
const cachedAxios = setupCache(axios, {
|
||||
// Cache for 30 minutes
|
||||
ttl: 30 * 60 * 1000,
|
||||
interpretHeader: false,
|
||||
cacheTakeover: false,
|
||||
methods: ["get", "post"],
|
||||
cachePredicate: {
|
||||
allowUrls: ["api.github.com", HOST],
|
||||
},
|
||||
});
|
||||
|
||||
axios.get = cachedAxios.get.bind(cachedAxios);
|
||||
axios.post = cachedAxios.post.bind(cachedAxios);
|
||||
|
||||
export function clearAxiosCache(): void {
|
||||
void cachedAxios.storage.clear?.();
|
||||
}
|
||||
|
||||
function createMockResponse<TData, TConfig>(
|
||||
data: TData,
|
||||
config: TConfig,
|
||||
): Promise<{
|
||||
data: TData;
|
||||
status: 200;
|
||||
statusText: string;
|
||||
headers: Record<string, never>;
|
||||
request: Record<string, never>;
|
||||
config: TConfig;
|
||||
}> {
|
||||
return Promise.resolve({
|
||||
data,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
request: {},
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
// store shouldMock outside React context so the interceptor can access it
|
||||
let shouldMock = false;
|
||||
|
||||
export function setShouldMock(newShouldMock: boolean): void {
|
||||
shouldMock = newShouldMock;
|
||||
}
|
||||
|
||||
const defaultAdapter = getAdapter(axios.defaults.adapter);
|
||||
|
||||
// mock responses to "anuraghazra" requests
|
||||
axios.defaults.adapter = async (config) => {
|
||||
if (!shouldMock) {
|
||||
return defaultAdapter(config);
|
||||
}
|
||||
|
||||
interface Params {
|
||||
query?: string;
|
||||
variables?: {
|
||||
login?: string;
|
||||
repo?: string;
|
||||
gistName?: string;
|
||||
};
|
||||
}
|
||||
const params = (
|
||||
config.data ? JSON.parse(config.data as string) : {}
|
||||
) as Params;
|
||||
|
||||
if (
|
||||
config.url === "https://api.github.com/graphql" &&
|
||||
params.query?.includes(
|
||||
"query userInfo($login: String!, $after: String, $includeMergedPullRequests:",
|
||||
) &&
|
||||
params.variables?.login === DEMO_USER
|
||||
) {
|
||||
return createMockResponse(userStats, config);
|
||||
}
|
||||
|
||||
if (
|
||||
config.url === "https://api.github.com/graphql" &&
|
||||
params.query?.includes(
|
||||
"query userInfo($login: String!, $after: String, $ownerAffiliations:",
|
||||
) &&
|
||||
params.variables?.login === DEMO_USER
|
||||
) {
|
||||
return createMockResponse(additionalUserStars, config);
|
||||
}
|
||||
|
||||
if (
|
||||
config.url === "https://api.github.com/graphql" &&
|
||||
params.query?.includes(
|
||||
"query userInfo($login: String!, $ownerAffiliations:",
|
||||
) &&
|
||||
params.variables?.login === DEMO_USER
|
||||
) {
|
||||
return createMockResponse(topLanguages, config);
|
||||
}
|
||||
|
||||
if (
|
||||
config.url === "https://api.github.com/graphql" &&
|
||||
params.query?.includes("fragment RepoInfo on Repository {") &&
|
||||
params.variables &&
|
||||
params.variables.login === DEMO_REPO.split("/")[0] &&
|
||||
params.variables.repo === DEMO_REPO.split("/")[1]
|
||||
) {
|
||||
return createMockResponse(repository, config);
|
||||
}
|
||||
|
||||
if (
|
||||
config.url === "https://api.github.com/graphql" &&
|
||||
params.query?.includes("query gistInfo(") &&
|
||||
params.variables?.gistName === DEMO_GIST
|
||||
) {
|
||||
return createMockResponse(gist_graphql, config);
|
||||
}
|
||||
|
||||
switch (config.url) {
|
||||
case `https://api.github.com/gists/${DEMO_GIST}`:
|
||||
return createMockResponse(gist_rest, config);
|
||||
case `https://api.github.com/search/commits?per_page=1&q=author:${DEMO_USER}`:
|
||||
return createMockResponse(commits, config);
|
||||
case `https://api.github.com/search/issues?per_page=1&q=commenter:${DEMO_USER}+-author:${DEMO_USER}+type:pr`:
|
||||
return createMockResponse(commentedPrs, config);
|
||||
case `https://api.github.com/search/issues?per_page=1&q=reviewed-by:${DEMO_USER}+-author:${DEMO_USER}+type:pr`:
|
||||
return createMockResponse(reviewedPrs, config);
|
||||
case `https://api.github.com/search/issues?per_page=1&q=commenter:${DEMO_USER}+-author:${DEMO_USER}+type:issue`:
|
||||
return createMockResponse(commentedIssues, config);
|
||||
case `https://${HOST}/api/wakatime-proxy?username=${DEMO_WAKATIME_USER}`:
|
||||
return createMockResponse(wakatimeProxy, config);
|
||||
default:
|
||||
return defaultAdapter(config);
|
||||
}
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { CardImage } from "./CardImage";
|
||||
|
||||
interface CardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
imageSrc: string;
|
||||
stage: number;
|
||||
selected?: boolean;
|
||||
compact?: boolean;
|
||||
fixedSize?: boolean;
|
||||
}
|
||||
|
||||
export const Card = ({
|
||||
title,
|
||||
description,
|
||||
imageSrc,
|
||||
stage,
|
||||
selected = false,
|
||||
compact = false,
|
||||
fixedSize = false,
|
||||
}: CardProps): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("p-6 rounded border-2", {
|
||||
"h-[370px] w-[510px]": fixedSize,
|
||||
"border-blue-500 bg-blue-50": selected,
|
||||
"border-gray-200 bg-white hover:bg-gray-50": !selected,
|
||||
})}
|
||||
>
|
||||
<h2 className="text-xl font-medium title-font text-gray-900">{title}</h2>
|
||||
<p className="text-base leading-relaxed mt-2 mb-4">{description}</p>
|
||||
<CardImage
|
||||
imageSrc={imageSrc}
|
||||
compact={compact}
|
||||
className={clsx({ "flex justify-center": fixedSize })}
|
||||
stage={stage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
import { HOST } from "../../constants";
|
||||
|
||||
import { SvgInline } from "./SvgInline";
|
||||
|
||||
interface CardImageProps {
|
||||
imageSrc: string;
|
||||
stage: number;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CardImage = ({
|
||||
imageSrc,
|
||||
stage,
|
||||
compact = false,
|
||||
className,
|
||||
}: CardImageProps) => {
|
||||
const fullImageSrc = `https://${HOST}/api${imageSrc}&client=wizard`;
|
||||
|
||||
return (
|
||||
<div className={clsx("w-full relative", className)}>
|
||||
<SvgInline
|
||||
className="object-cover"
|
||||
url={fullImageSrc}
|
||||
compact={compact}
|
||||
stage={stage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,141 +0,0 @@
|
||||
// @ts-expect-error type info should be added later
|
||||
import { router } from "@stats-organization/github-readme-stats-backend";
|
||||
import { loadConfigFromEnv } from "@stats-organization/github-readme-stats-core";
|
||||
import axios from "axios";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
|
||||
import { setShouldMock } from "../../axios-override.js";
|
||||
import { createMockRequest, createMockResponse } from "../../mock-http.js";
|
||||
import {
|
||||
useIsAuthenticated,
|
||||
useUserToken,
|
||||
} from "../../redux/selectors/userSelectors.js";
|
||||
|
||||
interface SvgInlineProps {
|
||||
url: string;
|
||||
stage: number;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
forceLoading?: boolean;
|
||||
}
|
||||
|
||||
export function SvgInline(props: SvgInlineProps): JSX.Element {
|
||||
const {
|
||||
url,
|
||||
stage,
|
||||
className,
|
||||
compact = false,
|
||||
forceLoading = false,
|
||||
} = props;
|
||||
|
||||
const [svg, setSvg] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const userToken = useUserToken();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
// provide shouldMock to non-react code in axios-override.js
|
||||
useEffect(() => {
|
||||
setShouldMock(stage === 0 || !isAuthenticated);
|
||||
}, [isAuthenticated, stage]);
|
||||
|
||||
useEffect(() => {
|
||||
let isCurrent = true;
|
||||
|
||||
const loadSvg = async () => {
|
||||
const config: Record<string, string | undefined> = {
|
||||
FETCH_MULTI_PAGE_STARS: "10",
|
||||
PAT_1: userToken as string, // even if it's null, core's retryer.js sees there is 1 PAT and sets `RETRIES` accordingly
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
loadConfigFromEnv(config);
|
||||
|
||||
setLoaded(false);
|
||||
|
||||
let body: string;
|
||||
let status;
|
||||
|
||||
if (isAuthenticated && (!userToken || userToken === "placeholderPAT")) {
|
||||
// waiting for backend call to private-access
|
||||
return;
|
||||
}
|
||||
|
||||
if (stage === 4 && !isAuthenticated) {
|
||||
const res = await axios.get<string>(url);
|
||||
body = res.data;
|
||||
status = res.status;
|
||||
} else {
|
||||
const req = createMockRequest({
|
||||
method: "GET",
|
||||
url,
|
||||
});
|
||||
const res = createMockResponse();
|
||||
// will be solved by npm package
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
await router(req, res);
|
||||
body = res._getBody() as string;
|
||||
status = res._getStatusCode();
|
||||
}
|
||||
|
||||
if (status >= 300) {
|
||||
console.error("failed to fetch/generate SVG");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCurrent) {
|
||||
return;
|
||||
}
|
||||
setSvg(body);
|
||||
setLoaded(true);
|
||||
};
|
||||
void loadSvg();
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [userToken, isAuthenticated, url, stage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded && svg && containerRef.current) {
|
||||
// Attach shadow root if not already present
|
||||
let shadow = containerRef.current.shadowRoot;
|
||||
if (!shadow) {
|
||||
shadow = containerRef.current.attachShadow({ mode: "open" });
|
||||
}
|
||||
// Clear previous content
|
||||
shadow.innerHTML = "";
|
||||
// Insert SVG
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = svg;
|
||||
shadow.appendChild(wrapper);
|
||||
}
|
||||
}, [loaded, svg]);
|
||||
|
||||
if (forceLoading || !loaded) {
|
||||
if (compact) {
|
||||
return (
|
||||
<Skeleton key="compactSkeleton" style={{ paddingBottom: "58%" }} />
|
||||
);
|
||||
}
|
||||
// maximum dimensions of cards in SelectCard stage
|
||||
return (
|
||||
<div className=" w-[450px]">
|
||||
<Skeleton key="skeleton" className="h-[245px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render a container div for the shadow DOM
|
||||
// Using a different key than the skeletons above to ensure react doesn't reuse the node, which would keep its old shadow DOM content visible.
|
||||
return (
|
||||
<div
|
||||
key="svgWrapper"
|
||||
ref={containerRef}
|
||||
id="svgWrapper"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { HTMLProps, JSX, ReactNode } from "react";
|
||||
|
||||
interface ButtonProps extends HTMLProps<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps): JSX.Element {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"border-0 py-2 px-6 inline-flex focus:outline-none rounded-[0.25rem] text-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { JSX, ReactNode } from "react";
|
||||
|
||||
interface CheckboxProps {
|
||||
question: ReactNode;
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
question,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled = false,
|
||||
}: CheckboxProps): JSX.Element {
|
||||
return (
|
||||
<label className="flex inline-row mt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={checked}
|
||||
className="checkbox bg-white text-[#18181b] mr-2"
|
||||
onChange={() => {
|
||||
onCheckedChange(!checked);
|
||||
}}
|
||||
/>
|
||||
{question}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { JSX } from "react";
|
||||
|
||||
export interface SelectOption {
|
||||
id: number;
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
options: Array<SelectOption>;
|
||||
selectedOption: SelectOption;
|
||||
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
|
||||
onOptionChange: (option: SelectOption) => void;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
options,
|
||||
selectedOption,
|
||||
onOptionChange,
|
||||
disabled,
|
||||
className,
|
||||
}: SelectProps): JSX.Element {
|
||||
return (
|
||||
<select
|
||||
className={clsx(
|
||||
"text-gray-700 text-base bg-white select select-sm w-40 rounded-sm mt-4",
|
||||
className,
|
||||
)}
|
||||
value={selectedOption.value}
|
||||
onChange={(e) => {
|
||||
onOptionChange(options[e.target.selectedIndex] as SelectOption);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
className={clsx({
|
||||
"bg-blue-200": option.value === selectedOption.value,
|
||||
})}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { Checkbox } from "../Generic/Checkbox";
|
||||
|
||||
import { Section } from "./Section";
|
||||
|
||||
interface CheckboxSectionProps {
|
||||
title: string;
|
||||
question: string;
|
||||
checked: boolean;
|
||||
|
||||
text?: string;
|
||||
disabled?: boolean;
|
||||
|
||||
onCheckedChange: (check: boolean) => void;
|
||||
}
|
||||
|
||||
export function CheckboxSection({
|
||||
title,
|
||||
text,
|
||||
question,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled = false,
|
||||
}: CheckboxSectionProps): JSX.Element {
|
||||
return (
|
||||
<Section title={title}>
|
||||
{text && <p>{text}</p>}
|
||||
<Checkbox
|
||||
question={question}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { Select } from "../Generic/Select";
|
||||
import type { SelectOption } from "../Generic/Select";
|
||||
|
||||
import { Section } from "./Section";
|
||||
|
||||
export const DEFAULT_OPTION: SelectOption = {
|
||||
id: 1,
|
||||
label: "Normal",
|
||||
disabled: false,
|
||||
value: "normal",
|
||||
};
|
||||
|
||||
const options: Array<SelectOption> = [
|
||||
DEFAULT_OPTION,
|
||||
{
|
||||
id: 2,
|
||||
label: "Compact",
|
||||
disabled: false,
|
||||
value: "compact",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: "Donut",
|
||||
disabled: false,
|
||||
value: "donut",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: "Vertical Donut",
|
||||
disabled: false,
|
||||
value: "donut-vertical",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
label: "Pie",
|
||||
disabled: false,
|
||||
value: "pie",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
label: "Only Languages",
|
||||
disabled: false,
|
||||
value: "compact&hide_progress=true",
|
||||
},
|
||||
];
|
||||
|
||||
interface LanguagesLayoutSectionProps {
|
||||
selectedLanguageLayoutOption: SelectOption;
|
||||
onLanguageLayoutOptionChange: (option: SelectOption) => void;
|
||||
}
|
||||
|
||||
export function LanguagesLayoutSection({
|
||||
selectedLanguageLayoutOption,
|
||||
onLanguageLayoutOptionChange,
|
||||
}: LanguagesLayoutSectionProps): JSX.Element {
|
||||
return (
|
||||
<Section title="Card Layout">
|
||||
<p>Select a card layout.</p>
|
||||
<Select
|
||||
options={options}
|
||||
selectedOption={selectedLanguageLayoutOption}
|
||||
onOptionChange={onLanguageLayoutOptionChange}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { JSX, ReactNode } from "react";
|
||||
|
||||
import { Section } from "./Section";
|
||||
|
||||
interface NumericSectionProps {
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
value?: number | undefined;
|
||||
onValueChange: (value: number | undefined) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function NumericSection({
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
onValueChange,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
}: NumericSectionProps): JSX.Element {
|
||||
const [internalValue, setInternalValue] = useState(() => value?.toString());
|
||||
const debounceTimeout = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Debounce onValueChange
|
||||
if (debounceTimeout.current) {
|
||||
clearTimeout(debounceTimeout.current);
|
||||
}
|
||||
if (internalValue === value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
debounceTimeout.current = window.setTimeout(() => {
|
||||
const maybeNumber = internalValue && parseInt(internalValue, 10);
|
||||
if (typeof maybeNumber !== "number" || Number.isNaN(maybeNumber)) {
|
||||
onValueChange(undefined);
|
||||
} else {
|
||||
onValueChange(maybeNumber);
|
||||
}
|
||||
}, 700);
|
||||
|
||||
return () => {
|
||||
clearTimeout(debounceTimeout.current as number);
|
||||
};
|
||||
}, [internalValue, onValueChange, value]);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value?.toString());
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Section title={title}>
|
||||
<p>{description}</p>
|
||||
<input
|
||||
type="number"
|
||||
className="border border-gray-300 bg-white rounded px-2 py-1 mt-2 w-1/4"
|
||||
value={internalValue ?? ""}
|
||||
onChange={(e) => {
|
||||
setInternalValue(e.target.value);
|
||||
}}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { JSX, MouseEventHandler } from "react";
|
||||
import {
|
||||
FaArrowLeft as LeftArrowIcon,
|
||||
FaArrowRight as RightArrowIcon,
|
||||
} from "react-icons/fa";
|
||||
|
||||
interface ProgressSectionProps {
|
||||
num: number;
|
||||
item: string;
|
||||
passed: boolean;
|
||||
isActive: boolean;
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
function ProgressSection({
|
||||
num,
|
||||
item,
|
||||
passed,
|
||||
isActive,
|
||||
onClick,
|
||||
}: ProgressSectionProps): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"w-1/4 flex flex-col mx-2 p-2 cursor-pointer",
|
||||
passed ? "border-blue-500" : "border-gray-500",
|
||||
isActive ? "border-t-[14px] -mt-[5px]" : "border-t-4",
|
||||
)}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={clsx("text-lg font-bold", {
|
||||
"text-blue-500": passed,
|
||||
"text-gray-500": !passed,
|
||||
"-mt-[4px]": isActive,
|
||||
})}
|
||||
>
|
||||
{`Step ${num + 1}`}
|
||||
</div>
|
||||
<div className={passed ? "text-gray-700" : "text-gray-500"}>{item}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProgressBarProps {
|
||||
items: Array<string>;
|
||||
currItemIndex: number;
|
||||
onItemClick: (itemIndex: number) => void;
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
items,
|
||||
currItemIndex,
|
||||
onItemClick,
|
||||
}: ProgressBarProps): JSX.Element {
|
||||
const leftDisabled = currItemIndex === 0;
|
||||
const rightDisabled = currItemIndex === items.length - 1;
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center sticky top-0 bg-gray-200 z-50 pt-3 pb-1 px-1 md:px-20 shadow-md">
|
||||
<LeftArrowIcon
|
||||
className={clsx("w-8 h-8", {
|
||||
"text-gray-400 cursor-not-allowed": leftDisabled,
|
||||
"text-gray-700 cursor-pointer": !leftDisabled,
|
||||
})}
|
||||
onClick={() => {
|
||||
onItemClick(Math.max(currItemIndex - 1, 0));
|
||||
}}
|
||||
/>
|
||||
<div className="px-2 flex-grow flex flex-row">
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<ProgressSection
|
||||
num={index}
|
||||
key={item} // each step should have a unique name
|
||||
item={item}
|
||||
passed={currItemIndex >= index}
|
||||
isActive={currItemIndex === index}
|
||||
onClick={() => {
|
||||
onItemClick(index);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<RightArrowIcon
|
||||
className={clsx("w-8 h-8", {
|
||||
"text-gray-400 cursor-not-allowed": rightDisabled,
|
||||
"text-gray-700 cursor-pointer": !rightDisabled,
|
||||
})}
|
||||
onClick={() => {
|
||||
onItemClick(Math.min(currItemIndex + 1, items.length - 1));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { Select } from "../Generic/Select";
|
||||
import type { SelectOption } from "../Generic/Select";
|
||||
|
||||
import { Section } from "./Section";
|
||||
|
||||
export const DEFAULT_OPTION: SelectOption = {
|
||||
id: 1,
|
||||
label: "Rank",
|
||||
value: "default",
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
const options: Array<SelectOption> = [
|
||||
DEFAULT_OPTION,
|
||||
{ id: 2, label: "Percentile", value: "percentile", disabled: false },
|
||||
{ id: 3, label: "GitHub", value: "github", disabled: false },
|
||||
{ id: 4, label: "None", value: "default&hide_rank=true", disabled: false },
|
||||
];
|
||||
|
||||
interface StatsRankSectionProps {
|
||||
selectedOption: SelectOption;
|
||||
onOptionChange: (option: SelectOption) => void;
|
||||
}
|
||||
|
||||
export function StatsRankSection({
|
||||
selectedOption,
|
||||
onOptionChange,
|
||||
}: StatsRankSectionProps): JSX.Element {
|
||||
return (
|
||||
<Section title="Progress Style">
|
||||
<p>Select a progress style.</p>
|
||||
<Select
|
||||
options={options}
|
||||
selectedOption={selectedOption}
|
||||
onOptionChange={onOptionChange}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ClipboardEventHandler, JSX, ReactNode } from "react";
|
||||
|
||||
import { Section } from "./Section";
|
||||
|
||||
interface TextSectionProps {
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
|
||||
onPaste?: ClipboardEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export function TextSection({
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
onPaste,
|
||||
}: TextSectionProps): JSX.Element {
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
const debounceTimeout = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
// Debounce onValueChange
|
||||
if (debounceTimeout.current) {
|
||||
window.clearTimeout(debounceTimeout.current);
|
||||
}
|
||||
if (internalValue === value) {
|
||||
return undefined;
|
||||
}
|
||||
debounceTimeout.current = window.setTimeout(() => {
|
||||
onValueChange(internalValue);
|
||||
}, 700);
|
||||
// return cleanup function:
|
||||
return () => {
|
||||
window.clearTimeout(debounceTimeout.current as number);
|
||||
};
|
||||
}, [internalValue, onValueChange, value]);
|
||||
|
||||
return (
|
||||
<Section title={title}>
|
||||
<p>{description}</p>
|
||||
<input
|
||||
type="text"
|
||||
className={clsx(
|
||||
"border border-gray-300 rounded px-2 py-1 mt-2 w-3/4 min-w-48 max-w-xl",
|
||||
{ "cursor-not-allowed bg-gray-100": disabled, "bg-white": !disabled },
|
||||
)}
|
||||
value={internalValue}
|
||||
onChange={(e) => {
|
||||
setInternalValue(e.target.value);
|
||||
}}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { Select } from "../Generic/Select";
|
||||
import type { SelectOption } from "../Generic/Select";
|
||||
|
||||
import { Section } from "./Section";
|
||||
|
||||
export const DEFAULT_OPTION: SelectOption = {
|
||||
id: 1,
|
||||
label: "Normal",
|
||||
disabled: false,
|
||||
value: "default",
|
||||
};
|
||||
|
||||
const options: Array<SelectOption> = [
|
||||
DEFAULT_OPTION,
|
||||
{
|
||||
id: 2,
|
||||
label: "Compact",
|
||||
disabled: false,
|
||||
value: "compact",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: "Text Only",
|
||||
disabled: false,
|
||||
value: "default&hide_progress=true&card_width=315",
|
||||
},
|
||||
];
|
||||
|
||||
interface WakatimeLayoutSectionProps {
|
||||
selectedOption: SelectOption;
|
||||
onOptionChange: (option: SelectOption) => void;
|
||||
}
|
||||
|
||||
export function WakatimeLayoutSection({
|
||||
selectedOption,
|
||||
onOptionChange,
|
||||
}: WakatimeLayoutSectionProps): JSX.Element {
|
||||
return (
|
||||
<Section title="Card Layout">
|
||||
<p>Select a card layout.</p>
|
||||
<Select
|
||||
options={options}
|
||||
selectedOption={selectedOption}
|
||||
onOptionChange={onOptionChange}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export const USE_LOGGER = true as boolean;
|
||||
|
||||
export const CLIENT_ID = "Ov23lilAc5biyyRY0K1u";
|
||||
|
||||
export const HOST = window.location.host;
|
||||
|
||||
const REDIRECT_URI = `https://${HOST}/frontend`;
|
||||
|
||||
export const GITHUB_PRIVATE_AUTH_URL = `https://github.com/login/oauth/authorize?scope=user,repo&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}?mode=private`;
|
||||
export const GITHUB_PUBLIC_AUTH_URL = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}?mode=public`;
|
||||
|
||||
export const DEMO_USER = "anuraghazra" as string;
|
||||
export const DEMO_REPO = "anuraghazra/github-readme-stats" as string;
|
||||
export const DEMO_GIST = "bbfce31e0217a3689c8d961a356cb10d" as string;
|
||||
export const DEMO_WAKATIME_USER = "alan" as string;
|
||||
|
||||
// imported backend code expects process.env to be defined
|
||||
window.process = {
|
||||
env: {},
|
||||
} as (typeof window)["process"];
|
||||
Vendored
-14
@@ -1,14 +0,0 @@
|
||||
declare global {
|
||||
interface CustomProcess {
|
||||
env: {
|
||||
FETCH_MULTI_PAGE_STARS: string | undefined;
|
||||
PAT_1: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
interface Window {
|
||||
process?: CustomProcess;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,20 +0,0 @@
|
||||
import "./axios-override";
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
|
||||
import { AppTrends } from "./pages/App/AppTrends";
|
||||
import { store } from "./redux/store";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
const root = createRoot(document.getElementById("root") as HTMLElement);
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<Provider store={store}>
|
||||
<AppTrends />
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,96 +0,0 @@
|
||||
type HeaderMap = Partial<Record<string, string>>;
|
||||
|
||||
type CreateMockRequestOptions = {
|
||||
url: string;
|
||||
headers?: HeaderMap;
|
||||
} & (
|
||||
| {
|
||||
method: "GET";
|
||||
}
|
||||
| {
|
||||
method: "POST";
|
||||
body: unknown;
|
||||
}
|
||||
);
|
||||
|
||||
type CreateMockRequestResult = {
|
||||
url: string;
|
||||
headers: HeaderMap;
|
||||
} & (
|
||||
| {
|
||||
method: "GET";
|
||||
}
|
||||
| {
|
||||
method: "POST";
|
||||
body: unknown;
|
||||
}
|
||||
);
|
||||
|
||||
export function createMockRequest(
|
||||
options: CreateMockRequestOptions,
|
||||
): CreateMockRequestResult {
|
||||
const { headers = {}, ...rest } = options;
|
||||
|
||||
return { ...rest, headers };
|
||||
}
|
||||
|
||||
interface CreateMockResponseResult {
|
||||
statusCode: number;
|
||||
chunks: Array<unknown>;
|
||||
|
||||
setHeader(name: string, value: string): void;
|
||||
getHeader(name: string): string | undefined;
|
||||
getHeaders(): HeaderMap;
|
||||
|
||||
write(chunk: unknown): void;
|
||||
end(chunk: unknown): void;
|
||||
|
||||
// --- Helpers for inspection ---
|
||||
_getStatusCode(): number;
|
||||
_getHeaders(): HeaderMap;
|
||||
_getBody(): unknown;
|
||||
}
|
||||
|
||||
export function createMockResponse(): CreateMockResponseResult {
|
||||
const statusCode = 200;
|
||||
const headers: HeaderMap = {};
|
||||
const chunks: Array<unknown> = [];
|
||||
|
||||
const res: CreateMockResponseResult = {
|
||||
statusCode,
|
||||
chunks,
|
||||
|
||||
setHeader(name, value) {
|
||||
headers[name.toLowerCase()] = value;
|
||||
},
|
||||
getHeader(name) {
|
||||
return headers[name.toLowerCase()];
|
||||
},
|
||||
getHeaders() {
|
||||
return { ...headers };
|
||||
},
|
||||
|
||||
write(chunk) {
|
||||
if (typeof chunk !== "string") {
|
||||
chunk = String(chunk);
|
||||
}
|
||||
chunks.push(chunk);
|
||||
},
|
||||
|
||||
end(chunk) {
|
||||
res.write(chunk);
|
||||
},
|
||||
|
||||
_getStatusCode() {
|
||||
return statusCode;
|
||||
},
|
||||
_getHeaders() {
|
||||
return { ...headers };
|
||||
},
|
||||
_getBody() {
|
||||
return chunks.join("");
|
||||
},
|
||||
};
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
{
|
||||
"id": "6c0b00b7-f277-4454-b889-6c58c9f488c9",
|
||||
"user_id": "66b6796d-eb84-4bb9-b9d2-8dc882f4c6ac",
|
||||
"range": "last_7_days",
|
||||
"timeout": 15,
|
||||
"writes_only": false,
|
||||
"ai_additions": 16,
|
||||
"ai_deletions": 0,
|
||||
"human_additions": 0,
|
||||
"human_deletions": 0,
|
||||
"holidays": 2,
|
||||
"status": "ok",
|
||||
"categories": [
|
||||
{
|
||||
"name": "Coding",
|
||||
"total_seconds": 8673.080165,
|
||||
"percent": 81.4,
|
||||
"digital": "2:24",
|
||||
"decimal": "2.40",
|
||||
"text": "2 hrs 24 mins",
|
||||
"hours": 2,
|
||||
"minutes": 24
|
||||
},
|
||||
{
|
||||
"name": "Meeting",
|
||||
"total_seconds": 1500,
|
||||
"percent": 14.08,
|
||||
"digital": "0:25",
|
||||
"decimal": "0.42",
|
||||
"text": "25 mins",
|
||||
"hours": 0,
|
||||
"minutes": 25
|
||||
},
|
||||
{
|
||||
"name": "AI Coding",
|
||||
"total_seconds": 390.931491,
|
||||
"percent": 3.67,
|
||||
"digital": "0:06",
|
||||
"decimal": "0.10",
|
||||
"text": "6 mins",
|
||||
"hours": 0,
|
||||
"minutes": 6
|
||||
},
|
||||
{
|
||||
"name": "Writing Docs",
|
||||
"total_seconds": 91,
|
||||
"percent": 0.85,
|
||||
"digital": "0:01",
|
||||
"decimal": "0.02",
|
||||
"text": "1 min",
|
||||
"hours": 0,
|
||||
"minutes": 1
|
||||
}
|
||||
],
|
||||
"percent_calculated": 100,
|
||||
"human_readable_total_including_other_language": "2 hrs 57 mins",
|
||||
"human_readable_daily_average": "29 mins",
|
||||
"languages": [
|
||||
{
|
||||
"name": "HTML",
|
||||
"total_seconds": 2771,
|
||||
"percent": 26.01,
|
||||
"digital": "0:46",
|
||||
"decimal": "0.77",
|
||||
"text": "46 mins",
|
||||
"hours": 0,
|
||||
"minutes": 46
|
||||
},
|
||||
{
|
||||
"name": "YAML",
|
||||
"total_seconds": 2465,
|
||||
"percent": 23.13,
|
||||
"digital": "0:41",
|
||||
"decimal": "0.68",
|
||||
"text": "41 mins",
|
||||
"hours": 0,
|
||||
"minutes": 41
|
||||
},
|
||||
{
|
||||
"name": "Other",
|
||||
"total_seconds": 1710.011656,
|
||||
"percent": 16.05,
|
||||
"digital": "0:28",
|
||||
"decimal": "0.47",
|
||||
"text": "28 mins",
|
||||
"hours": 0,
|
||||
"minutes": 28
|
||||
},
|
||||
{
|
||||
"name": "Python",
|
||||
"total_seconds": 1449,
|
||||
"percent": 13.6,
|
||||
"digital": "0:24",
|
||||
"decimal": "0.40",
|
||||
"text": "24 mins",
|
||||
"hours": 0,
|
||||
"minutes": 24
|
||||
},
|
||||
{
|
||||
"name": "Text",
|
||||
"total_seconds": 797,
|
||||
"percent": 7.48,
|
||||
"digital": "0:13",
|
||||
"decimal": "0.22",
|
||||
"text": "13 mins",
|
||||
"hours": 0,
|
||||
"minutes": 13
|
||||
},
|
||||
{
|
||||
"name": "XML",
|
||||
"total_seconds": 554,
|
||||
"percent": 5.2,
|
||||
"digital": "0:09",
|
||||
"decimal": "0.15",
|
||||
"text": "9 mins",
|
||||
"hours": 0,
|
||||
"minutes": 9
|
||||
},
|
||||
{
|
||||
"name": "JSON",
|
||||
"total_seconds": 263,
|
||||
"percent": 2.47,
|
||||
"digital": "0:04",
|
||||
"decimal": "0.07",
|
||||
"text": "4 mins",
|
||||
"hours": 0,
|
||||
"minutes": 4
|
||||
},
|
||||
{
|
||||
"name": "Go",
|
||||
"total_seconds": 232,
|
||||
"percent": 2.18,
|
||||
"digital": "0:03",
|
||||
"decimal": "0.05",
|
||||
"text": "3 mins",
|
||||
"hours": 0,
|
||||
"minutes": 3
|
||||
},
|
||||
{
|
||||
"name": "solution",
|
||||
"total_seconds": 170,
|
||||
"percent": 1.6,
|
||||
"digital": "0:02",
|
||||
"decimal": "0.03",
|
||||
"text": "2 mins",
|
||||
"hours": 0,
|
||||
"minutes": 2
|
||||
},
|
||||
{
|
||||
"name": "Less",
|
||||
"total_seconds": 95,
|
||||
"percent": 0.89,
|
||||
"digital": "0:01",
|
||||
"decimal": "0.02",
|
||||
"text": "1 min",
|
||||
"hours": 0,
|
||||
"minutes": 1
|
||||
},
|
||||
{
|
||||
"name": "Markdown",
|
||||
"total_seconds": 91,
|
||||
"percent": 0.85,
|
||||
"digital": "0:01",
|
||||
"decimal": "0.02",
|
||||
"text": "1 min",
|
||||
"hours": 0,
|
||||
"minutes": 1
|
||||
},
|
||||
{
|
||||
"name": "C#",
|
||||
"total_seconds": 58,
|
||||
"percent": 0.54,
|
||||
"digital": "0:00",
|
||||
"decimal": "0.00",
|
||||
"text": "0 secs",
|
||||
"hours": 0,
|
||||
"minutes": 0
|
||||
}
|
||||
],
|
||||
"total_seconds_including_other_language": 10655.011656,
|
||||
"human_readable_total": "2 hrs 29 mins",
|
||||
"is_already_updating": false,
|
||||
"human_readable_daily_average_including_other_language": "35 mins",
|
||||
"is_up_to_date": true,
|
||||
"operating_systems": [
|
||||
{
|
||||
"total_seconds": 9155.011656,
|
||||
"name": "Mac",
|
||||
"percent": 85.92,
|
||||
"digital": "2:32",
|
||||
"decimal": "2.53",
|
||||
"text": "2 hrs 32 mins",
|
||||
"hours": 2,
|
||||
"minutes": 32
|
||||
},
|
||||
{
|
||||
"total_seconds": 1500,
|
||||
"name": "Unknown OS",
|
||||
"percent": 14.08,
|
||||
"digital": "0:25",
|
||||
"decimal": "0.42",
|
||||
"text": "25 mins",
|
||||
"hours": 0,
|
||||
"minutes": 25
|
||||
}
|
||||
],
|
||||
"is_stuck": false,
|
||||
"daily_average_including_other_language": 2131,
|
||||
"days_minus_holidays": 5,
|
||||
"days_including_holidays": 7,
|
||||
"daily_average": 1789,
|
||||
"is_up_to_date_pending_future": false,
|
||||
"editors": [
|
||||
{
|
||||
"total_seconds": 8764.080165,
|
||||
"name": "Vim",
|
||||
"percent": 82.25,
|
||||
"digital": "2:26",
|
||||
"decimal": "2.43",
|
||||
"text": "2 hrs 26 mins",
|
||||
"hours": 2,
|
||||
"minutes": 26
|
||||
},
|
||||
{
|
||||
"total_seconds": 1500,
|
||||
"name": "Google Calendar",
|
||||
"percent": 14.08,
|
||||
"digital": "0:25",
|
||||
"decimal": "0.42",
|
||||
"text": "25 mins",
|
||||
"hours": 0,
|
||||
"minutes": 25
|
||||
},
|
||||
{
|
||||
"total_seconds": 390.931491,
|
||||
"name": "Claude Code",
|
||||
"percent": 3.67,
|
||||
"digital": "0:06",
|
||||
"decimal": "0.10",
|
||||
"text": "6 mins",
|
||||
"hours": 0,
|
||||
"minutes": 6
|
||||
}
|
||||
],
|
||||
"total_seconds": 8945,
|
||||
"is_cached": true,
|
||||
"username": "alan",
|
||||
"is_including_today": false,
|
||||
"human_readable_range": "last week",
|
||||
"is_coding_activity_visible": true,
|
||||
"is_language_usage_visible": true,
|
||||
"is_editor_usage_visible": true,
|
||||
"is_category_usage_visible": true,
|
||||
"is_os_usage_visible": true
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export const CardType = {
|
||||
STATS: "stats",
|
||||
TOP_LANGS: "top-langs",
|
||||
PIN: "pin",
|
||||
GIST: "gist",
|
||||
WAKATIME: "wakatime",
|
||||
} as const;
|
||||
export type CardType = (typeof CardType)[keyof typeof CardType];
|
||||
@@ -1,28 +0,0 @@
|
||||
export const STAGE_LABELS = [
|
||||
{
|
||||
title: "Login",
|
||||
shortTitle: "Login",
|
||||
},
|
||||
{
|
||||
title: "Select a Card",
|
||||
shortTitle: "Select Card",
|
||||
},
|
||||
{
|
||||
title: "Modify Card Parameters",
|
||||
shortTitle: "Modify Parameters",
|
||||
},
|
||||
{
|
||||
title: "Choose a Theme",
|
||||
shortTitle: "Select Theme",
|
||||
},
|
||||
{
|
||||
title: "Display your Card",
|
||||
shortTitle: "Display Card",
|
||||
},
|
||||
] as const satisfies Array<{ title: string; shortTitle: string }>;
|
||||
|
||||
export type StageIndex = {
|
||||
[K in keyof typeof STAGE_LABELS]: K extends `${infer N extends number}`
|
||||
? N
|
||||
: never;
|
||||
}[keyof typeof STAGE_LABELS];
|
||||
Vendored
-19
@@ -1,19 +0,0 @@
|
||||
declare module "*.css";
|
||||
|
||||
declare module "*.png" {
|
||||
const img: string;
|
||||
export default img;
|
||||
}
|
||||
|
||||
// This package doesn't have a @types counter part
|
||||
declare module "save-svg-as-png" {
|
||||
const saveSvgAsPng: (
|
||||
element: Element,
|
||||
filename: string,
|
||||
options: {
|
||||
scale: number;
|
||||
encoderOptions: number;
|
||||
},
|
||||
) => void;
|
||||
export { saveSvgAsPng };
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { ToastContainer, toast } from "react-toastify";
|
||||
|
||||
import { getUserMetadata } from "../../api/user";
|
||||
import { clearAxiosCache } from "../../axios-override";
|
||||
import type { StageIndex } from "../../models/Stage";
|
||||
import {
|
||||
useIsAuthenticated,
|
||||
useUserKey,
|
||||
useUserToken,
|
||||
} from "../../redux/selectors/userSelectors";
|
||||
import {
|
||||
logout as _logout,
|
||||
setUserAccess as _setUserAccess,
|
||||
} from "../../redux/slices/user";
|
||||
import { HomeScreen } from "../Home/Home";
|
||||
|
||||
import { Header } from "./Header";
|
||||
|
||||
const toMessage = (
|
||||
input: string | ErrorEvent | PromiseRejectionEvent,
|
||||
): string => {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
type MaybeErrorReason = { message: string } | null;
|
||||
const reason = ("reason" in input ? input.reason : null) as MaybeErrorReason;
|
||||
if (typeof reason?.message === "string" && !!reason.message.trim()) {
|
||||
return reason.message;
|
||||
}
|
||||
|
||||
if ("message" in input && input.message) {
|
||||
return input.message;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(input);
|
||||
} catch {
|
||||
return "Unknown error";
|
||||
}
|
||||
};
|
||||
|
||||
const showError = (event: ErrorEvent | PromiseRejectionEvent): void => {
|
||||
toast.error(toMessage(event), {
|
||||
position: "bottom-right",
|
||||
autoClose: 1500,
|
||||
hideProgressBar: true,
|
||||
closeOnClick: false,
|
||||
pauseOnHover: true,
|
||||
draggable: false,
|
||||
});
|
||||
};
|
||||
|
||||
export function AppTrends() {
|
||||
const userKey = useUserKey();
|
||||
const userToken = useUserToken();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
const [stage, setStage] = useState<StageIndex>(isAuthenticated ? 1 : 0);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: ErrorEvent | PromiseRejectionEvent) => {
|
||||
showError(event);
|
||||
};
|
||||
|
||||
window.addEventListener("error", handler);
|
||||
window.addEventListener("unhandledrejection", handler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("error", handler);
|
||||
window.removeEventListener("unhandledrejection", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
clearAxiosCache();
|
||||
}, [userToken]);
|
||||
|
||||
{
|
||||
/*
|
||||
* This effect must only be executed when `isAuthenticated` changes,
|
||||
* otherwise logged-in user are unable to go back to step 1.
|
||||
*/
|
||||
const prevIsAuthenticated = useRef(isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevIsAuthenticated.current === isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevIsAuthenticated.current = isAuthenticated;
|
||||
|
||||
if (isAuthenticated && stage === 0) {
|
||||
setStage(1);
|
||||
}
|
||||
}, [isAuthenticated, stage]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function getPrivateAccess() {
|
||||
if (userKey && userKey.length > 0) {
|
||||
const userAccess = await getUserMetadata(userKey);
|
||||
if (userAccess === null) {
|
||||
dispatch(_logout({ userKey }));
|
||||
} else {
|
||||
dispatch(_setUserAccess(userAccess));
|
||||
}
|
||||
}
|
||||
}
|
||||
void getPrivateAccess();
|
||||
}, [dispatch, userKey]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header currStageIndex={stage} onStageIndexChange={setStage} />
|
||||
<section className="bg-white text-gray-700 flex-grow">
|
||||
<HomeScreen stage={stage} setStage={setStage} />
|
||||
<ToastContainer />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { JSX } from "react";
|
||||
import { FaGithub as GithubIcon } from "react-icons/fa";
|
||||
|
||||
import appIcon from "../../assets/appLogo64.png";
|
||||
import { ProgressBar } from "../../components/Home/Progress";
|
||||
import { STAGE_LABELS } from "../../models/Stage";
|
||||
import type { StageIndex } from "../../models/Stage";
|
||||
|
||||
interface HeaderProps {
|
||||
currStageIndex: StageIndex;
|
||||
onStageIndexChange: (stageIndex: StageIndex) => void;
|
||||
}
|
||||
|
||||
const items = STAGE_LABELS.map((it) => it.shortTitle);
|
||||
|
||||
export function Header({
|
||||
currStageIndex,
|
||||
onStageIndexChange,
|
||||
}: HeaderProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className="text-gray-100 bg-gray-800 shadow-md z-50">
|
||||
<div className="px-5 py-2 flex flex-wrap">
|
||||
{/* Logo */}
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center title-font font-medium text-gray-50 mb-0 md:mr-8"
|
||||
>
|
||||
<img src={appIcon} alt="logo" className="w-6 h-6" />
|
||||
<span className="ml-2 text-xl">GitHub Stats Extended</span>
|
||||
</a>
|
||||
{/* Star on GitHub */}
|
||||
<div className="flex ml-auto items-center text-base justify-center">
|
||||
<a
|
||||
href="https://github.com/stats-organization/github-stats-extended"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-[0.25rem] shadow bg-gray-200 hover:bg-gray-300 text-black px-3 py-1 flex items-center"
|
||||
>
|
||||
Star on
|
||||
<GithubIcon className="ml-1.5 w-5 h-5" />
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBar
|
||||
items={items}
|
||||
currItemIndex={currStageIndex}
|
||||
onItemClick={(itemIndex) => {
|
||||
onStageIndexChange(itemIndex as StageIndex);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
import axios from "axios";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { BounceLoader } from "react-spinners";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { authenticate } from "../../api/user";
|
||||
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
|
||||
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import {
|
||||
DEMO_GIST,
|
||||
DEMO_REPO,
|
||||
DEMO_USER,
|
||||
DEMO_WAKATIME_USER,
|
||||
HOST,
|
||||
} from "../../constants";
|
||||
import { CardType } from "../../models/CardType";
|
||||
import { STAGE_LABELS } from "../../models/Stage";
|
||||
import type { StageIndex } from "../../models/Stage";
|
||||
import {
|
||||
useIsAuthenticated,
|
||||
usePrivateAccess,
|
||||
useUserId,
|
||||
} from "../../redux/selectors/userSelectors";
|
||||
import { login } from "../../redux/slices/user";
|
||||
|
||||
import { getFullSuffix } from "./getFullSuffix";
|
||||
import { CustomizeStage } from "./stages/Customize";
|
||||
import { DisplayStage } from "./stages/Display";
|
||||
import { LoginStage } from "./stages/Login/Login";
|
||||
import { SelectCardStage } from "./stages/SelectCard";
|
||||
import { ThemeStage } from "./stages/Theme";
|
||||
|
||||
interface HomeScreenProps {
|
||||
stage: StageIndex;
|
||||
setStage: (stageIndex: StageIndex) => void;
|
||||
}
|
||||
|
||||
export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const userId = useUserId(DEMO_USER);
|
||||
const privateAccess = usePrivateAccess();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// for stage two
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId);
|
||||
const [repo, setRepo] = useState(DEMO_REPO);
|
||||
const [gist, setGist] = useState(DEMO_GIST);
|
||||
const [wakatimeUser, setWakatimeUser] = useState(DEMO_WAKATIME_USER);
|
||||
|
||||
const [selectedCard, setSelectedCard] = useState<CardType>(CardType.STATS);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedUserId(userId);
|
||||
}, [userId]);
|
||||
|
||||
// for stage three
|
||||
const [selectedStatsRank, setSelectedStatsRank] =
|
||||
useState(STATS_DEFAULT_RANK);
|
||||
const [selectedLanguagesLayout, setSelectedLanguagesLayout] = useState(
|
||||
LANGUAGES_DEFAULT_LAYOUT,
|
||||
);
|
||||
const [selectedWakatimeLayout, setSelectedWakatimeLayout] = useState(
|
||||
WAKATIME_DEFAULT_LAYOUT,
|
||||
);
|
||||
|
||||
const [showTitle, setShowTitle] = useState(true);
|
||||
const [showOwner, setShowOwner] = useState(false);
|
||||
const [descriptionLines, setDescriptionLines] = useState<
|
||||
number | undefined
|
||||
>();
|
||||
const [customTitle, setCustomTitle] = useState("");
|
||||
const [langsCount, setLangsCount] = useState<number | undefined>();
|
||||
const [hideValues, setHideValues] = useState(false);
|
||||
const [showAllStats, setShowAllStats] = useState(false);
|
||||
const [showIcons, setShowIcons] = useState(false);
|
||||
const [includeAllCommits, setIncludeAllCommits] = useState(true);
|
||||
const [enableAnimations, setEnableAnimations] = useState(true);
|
||||
const [usePercent, setUsePercent] = useState(false);
|
||||
|
||||
const [theme, setTheme] = useState("default");
|
||||
|
||||
const handleCardTypeChange = (cardType: CardType) => {
|
||||
if (cardType === CardType.TOP_LANGS) {
|
||||
setLangsCount(4);
|
||||
setSelectedWakatimeLayout(WAKATIME_DEFAULT_LAYOUT);
|
||||
} else if (cardType === CardType.WAKATIME) {
|
||||
setLangsCount(6);
|
||||
setSelectedLanguagesLayout(LANGUAGES_DEFAULT_LAYOUT);
|
||||
}
|
||||
|
||||
if (theme === "default" || theme === "default_repocard") {
|
||||
if (cardType === CardType.PIN || cardType === CardType.GIST) {
|
||||
setTheme("default_repocard");
|
||||
} else {
|
||||
setTheme("default");
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedCard(cardType);
|
||||
// Go to the next stage
|
||||
setStage(2);
|
||||
};
|
||||
|
||||
const fullSuffix = getFullSuffix({
|
||||
userId,
|
||||
selectedCard,
|
||||
selectedUserId,
|
||||
repo,
|
||||
gist,
|
||||
wakatimeUser,
|
||||
selectedStatsRank,
|
||||
selectedLanguagesLayout,
|
||||
selectedWakatimeLayout,
|
||||
showTitle,
|
||||
showOwner,
|
||||
descriptionLines,
|
||||
customTitle,
|
||||
langsCount,
|
||||
hideValues,
|
||||
showAllStats,
|
||||
showIcons,
|
||||
includeAllCommits,
|
||||
enableAnimations,
|
||||
usePercent,
|
||||
});
|
||||
|
||||
// for stage four
|
||||
let themeSuffix = fullSuffix;
|
||||
|
||||
if (
|
||||
!(
|
||||
(theme === "default" &&
|
||||
[CardType.STATS, CardType.TOP_LANGS, CardType.WAKATIME].includes(
|
||||
selectedCard as never,
|
||||
)) ||
|
||||
(theme === "default_repocard" &&
|
||||
[CardType.PIN, CardType.GIST].includes(selectedCard as never))
|
||||
)
|
||||
) {
|
||||
themeSuffix += `&theme=${theme}`;
|
||||
}
|
||||
|
||||
// for stage five
|
||||
const [gistUrl, setGistUrl] = useState("");
|
||||
|
||||
const guestHint = useMemo(() => {
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS:
|
||||
case CardType.TOP_LANGS:
|
||||
return `username "${DEMO_USER}"`;
|
||||
case CardType.PIN:
|
||||
return `repo "${DEMO_REPO}"`;
|
||||
case CardType.GIST:
|
||||
return `Gist ID "${DEMO_GIST}"`;
|
||||
case CardType.WAKATIME:
|
||||
return `WakaTime username "${DEMO_WAKATIME_USER}"`;
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
return "";
|
||||
}
|
||||
}, [selectedCard]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchGistURL() {
|
||||
try {
|
||||
const fullUrl = `https://api.github.com/gists/${gist}`;
|
||||
const result = await axios.get<{ html_url: string }>(fullUrl);
|
||||
setGistUrl(result.data.html_url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
void fetchGistURL();
|
||||
}, [gist]);
|
||||
|
||||
const contentSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// scroll to top of content section if we're scrolled down
|
||||
if (contentSectionRef.current) {
|
||||
const rect = contentSectionRef.current.getBoundingClientRect();
|
||||
if (rect.top < 0) {
|
||||
contentSectionRef.current.scrollIntoView();
|
||||
// additional offset to account for sticky progress bar
|
||||
window.scrollBy({ top: -80 });
|
||||
}
|
||||
}
|
||||
}, [stage]);
|
||||
|
||||
useEffect(() => {
|
||||
async function redirectCode() {
|
||||
// After requesting GitHub access, GitHub redirects back to your app with a code parameter
|
||||
const url = window.location.href;
|
||||
|
||||
// If GitHub API returns the code parameter
|
||||
if (url.includes("code=")) {
|
||||
const tempPrivateAccess = url.includes("private");
|
||||
const newUrl = url.split("code=", 2) as [string, string];
|
||||
const redirect = `${url.split(HOST)[0] as string}${HOST}/frontend`;
|
||||
window.history.pushState({}, "", redirect);
|
||||
setIsLoading(true);
|
||||
const userKey = uuidv4();
|
||||
const newUserId = await authenticate(
|
||||
newUrl[1],
|
||||
tempPrivateAccess,
|
||||
userKey,
|
||||
);
|
||||
|
||||
dispatch(login({ userId: newUserId, userKey }));
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void redirectCode();
|
||||
}, [dispatch]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full py-8 flex justify-center items-center">
|
||||
<BounceLoader color="#3B82F6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={contentSectionRef} className="h-full px-2 lg:px-8 text-gray-600">
|
||||
<div className="flex flex-col">
|
||||
<div className="m-4 rounded-sm">
|
||||
<div className="lg:p-4">
|
||||
<h1 className="text-2xl text-gray-600 font-semibold">
|
||||
{STAGE_LABELS[stage].title}
|
||||
</h1>
|
||||
<div>
|
||||
{stage === 0 && isAuthenticated ? (
|
||||
<div>
|
||||
<p>
|
||||
You are logged in as{" "}
|
||||
<a
|
||||
href={`https://github.com/${userId}`}
|
||||
target="_blank"
|
||||
className="text-blue-500 hover:underline font-semibold"
|
||||
>
|
||||
{userId}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
{privateAccess ? (
|
||||
<>
|
||||
You granted GitHub-Stats-Extended access to both your{" "}
|
||||
<b>public and private</b> contributions.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
You granted GitHub-Stats-Extended access to your{" "}
|
||||
<b>public</b> contributions.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
[
|
||||
"",
|
||||
"You will be able to customize your card in future steps.",
|
||||
"",
|
||||
"",
|
||||
"Display the finished card on GitHub, Twitter/X, LinkedIn, or anywhere else!",
|
||||
][stage]
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{stage === 0 && (
|
||||
<LoginStage
|
||||
onContinueAsGuest={() => {
|
||||
setStage(1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{stage === 1 && (
|
||||
<SelectCardStage
|
||||
selectedCardType={selectedCard}
|
||||
onCardTypeChange={handleCardTypeChange}
|
||||
/>
|
||||
)}
|
||||
{stage === 2 && (
|
||||
<CustomizeStage
|
||||
selectedCard={selectedCard}
|
||||
selectedStatsRank={selectedStatsRank}
|
||||
setSelectedStatsRank={setSelectedStatsRank}
|
||||
selectedLanguagesLayout={selectedLanguagesLayout}
|
||||
setSelectedLanguagesLayout={setSelectedLanguagesLayout}
|
||||
selectedWakatimeLayout={selectedWakatimeLayout}
|
||||
setSelectedWakatimeLayout={setSelectedWakatimeLayout}
|
||||
showTitle={showTitle}
|
||||
setShowTitle={setShowTitle}
|
||||
showOwner={showOwner}
|
||||
setShowOwner={setShowOwner}
|
||||
descriptionLines={descriptionLines}
|
||||
setDescriptionLines={setDescriptionLines}
|
||||
customTitle={customTitle}
|
||||
setCustomTitle={setCustomTitle}
|
||||
langsCount={langsCount}
|
||||
setLangsCount={setLangsCount}
|
||||
hideValues={hideValues}
|
||||
setHideValues={setHideValues}
|
||||
showAllStats={showAllStats}
|
||||
setShowAllStats={setShowAllStats}
|
||||
showIcons={showIcons}
|
||||
setShowIcons={setShowIcons}
|
||||
includeAllCommits={includeAllCommits}
|
||||
setIncludeAllCommits={setIncludeAllCommits}
|
||||
enableAnimations={enableAnimations}
|
||||
setEnableAnimations={setEnableAnimations}
|
||||
selectedUserId={selectedUserId}
|
||||
setSelectedUserId={setSelectedUserId}
|
||||
repo={repo}
|
||||
setRepo={setRepo}
|
||||
gist={gist}
|
||||
setGist={setGist}
|
||||
wakatimeUser={wakatimeUser}
|
||||
setWakatimeUser={setWakatimeUser}
|
||||
usePercent={usePercent}
|
||||
setUsePercent={setUsePercent}
|
||||
fullSuffix={fullSuffix}
|
||||
setStage={setStage}
|
||||
/>
|
||||
)}
|
||||
{stage === 3 && (
|
||||
<ThemeStage
|
||||
fullSuffix={fullSuffix}
|
||||
theme={theme}
|
||||
onThemeChange={(theme) => {
|
||||
setTheme(theme);
|
||||
setStage(4);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{stage === 4 && (
|
||||
<DisplayStage
|
||||
filename={(() => {
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS:
|
||||
case CardType.TOP_LANGS:
|
||||
return `${selectedUserId}_card`;
|
||||
case CardType.PIN:
|
||||
return `${repo}_card`;
|
||||
case CardType.GIST:
|
||||
return `gist_card`;
|
||||
case CardType.WAKATIME:
|
||||
return `${wakatimeUser}_card`;
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
link={(() => {
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS:
|
||||
case CardType.TOP_LANGS:
|
||||
return `https://${HOST}/api${themeSuffix}`;
|
||||
|
||||
case CardType.PIN: {
|
||||
let myRepo = repo;
|
||||
if (!myRepo.includes("/")) {
|
||||
myRepo = `${userId}/${myRepo}`;
|
||||
}
|
||||
return `https://github.com/${myRepo}`;
|
||||
}
|
||||
case CardType.GIST:
|
||||
return gistUrl;
|
||||
case CardType.WAKATIME:
|
||||
return `https://wakatime.com/@${wakatimeUser}`;
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
themeSuffix={themeSuffix}
|
||||
guestHint={
|
||||
isAuthenticated
|
||||
? null
|
||||
: `Replace the sample ${guestHint} with your own after copying your Markdown or URL!`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
|
||||
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import { CardType } from "../../models/CardType";
|
||||
|
||||
import { getFullSuffix } from "./getFullSuffix";
|
||||
|
||||
const baseOptions = {
|
||||
selectedCard: CardType.STATS,
|
||||
selectedUserId: "john",
|
||||
userId: "john-github",
|
||||
repo: "repo1",
|
||||
gist: "gist1",
|
||||
wakatimeUser: "wakaUser",
|
||||
selectedStatsRank: STATS_DEFAULT_RANK,
|
||||
selectedLanguagesLayout: LANGUAGES_DEFAULT_LAYOUT,
|
||||
selectedWakatimeLayout: WAKATIME_DEFAULT_LAYOUT,
|
||||
showTitle: true,
|
||||
showOwner: false,
|
||||
descriptionLines: undefined,
|
||||
customTitle: "",
|
||||
langsCount: undefined,
|
||||
hideValues: false,
|
||||
showAllStats: false,
|
||||
showIcons: false,
|
||||
includeAllCommits: false,
|
||||
enableAnimations: true,
|
||||
usePercent: false,
|
||||
};
|
||||
|
||||
describe("getFullSuffix", () => {
|
||||
it("builds stats suffix with defaults", () => {
|
||||
const result = getFullSuffix(baseOptions);
|
||||
|
||||
expect(result).toBe("?username=john");
|
||||
});
|
||||
|
||||
it("adds stats options", () => {
|
||||
const result = getFullSuffix({
|
||||
...baseOptions,
|
||||
showIcons: true,
|
||||
includeAllCommits: true,
|
||||
showAllStats: true,
|
||||
showTitle: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
"?username=john" +
|
||||
"&hide_title=true" +
|
||||
"&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented" +
|
||||
"&show_icons=true" +
|
||||
"&include_all_commits=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds top-langs suffix", () => {
|
||||
const result = getFullSuffix({
|
||||
...baseOptions,
|
||||
selectedCard: CardType.TOP_LANGS,
|
||||
langsCount: 5,
|
||||
showTitle: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
"/top-langs?username=john&hide_title=true&langs_count=5",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds pin suffix using userId not selectedUserId", () => {
|
||||
const result = getFullSuffix({
|
||||
...baseOptions,
|
||||
selectedCard: CardType.PIN,
|
||||
showOwner: true,
|
||||
descriptionLines: 3,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
"/pin?username=john-github&repo=repo1&show_owner=true&description_lines_count=3",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds gist suffix", () => {
|
||||
const result = getFullSuffix({
|
||||
...baseOptions,
|
||||
selectedCard: CardType.GIST,
|
||||
showOwner: true,
|
||||
});
|
||||
|
||||
expect(result).toBe("/gist?id=gist1&show_owner=true");
|
||||
});
|
||||
|
||||
it("builds wakatime suffix with percent and custom title", () => {
|
||||
const result = getFullSuffix({
|
||||
...baseOptions,
|
||||
selectedCard: CardType.WAKATIME,
|
||||
wakatimeUser: "waka",
|
||||
usePercent: true,
|
||||
customTitle: "My Stats",
|
||||
showTitle: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
"/wakatime?username=waka&hide_title=true&custom_title=My%20Stats&display_format=percent",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds non-default layouts", () => {
|
||||
const result = getFullSuffix({
|
||||
...baseOptions,
|
||||
selectedCard: CardType.TOP_LANGS,
|
||||
selectedLanguagesLayout: { id: 2, value: "compact", label: "Compact" },
|
||||
});
|
||||
|
||||
expect(result).toBe("/top-langs?username=john&layout=compact");
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
import type { SelectOption } from "../../components/Generic/Select";
|
||||
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
|
||||
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
|
||||
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
|
||||
import { CardType } from "../../models/CardType";
|
||||
|
||||
interface Options {
|
||||
userId: string;
|
||||
selectedUserId: string;
|
||||
selectedCard: CardType;
|
||||
repo: string;
|
||||
gist: string;
|
||||
wakatimeUser: string;
|
||||
selectedStatsRank: SelectOption;
|
||||
selectedLanguagesLayout: SelectOption;
|
||||
selectedWakatimeLayout: SelectOption;
|
||||
showTitle: boolean;
|
||||
showOwner: boolean;
|
||||
descriptionLines: number | undefined;
|
||||
customTitle: string;
|
||||
langsCount: number | undefined;
|
||||
hideValues: boolean;
|
||||
showAllStats: boolean;
|
||||
showIcons: boolean;
|
||||
includeAllCommits: boolean;
|
||||
enableAnimations: boolean;
|
||||
usePercent: boolean;
|
||||
}
|
||||
|
||||
export function getFullSuffix({
|
||||
userId,
|
||||
selectedCard,
|
||||
selectedUserId,
|
||||
repo,
|
||||
gist,
|
||||
wakatimeUser,
|
||||
selectedStatsRank,
|
||||
selectedLanguagesLayout,
|
||||
selectedWakatimeLayout,
|
||||
showTitle,
|
||||
showOwner,
|
||||
descriptionLines,
|
||||
customTitle,
|
||||
langsCount,
|
||||
hideValues,
|
||||
showAllStats,
|
||||
showIcons,
|
||||
includeAllCommits,
|
||||
enableAnimations,
|
||||
usePercent,
|
||||
}: Options): string {
|
||||
let fullSuffix = `${selectedCard === CardType.STATS ? "" : "/" + selectedCard}?`;
|
||||
|
||||
switch (selectedCard) {
|
||||
case CardType.STATS:
|
||||
case CardType.TOP_LANGS:
|
||||
fullSuffix += `username=${selectedUserId}`;
|
||||
break;
|
||||
case CardType.PIN:
|
||||
/**
|
||||
* We should use the name of the logged-in user, not the value entered in the
|
||||
* username field in step 3.
|
||||
*
|
||||
* This input is not shown when the PIN card type is selected,
|
||||
* but it may still contain a different value if the user previously chose another card type.
|
||||
*
|
||||
* For example,
|
||||
* 1. the user could select the STATS card
|
||||
* 2. enter a username
|
||||
* 3. then go back and switch to the PIN card, leaving the old value behind.
|
||||
*
|
||||
* @see https://github.com/stats-organization/github-stats-extended/pull/73#discussion_r2792177515
|
||||
*/
|
||||
fullSuffix += `username=${userId}&repo=${repo}`;
|
||||
break;
|
||||
case CardType.GIST:
|
||||
fullSuffix += `id=${gist}`;
|
||||
break;
|
||||
case CardType.WAKATIME:
|
||||
fullSuffix += `username=${wakatimeUser}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
selectedCard satisfies never;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedStatsRank !== STATS_DEFAULT_RANK &&
|
||||
selectedCard === CardType.STATS
|
||||
) {
|
||||
fullSuffix += `&rank_icon=${selectedStatsRank.value}`;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedLanguagesLayout !== LANGUAGES_DEFAULT_LAYOUT &&
|
||||
selectedCard === CardType.TOP_LANGS
|
||||
) {
|
||||
fullSuffix += `&layout=${selectedLanguagesLayout.value}`;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedWakatimeLayout !== WAKATIME_DEFAULT_LAYOUT &&
|
||||
selectedCard === CardType.WAKATIME
|
||||
) {
|
||||
fullSuffix += `&layout=${selectedWakatimeLayout.value}`;
|
||||
}
|
||||
|
||||
if (
|
||||
!showTitle &&
|
||||
(selectedCard === CardType.STATS ||
|
||||
selectedCard === CardType.TOP_LANGS ||
|
||||
selectedCard === CardType.WAKATIME)
|
||||
) {
|
||||
fullSuffix += "&hide_title=true";
|
||||
}
|
||||
|
||||
if (
|
||||
showOwner &&
|
||||
(selectedCard === CardType.PIN || selectedCard === CardType.GIST)
|
||||
) {
|
||||
fullSuffix += "&show_owner=true";
|
||||
}
|
||||
|
||||
if (descriptionLines && selectedCard === CardType.PIN) {
|
||||
fullSuffix += `&description_lines_count=${descriptionLines}`;
|
||||
}
|
||||
|
||||
if (
|
||||
customTitle &&
|
||||
(selectedCard === CardType.STATS || selectedCard === CardType.WAKATIME)
|
||||
) {
|
||||
const encodedTitle = encodeURIComponent(customTitle);
|
||||
fullSuffix += `&custom_title=${encodedTitle}`;
|
||||
}
|
||||
|
||||
if (
|
||||
langsCount &&
|
||||
(selectedCard === CardType.TOP_LANGS || selectedCard === CardType.WAKATIME)
|
||||
) {
|
||||
fullSuffix += `&langs_count=${langsCount}`;
|
||||
}
|
||||
|
||||
if (hideValues && selectedCard === CardType.TOP_LANGS) {
|
||||
fullSuffix += `&hide_values=true`;
|
||||
}
|
||||
|
||||
if (showAllStats && selectedCard === CardType.STATS) {
|
||||
fullSuffix += `&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented`;
|
||||
}
|
||||
|
||||
if (showIcons && selectedCard === CardType.STATS) {
|
||||
fullSuffix += `&show_icons=true`;
|
||||
}
|
||||
|
||||
if (includeAllCommits && selectedCard === CardType.STATS) {
|
||||
fullSuffix += `&include_all_commits=true`;
|
||||
}
|
||||
|
||||
if (
|
||||
!enableAnimations &&
|
||||
(selectedCard === CardType.STATS ||
|
||||
selectedCard === CardType.TOP_LANGS ||
|
||||
selectedCard === CardType.WAKATIME)
|
||||
) {
|
||||
fullSuffix += `&disable_animations=${!enableAnimations}`;
|
||||
}
|
||||
|
||||
if (usePercent && selectedCard === CardType.WAKATIME) {
|
||||
fullSuffix += `&display_format=percent`;
|
||||
}
|
||||
|
||||
return fullSuffix;
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { JSX } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { saveSvgAsPng } from "save-svg-as-png";
|
||||
|
||||
import { CardImage } from "../../../components/Card/CardImage";
|
||||
import { Button } from "../../../components/Generic/Button";
|
||||
import { HOST } from "../../../constants";
|
||||
|
||||
interface DisplayStageProps {
|
||||
filename: string;
|
||||
link: string;
|
||||
themeSuffix: string;
|
||||
guestHint: string | null;
|
||||
}
|
||||
|
||||
export function DisplayStage({
|
||||
filename,
|
||||
link,
|
||||
themeSuffix,
|
||||
guestHint,
|
||||
}: DisplayStageProps): JSX.Element {
|
||||
const downloadPNG = () => {
|
||||
saveSvgAsPng(
|
||||
document.getElementById("svgWrapper")?.shadowRoot?.firstElementChild
|
||||
?.firstElementChild as HTMLElement,
|
||||
`${filename}.png`,
|
||||
{
|
||||
scale: 2,
|
||||
encoderOptions: 1,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const copyMarkdown = () => {
|
||||
void navigator.clipboard.writeText(
|
||||
`[](${link})`,
|
||||
);
|
||||
toast.info("Copied to Clipboard!", {
|
||||
position: "bottom-right",
|
||||
autoClose: 1500,
|
||||
hideProgressBar: true,
|
||||
closeOnClick: false,
|
||||
pauseOnHover: true,
|
||||
draggable: false,
|
||||
});
|
||||
};
|
||||
|
||||
const copyUrl = () => {
|
||||
void navigator.clipboard.writeText(`https://${HOST}/api${themeSuffix}`);
|
||||
toast.info("Copied to Clipboard!", {
|
||||
position: "bottom-right",
|
||||
autoClose: 1500,
|
||||
hideProgressBar: true,
|
||||
closeOnClick: false,
|
||||
pauseOnHover: true,
|
||||
draggable: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-wrap">
|
||||
<div className="h-auto lg:w-2/5 md:w-1/2">
|
||||
<div className="p-10 rounded-sm bg-gray-200">
|
||||
<div className="flex flex-col items-center">
|
||||
{[
|
||||
{
|
||||
title: "Copy Markdown",
|
||||
highlight: true,
|
||||
onClick: copyMarkdown,
|
||||
},
|
||||
{
|
||||
title: "Copy URL",
|
||||
highlight: false,
|
||||
onClick: copyUrl,
|
||||
},
|
||||
{
|
||||
title: "Download PNG",
|
||||
highlight: false,
|
||||
onClick: downloadPNG,
|
||||
},
|
||||
].map((item) => (
|
||||
<Button
|
||||
key={item.title}
|
||||
className={clsx("m-4 w-60 flex justify-center", {
|
||||
"bg-blue-500 hover:bg-blue-600 text-white": item.highlight,
|
||||
"bg-white hover:bg-gray-100 text-black": !item.highlight,
|
||||
})}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{!!guestHint && <div className="pt-10 pl-10 pr-10">{guestHint}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full lg:w-3/5 md:w-1/2 object-center pt-5 md:pt-0 pl-0 md:pl-5 lg:pl-0">
|
||||
<div className="w-full lg:w-3/5 mx-auto flex flex-col justify-center sticky top-32">
|
||||
<CardImage
|
||||
imageSrc={`${themeSuffix}&disable_animations=true`}
|
||||
stage={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { useIsAuthenticated } from "../../../../redux/selectors/userSelectors";
|
||||
|
||||
import { LoginAccountManagement } from "./LoginAccountManagement";
|
||||
import { LoginOptions } from "./LoginOptions";
|
||||
|
||||
interface LoginStageProps {
|
||||
onContinueAsGuest: () => void;
|
||||
}
|
||||
|
||||
export function LoginStage({
|
||||
onContinueAsGuest,
|
||||
}: LoginStageProps): JSX.Element {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <LoginAccountManagement />;
|
||||
}
|
||||
|
||||
return <LoginOptions onContinueAsGuest={onContinueAsGuest} />;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { JSX, RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { Button } from "../../../../components/Generic/Button";
|
||||
|
||||
function useOutsideAlerter(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
action: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Alert if clicked on outside of element
|
||||
*/
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
||||
// Bind the event listener
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
|
||||
return () => {
|
||||
// Unbind the event listener on clean up
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [action, ref]);
|
||||
}
|
||||
|
||||
interface LoginAccountDeleteModalProps {
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function LoginAccountDeleteModal(
|
||||
props: LoginAccountDeleteModalProps,
|
||||
): JSX.Element {
|
||||
const { onConfirm, onClose } = props;
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
useOutsideAlerter(wrapperRef, onClose);
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed left-0 top-0 w-full h-full">
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<div
|
||||
className="w-96 p-4 bg-white rounded-sm border-2 border-gray-200 text-gray-700"
|
||||
ref={wrapperRef}
|
||||
>
|
||||
<p className="mb-1 text-2xl">Delete Account</p>
|
||||
<hr />
|
||||
<br />
|
||||
<p>
|
||||
Are you sure you want to delete your account from GitHub Stats
|
||||
Extended?
|
||||
</p>
|
||||
<br />
|
||||
<div className="flex flex-wrap">
|
||||
<Button
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white rounded-[0.25rem]"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gray-200 hover:bg-gray-300 ml-auto rounded-[0.25rem] text-red-600 border-2"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { FaGithub as GithubIcon } from "react-icons/fa";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { deleteAccount } from "../../../../api/user";
|
||||
import { Button } from "../../../../components/Generic/Button";
|
||||
import {
|
||||
CLIENT_ID,
|
||||
GITHUB_PRIVATE_AUTH_URL,
|
||||
HOST,
|
||||
} from "../../../../constants";
|
||||
import {
|
||||
usePrivateAccess,
|
||||
useUserId,
|
||||
useUserKey,
|
||||
} from "../../../../redux/selectors/userSelectors";
|
||||
import { logout } from "../../../../redux/slices/user";
|
||||
|
||||
import { LoginAccountDeleteModal } from "./LoginAccountDeleteModal";
|
||||
import { LoginBox } from "./LoginBox";
|
||||
|
||||
export function LoginAccountManagement(): JSX.Element {
|
||||
const userId = useUserId();
|
||||
const userKey = useUserKey();
|
||||
const privateAccess = usePrivateAccess();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const openDeleteModal = () => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
dispatch(logout({ userKey: null }));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleAccountDelete = async () => {
|
||||
const success = await deleteAccount(userId as string, userKey as string);
|
||||
if (success) {
|
||||
handleLogout();
|
||||
window.location.href = `https://github.com/settings/connections/applications/${CLIENT_ID}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LoginBox isOpaque={showDeleteModal}>
|
||||
<div className="mb-4">
|
||||
{privateAccess ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href={`https://${HOST}/api/downgrade?user_key=${userKey as string}`}
|
||||
>
|
||||
<Button className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100">
|
||||
<GithubIcon className="w-6 h-6" />
|
||||
<span className="ml-2 xl:text-lg">
|
||||
Downgrade to Public Access
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
Switch to public access if you prefer not to share private
|
||||
contributions.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<a href={GITHUB_PRIVATE_AUTH_URL}>
|
||||
<Button className="h-12 flex justify-center items-center w-[320px] text-white bg-blue-500 hover:bg-blue-600">
|
||||
<GithubIcon className="w-6 h-6" />
|
||||
<span className="ml-2 xl:text-lg">
|
||||
Upgrade to Private Access
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
Upgrade to include contributions in private repositories for more
|
||||
complete and accurate stats.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Account Button */}
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button
|
||||
className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100"
|
||||
onClick={openDeleteModal}
|
||||
>
|
||||
<span className="xl:text-lg text-red-600">Delete Account</span>
|
||||
</Button>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
This will delete your GitHub-Stats-Extended account and then redirect
|
||||
you to a GitHub screen where you can revoke your access token.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Logout Button */}
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button
|
||||
className="h-12 flex justify-center items-center w-[320px] text-black border border-black bg-white hover:bg-gray-100"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<span className="xl:text-lg">Log Out</span>
|
||||
</Button>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
Log out from GitHub-Stats-Extended.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showDeleteModal && (
|
||||
<LoginAccountDeleteModal
|
||||
onClose={closeDeleteModal}
|
||||
onConfirm={() => {
|
||||
void handleAccountDelete();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</LoginBox>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { JSX, ReactNode } from "react";
|
||||
|
||||
import { LoginBoxDemoCards } from "./LoginBoxDemoCards";
|
||||
|
||||
interface LoginBoxProps {
|
||||
children: ReactNode;
|
||||
|
||||
isOpaque?: boolean;
|
||||
}
|
||||
|
||||
export function LoginBox(props: LoginBoxProps): JSX.Element {
|
||||
const { children, isOpaque = false } = props;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-wrap">
|
||||
<div className={clsx("md:flex", { "opacity-25": isOpaque })}>
|
||||
<div className="lg:block lg:w-3/5 lg:p-8">
|
||||
<div className="bg-gray-200 rounded-sm w-full h-full m-auto p-8 shadow lg:h-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginBoxDemoCards />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { CardImage } from "../../../../components/Card/CardImage";
|
||||
import {
|
||||
DEMO_GIST,
|
||||
DEMO_REPO,
|
||||
DEMO_USER,
|
||||
DEMO_WAKATIME_USER,
|
||||
} from "../../../../constants";
|
||||
|
||||
const cards: Array<{ demoImageSrc: string }> = [
|
||||
{
|
||||
demoImageSrc: `/pin?repo=${DEMO_REPO}&disable_animations=true`,
|
||||
},
|
||||
{
|
||||
demoImageSrc: `/top-langs?username=${DEMO_USER}&langs_count=4&disable_animations=true`,
|
||||
},
|
||||
{
|
||||
demoImageSrc: `?username=${DEMO_USER}&include_all_commits=true&disable_animations=true`,
|
||||
},
|
||||
{
|
||||
demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450&disable_animations=true`,
|
||||
},
|
||||
{
|
||||
demoImageSrc: `/gist?id=${DEMO_GIST}&disable_animations=true`,
|
||||
},
|
||||
];
|
||||
|
||||
function getCardXPosition(cardIndex: number): number {
|
||||
const radius = 60;
|
||||
const centerX = 70;
|
||||
const startAngle = Math.PI * 0.75; // 135 degrees
|
||||
const endAngle = Math.PI * 1.25; // 225 degrees
|
||||
|
||||
const angle =
|
||||
startAngle + (endAngle - startAngle) * (cardIndex / (cards.length - 1));
|
||||
const x = centerX + radius * Math.cos(angle);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function LoginBoxDemoCards(): JSX.Element {
|
||||
return (
|
||||
<div className="w-full h-full lg:w-2/5 flex lg:flex-col lg:p-8 relative overflow-hidden">
|
||||
<div className="relative w-full h-full">
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={card.demoImageSrc}
|
||||
style={{
|
||||
left: `${getCardXPosition(index)}%`,
|
||||
position: "relative",
|
||||
zoom: "0.5",
|
||||
marginBottom: "1%",
|
||||
}}
|
||||
>
|
||||
<CardImage imageSrc={card.demoImageSrc} compact={false} stage={0} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { JSX } from "react";
|
||||
import { FaGithub as GithubIcon } from "react-icons/fa";
|
||||
|
||||
import { Button } from "../../../../components/Generic/Button";
|
||||
import {
|
||||
GITHUB_PRIVATE_AUTH_URL,
|
||||
GITHUB_PUBLIC_AUTH_URL,
|
||||
} from "../../../../constants";
|
||||
|
||||
import { LoginBox } from "./LoginBox";
|
||||
|
||||
interface LoginOptionsProps {
|
||||
onContinueAsGuest: () => void;
|
||||
}
|
||||
|
||||
export function LoginOptions(props: LoginOptionsProps): JSX.Element {
|
||||
const { onContinueAsGuest } = props;
|
||||
|
||||
return (
|
||||
<LoginBox>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<a href={GITHUB_PUBLIC_AUTH_URL}>
|
||||
<Button className="h-12 flex justify-center items-center w-[260px] text-white bg-blue-500 hover:bg-blue-600">
|
||||
<GithubIcon className="w-6 h-6" />
|
||||
<span className="ml-2 xl:text-lg">GitHub Public Access</span>
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
Generate stats based on your contributions in public repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<a href={GITHUB_PRIVATE_AUTH_URL}>
|
||||
<Button className="h-12 flex justify-center items-center w-[260px] text-black border border-black bg-white hover:bg-gray-100">
|
||||
<GithubIcon className="w-6 h-6" />
|
||||
<span className="ml-2 xl:text-lg">GitHub Private Access</span>
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
Include contributions from private repositories for more complete and
|
||||
accurate stats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
className="h-12 flex justify-center items-center w-[260px] text-black border border-black bg-white hover:bg-gray-100"
|
||||
onClick={onContinueAsGuest}
|
||||
>
|
||||
<span className="ml-2 xl:text-lg">Continue as Guest</span>
|
||||
</Button>
|
||||
<p className="text-sm text-gray-600 flex-1">
|
||||
Explore options using sample data. Insert your own username in the
|
||||
last step.
|
||||
</p>
|
||||
</div>
|
||||
</LoginBox>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { Card } from "../../../components/Card/Card";
|
||||
import {
|
||||
DEMO_GIST,
|
||||
DEMO_REPO,
|
||||
DEMO_USER,
|
||||
DEMO_WAKATIME_USER,
|
||||
} from "../../../constants";
|
||||
import { CardType } from "../../../models/CardType";
|
||||
import { useUserId } from "../../../redux/selectors/userSelectors";
|
||||
|
||||
interface SelectCardStageProps {
|
||||
selectedCardType: CardType;
|
||||
onCardTypeChange: (cardType: CardType) => void;
|
||||
}
|
||||
|
||||
export function SelectCardStage({
|
||||
selectedCardType,
|
||||
onCardTypeChange,
|
||||
}: SelectCardStageProps): JSX.Element {
|
||||
const userId = useUserId(DEMO_USER);
|
||||
|
||||
const options = useMemo<
|
||||
Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
demoImageSrc: string;
|
||||
cardType: CardType;
|
||||
}>
|
||||
>(
|
||||
() => [
|
||||
{
|
||||
title: "GitHub Stats Card",
|
||||
description: "your overall GitHub statistics",
|
||||
demoImageSrc: `?username=${userId}&include_all_commits=true`,
|
||||
cardType: CardType.STATS,
|
||||
},
|
||||
{
|
||||
title: "Top Languages Card",
|
||||
description: "your most frequently used languages",
|
||||
demoImageSrc: `/top-langs?username=${userId}&langs_count=4`,
|
||||
cardType: CardType.TOP_LANGS,
|
||||
},
|
||||
{
|
||||
title: "GitHub Extra Pin",
|
||||
description:
|
||||
"pin more than 6 repositories in your profile using a GitHub profile readme",
|
||||
demoImageSrc: `/pin?repo=${DEMO_REPO}`,
|
||||
cardType: CardType.PIN,
|
||||
},
|
||||
{
|
||||
title: "GitHub Gist Pin",
|
||||
description:
|
||||
"pin gists in your GitHub profile using a GitHub profile readme",
|
||||
demoImageSrc: `/gist?id=${DEMO_GIST}`,
|
||||
cardType: CardType.GIST,
|
||||
},
|
||||
{
|
||||
title: "WakaTime Stats Card",
|
||||
description: "your coding activity from WakaTime",
|
||||
demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450`,
|
||||
cardType: CardType.WAKATIME,
|
||||
},
|
||||
],
|
||||
[userId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-wrap">
|
||||
{options.map((card) => (
|
||||
<button
|
||||
className="p-2 lg:p-4"
|
||||
key={card.cardType}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCardTypeChange(card.cardType);
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
imageSrc={card.demoImageSrc}
|
||||
selected={selectedCardType === card.cardType}
|
||||
fixedSize
|
||||
stage={1}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { themes } from "@stats-organization/github-readme-stats-core";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { Card } from "../../../components/Card/Card";
|
||||
|
||||
const excludedThemes = [
|
||||
"merko",
|
||||
"blue-green",
|
||||
"gotham",
|
||||
"blueberry",
|
||||
"outrun",
|
||||
"holi",
|
||||
];
|
||||
|
||||
const themeList = Object.keys(themes).filter(
|
||||
(myTheme) => !excludedThemes.includes(myTheme),
|
||||
);
|
||||
|
||||
interface ThemeStageProps {
|
||||
fullSuffix: string;
|
||||
theme: string;
|
||||
onThemeChange: (theme: string) => void;
|
||||
}
|
||||
|
||||
export function ThemeStage({
|
||||
theme,
|
||||
fullSuffix,
|
||||
onThemeChange,
|
||||
}: ThemeStageProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap">
|
||||
{themeList.map((myTheme) => (
|
||||
<button
|
||||
className="p-2 lg:p-4"
|
||||
key={myTheme}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onThemeChange(myTheme);
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title={myTheme}
|
||||
description=""
|
||||
imageSrc={`${fullSuffix}&theme=${myTheme}`}
|
||||
selected={theme === myTheme}
|
||||
stage={3}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pl-10 pr-10">
|
||||
For more theme options check the{" "}
|
||||
<a
|
||||
href="https://github.com/stats-organization/github-stats-extended/blob/master/docs/advanced_documentation.md#themes"
|
||||
target="_blank"
|
||||
className="underline text-blue-900"
|
||||
>
|
||||
customization documentation
|
||||
</a>{" "}
|
||||
after you copied your card URL in step 5.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import type { StoreState } from "../store";
|
||||
|
||||
export const useUserId = <TUserName extends string | undefined>(
|
||||
fallbackUsername?: TUserName,
|
||||
): TUserName => {
|
||||
const storeValue = useSelector((state: StoreState) => state.user.userId);
|
||||
return (storeValue || fallbackUsername || null) as TUserName;
|
||||
};
|
||||
|
||||
export const useIsAuthenticated = (): boolean => {
|
||||
return useSelector((state: StoreState) => {
|
||||
const userId = state.user.userId;
|
||||
return !!(userId && userId.length > 0);
|
||||
});
|
||||
};
|
||||
|
||||
export const usePrivateAccess = (): string | null => {
|
||||
return useSelector((state: StoreState) => state.user.privateAccess);
|
||||
};
|
||||
|
||||
export const useUserKey = (): string | null => {
|
||||
return useSelector((state: StoreState) => state.user.userKey);
|
||||
};
|
||||
|
||||
export const useUserToken = (): string | null => {
|
||||
return useSelector((state: StoreState) => state.user.token);
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Used to snooze knip report.
|
||||
* This export is unused but is required to properly perform infer types on the store:
|
||||
* error TS4023: Exported variable 'store' has or is using name 'UserState' from external module "src/redux/slices/user" but cannot be named.
|
||||
*/
|
||||
export interface UserState {
|
||||
userId: string | null;
|
||||
userKey: string | null;
|
||||
token: string | null;
|
||||
privateAccess: string | null;
|
||||
}
|
||||
|
||||
function getFromLocalStorage(key: string): string | null {
|
||||
const storageValue = localStorage.getItem(key);
|
||||
if (!storageValue) {
|
||||
return null;
|
||||
}
|
||||
return (JSON.parse(storageValue) as string) || null;
|
||||
}
|
||||
|
||||
const initialState: UserState = {
|
||||
userId: getFromLocalStorage("userId"),
|
||||
userKey: getFromLocalStorage("userKey"),
|
||||
token: null,
|
||||
privateAccess: null,
|
||||
};
|
||||
|
||||
const userSlice = createSlice({
|
||||
name: "user",
|
||||
initialState,
|
||||
reducers: {
|
||||
login: (
|
||||
state,
|
||||
action: PayloadAction<{ userId: string; userKey: string }>,
|
||||
) => {
|
||||
const { userId, userKey } = action.payload;
|
||||
localStorage.setItem("userId", JSON.stringify(userId));
|
||||
localStorage.setItem("userKey", JSON.stringify(userKey));
|
||||
state.userId = userId;
|
||||
state.userKey = userKey;
|
||||
},
|
||||
logout: (state, action: PayloadAction<{ userKey: string | null }>) => {
|
||||
const { userKey } = action.payload;
|
||||
if (
|
||||
userKey !== null &&
|
||||
userKey !== JSON.parse(localStorage.getItem("userKey") as string)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.clear();
|
||||
state.userId = null;
|
||||
state.userKey = null;
|
||||
state.token = null;
|
||||
state.privateAccess = null;
|
||||
},
|
||||
setUserAccess: (
|
||||
state,
|
||||
action: PayloadAction<{ token: string; privateAccess: string }>,
|
||||
) => {
|
||||
const { token, privateAccess } = action.payload;
|
||||
state.token = token;
|
||||
state.privateAccess = privateAccess;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { login, logout, setUserAccess } = userSlice.actions;
|
||||
|
||||
export default userSlice.reducer;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
|
||||
import { USE_LOGGER } from "../constants";
|
||||
|
||||
import { loggerMiddleware } from "./logger";
|
||||
import user from "./slices/user";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
user,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) => {
|
||||
const middleware = getDefaultMiddleware();
|
||||
if (USE_LOGGER) {
|
||||
return middleware.concat(loggerMiddleware);
|
||||
}
|
||||
return middleware;
|
||||
},
|
||||
});
|
||||
|
||||
export { store };
|
||||
|
||||
export type StoreState = ReturnType<(typeof store)["getState"]>;
|
||||
@@ -1,25 +0,0 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { HOST } from "./constants";
|
||||
|
||||
// See https://github.com/stats-organization/github-stats-extended/pull/27#discussion_r2712184285
|
||||
const fetchWakatimeStats = async ({
|
||||
username,
|
||||
api_domain: _,
|
||||
}: {
|
||||
username: string;
|
||||
api_domain: string;
|
||||
}): Promise<unknown> => {
|
||||
if (!username) {
|
||||
throw new Error("missing parameter: username");
|
||||
}
|
||||
|
||||
const res = await axios.get<unknown>(
|
||||
`https://${HOST}/api/wakatime-proxy?username=${username}`,
|
||||
);
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export { fetchWakatimeStats };
|
||||
export default fetchWakatimeStats;
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"extends": ["../../tsconfig.base.json"],
|
||||
"include": [
|
||||
"src",
|
||||
"src/**/*.json",
|
||||
"e2e",
|
||||
"vite.config.ts",
|
||||
"playwright.config.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM"],
|
||||
"composite": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "./build-ts",
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": ["./tsconfig.json"],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"customConditions": []
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"outputDirectory": "build"
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import path from "node:path";
|
||||
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineProject } from "vitest/config";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineProject({
|
||||
base: "/frontend/",
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: "build",
|
||||
sourcemap: true,
|
||||
|
||||
/** @todo use chunks to split bundle? */
|
||||
chunkSizeWarningLimit: 800,
|
||||
|
||||
rolldownOptions: {
|
||||
external: ["pg"],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
conditions: ["@stats/source"],
|
||||
alias: [
|
||||
{
|
||||
find: "../fetchers/wakatime.js",
|
||||
replacement: path.resolve(
|
||||
import.meta.dirname,
|
||||
"src/wakatime-override.ts",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
dir: path.join(import.meta.dirname, "./src"),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "GitHub Readme Stats Dev",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": { "version": "22" }
|
||||
},
|
||||
"forwardPorts": [3000],
|
||||
"portsAttributes": {
|
||||
"3000": { "label": "HTTP" }
|
||||
},
|
||||
"appPort": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "npm install -g vercel",
|
||||
|
||||
// Use 'postStartCommand' to run commands after the container is started.
|
||||
"postStartCommand": "hostname dev && npm install",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"yzhang.markdown-all-in-one",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"github.vscode-github-actions"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"remoteUser": "root",
|
||||
"privileged": true
|
||||
}
|
||||
Vendored
+1
-1
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import os
|
||||
|
||||
file = open('./vercel.json', 'r')
|
||||
str = file.read()
|
||||
file = open('./vercel.json', 'w')
|
||||
|
||||
str = str.replace('"maxDuration": 10', '"maxDuration": 15')
|
||||
|
||||
file.write(str)
|
||||
file.close()
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
name: Deployment Prep
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
config:
|
||||
if: github.repository == 'anuraghazra/github-readme-stats'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Deployment Prep
|
||||
run: python ./.github/workflows/deploy-prep.py
|
||||
- uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0
|
||||
with:
|
||||
branch: vercel
|
||||
create_branch: true
|
||||
push_options: "--force"
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
name: Test Deployment
|
||||
on:
|
||||
# Temporarily disabled automatic triggers; manual-only for now.
|
||||
workflow_dispatch:
|
||||
# Original trigger (restore to re-enable):
|
||||
# deployment_status:
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
e2eTests:
|
||||
# Temporarily disabled; set to the original condition to re-enable.
|
||||
# if:
|
||||
# github.repository == 'anuraghazra/github-readme-stats' &&
|
||||
# github.event_name == 'deployment_status' &&
|
||||
# github.event.deployment_status.state == 'success'
|
||||
if: false
|
||||
name: Perform e2e tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Run end-to-end tests.
|
||||
run: npm run test:e2e
|
||||
# env:
|
||||
# VERCEL_PREVIEW_URL: ${{ github.event.deployment_status.target_url }}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user