Compare commits

..
6 Commits
Author SHA1 Message Date
martin-mfg d982bef3c5 Revert "fix problem on simultaneous login"
This reverts commit 23ba727b9a.
2026-01-08 18:57:49 +01:00
martin-mfg 2463f0900d Merge branch 'master' into release 2026-01-08 18:51:11 +01:00
martin-mfg 23ba727b9a fix problem on simultaneous login 2026-01-07 11:55:03 +01:00
martin-mfg 6c625fb76b Merge branch 'master' into release 2026-01-07 11:44:26 +01:00
martin-mfg 0aafbbbdb5 back to release rewrite 2026-01-06 20:21:14 +01:00
martin-mfg 8e6e0fac47 back to relase env vars 2026-01-06 20:00:49 +01:00
352 changed files with 33255 additions and 18694 deletions
+10 -13
View File
@@ -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
-83
View File
@@ -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
-124
View File
@@ -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
-53
View File
@@ -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"
-30
View File
@@ -1,30 +0,0 @@
name: Publish core package to npm
on:
workflow_dispatch:
release:
types: [released]
permissions: {}
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Dependencies
uses: ./.github/actions/install-dependencies
- name: Verify core package
run: |
pnpm --filter @stats-organization/github-readme-stats-core run typecheck
pnpm test
pnpm build:packages
pnpm --filter @stats-organization/github-readme-stats-core pack --dry-run
- name: Publish core package to npm
run: pnpm --filter @stats-organization/github-readme-stats-core publish --provenance --access public --no-git-checks
+1 -5
View File
@@ -15,16 +15,12 @@ on:
jobs:
triggerRepeatRecent:
if: |
github.repository == 'anuraghazra/github-readme-stats' ||
github.repository == 'stats-organization/github-readme-stats' ||
github.repository == 'stats-organization/github-stats-extended'
name: Trigger server to update cached data.
runs-on: ubuntu-latest
steps:
- name: Make Request
id: myRequest
uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2
uses: fjogeleit/http-request-action@v1
with:
url: "https://github-stats-extended.vercel.app/api/repeat-recent"
method: "POST"
-18
View File
@@ -1,18 +0,0 @@
name: Update version tags
on:
push:
branches-ignore:
- "**"
tags:
- "v*.*.*"
permissions: {}
jobs:
update-semver:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: haya14busa/action-update-semver@7d2c558640ea49e798d46539536190aff8c18715 # v1.5.1
+9 -31
View File
@@ -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/
-3
View File
@@ -1,3 +0,0 @@
pnpm lint-staged
pnpm run lint
pnpm test
-1
View File
@@ -1 +0,0 @@
v24
-5
View File
@@ -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
-17
View File
@@ -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"
}
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"recommendations": [
"yzhang.markdown-all-in-one",
"prettier.prettier-vscode",
"dbaeumer.vscode-eslint",
"github.vscode-github-actions"
]
}
-10
View File
@@ -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"]
}
+10 -21
View File
@@ -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:
[![Alan's WakaTime stats](https://github-stats-extended.vercel.app/api/wakatime?username=alan&langs_count=6)](https://wakatime.com/@alan)
[![Harlok's WakaTime stats](https://github-stats-extended.vercel.app/api/wakatime?username=ffflabs&langs_count=6)](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
}
-12
View File
@@ -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
View File
@@ -1 +0,0 @@
export { default as router } from "./router.js";
-29
View File
@@ -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"
}
}
-225
View File
@@ -1,225 +0,0 @@
import {
api,
gist,
pin,
topLangs,
wakatime,
} from "@stats-organization/github-readme-stats-core";
import { default as authenticate } from "./api-renamed/authenticate.js";
import { default as deleteUser } from "./api-renamed/delete-user.js";
import { default as downgrade } from "./api-renamed/downgrade.js";
import { default as repeatRecent } from "./api-renamed/repeat-recent.js";
import { default as patInfo } from "./api-renamed/status/pat-info.js";
import { default as statusUp } from "./api-renamed/status/up.js";
import { default as userAccess } from "./api-renamed/user-access.js";
import { default as wakatimeProxy } from "./api-renamed/wakatime-proxy.js";
import { guardAccess } from "./src/common/access.js";
import {
CACHE_TTL,
resolveCacheSeconds,
setCacheHeaders,
setErrorCacheHeaders,
} from "./src/common/cache.js";
import { getUserAccessByName, storeRequest } from "./src/common/database.js";
const getGuardResult = (query, type, id) => {
const access = guardAccess({
id,
type,
colors: {
title_color: query.title_color,
text_color: query.text_color,
bg_color: query.bg_color,
border_color: query.border_color,
theme: query.theme,
},
});
if (access.isPassed) {
return null;
}
return {
status: "error - permanent",
content: access.result,
};
};
const getUserPat = async (username) => {
if (!username) {
return null;
}
const userAccess = await getUserAccessByName(username);
if (!userAccess?.token) {
return null;
}
return userAccess.token;
};
export default async (req, res) => {
const url = new URL(req.url, "https://localhost");
if (res.send === undefined) {
// remaining code expects express.js-like request and response objects
res.send = function (data) {
if (typeof data === "object") {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(data));
} else if (typeof data === "string") {
res.end(data);
} else {
res.end(String(data));
}
};
req.query = Object.fromEntries(url.searchParams.entries());
}
let result;
switch (url.pathname) {
case "/api": {
result = getGuardResult(req.query, "username", req.query.username);
if (!result) {
const userPat = await getUserPat(req.query.username);
result = await api(req.query, userPat);
}
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.STATS_CARD.DEFAULT,
min: CACHE_TTL.STATS_CARD.MIN,
max: CACHE_TTL.STATS_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
}
case "/api/gist":
result =
getGuardResult(req.query, "gist", req.query.id) ??
(await gist(req.query));
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.GIST_CARD.DEFAULT,
min: CACHE_TTL.GIST_CARD.MIN,
max: CACHE_TTL.GIST_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
case "/api/pin": {
result = getGuardResult(req.query, "username", req.query.username);
if (!result) {
const userPat = await getUserPat(req.query.username);
result = await pin(req.query, userPat);
}
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.PIN_CARD.DEFAULT,
min: CACHE_TTL.PIN_CARD.MIN,
max: CACHE_TTL.PIN_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
}
case "/api/top-langs": {
result = getGuardResult(req.query, "username", req.query.username);
if (!result) {
const userPat = await getUserPat(req.query.username);
result = await topLangs(req.query, userPat);
}
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT,
min: CACHE_TTL.TOP_LANGS_CARD.MIN,
max: CACHE_TTL.TOP_LANGS_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
}
case "/api/wakatime":
result =
getGuardResult(req.query, "wakatime", req.query.username) ??
(await wakatime(req.query));
if (result.status === "error - temporary") {
setErrorCacheHeaders(res);
} else {
const cacheSeconds = resolveCacheSeconds({
requested: parseInt(req.query.cache_seconds, 10),
def: CACHE_TTL.WAKATIME_CARD.DEFAULT,
min: CACHE_TTL.WAKATIME_CARD.MIN,
max: CACHE_TTL.WAKATIME_CARD.MAX,
});
setCacheHeaders(res, cacheSeconds);
}
res.setHeader("Content-Type", "image/svg+xml");
res.end(result.content);
if (result.status !== "error - permanent") {
await storeRequest(req);
}
break;
case "/api/wakatime-proxy":
await wakatimeProxy(req, res);
break;
case "/api/repeat-recent":
await repeatRecent(req, res);
break;
case "/api/status/pat-info":
await patInfo(req, res);
break;
case "/api/status/up":
await statusUp(req, res);
break;
case "/api/authenticate":
await authenticate(req, res);
break;
case "/api/delete-user":
await deleteUser(req, res);
break;
case "/api/user-access":
await userAccess(req, res);
break;
case "/api/downgrade":
await downgrade(req, res);
break;
default:
res.statusCode = 404;
res.end("Not Found");
break;
}
};
-7
View File
@@ -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";
-181
View File
@@ -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();
});
});
-41
View File
@@ -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 },
);
});
-43
View File
@@ -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 },
);
});
-41
View File
@@ -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 },
);
});
-345
View File
@@ -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);
});
-149
View File
@@ -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();
});
});
-84
View File
@@ -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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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&amp;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();
});
});
-84
View File
@@ -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);
});
});
-299
View File
@@ -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);
}
-78
View File
@@ -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);
});
});
-14
View File
@@ -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"
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"extends": ["./tsconfig.json"],
"compilerOptions": {
"noEmit": true,
"customConditions": []
}
}
-10
View File
@@ -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"
}
]
}
-12
View File
@@ -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"],
},
});
-11
View File
@@ -1,11 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
conditions: ["@stats/source"],
},
test: {
environment: "node",
dir: "tests/e2e",
},
});
-15
View File
@@ -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"],
},
});
-71
View File
@@ -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",
);
});
-55
View File
@@ -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"
]
}
}
-81
View File
@@ -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"],
},
});
-153
View File
@@ -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>
);
}
-20
View File
@@ -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"];
-14
View File
@@ -1,14 +0,0 @@
declare global {
interface CustomProcess {
env: {
FETCH_MULTI_PAGE_STARS: string | undefined;
PAT_1: string | undefined;
};
}
interface Window {
process?: CustomProcess;
}
}
export {};
-20
View File
@@ -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>,
);
-96
View File
@@ -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
}
-8
View File
@@ -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];
-28
View File
@@ -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];
-19
View File
@@ -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 };
}
-125
View File
@@ -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>
);
}
-59
View File
@@ -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);
}}
/>
</>
);
}
-397
View File
@@ -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(
`[![GitHub Stats](https://${HOST}/api${themeSuffix})](${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);
};
-74
View File
@@ -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;
-23
View File
@@ -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"]>;
-25
View File
@@ -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;
-18
View File
@@ -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
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"extends": ["./tsconfig.json"],
"compilerOptions": {
"noEmit": true,
"customConditions": []
}
}
-4
View File
@@ -1,4 +0,0 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"outputDirectory": "build"
}
-37
View File
@@ -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"),
},
});
+33
View File
@@ -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
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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