From df0876d162afbbcd764a6c8cf06accb4b43958c2 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Sat, 12 Sep 2026 09:57:48 -0500 Subject: [PATCH 01/14] Initial page deploy --- .eslintrc.json | 17 + .github/workflows/add-repository.yml | 104 + .github/workflows/redeploy.yml | 52 + .github/workflows/test.yml | 30 + .gitignore | 57 + .gitlab-ci.yml | 33 + AGENTS.md | 129 ++ README.md | 373 +++ assets/logo.png | Bin 0 -> 26267 bytes assets/refactor-first-report.mustache | 29 + assets/sentry-config.js | 17 + bitbucket-pipelines.yml | 32 + bun.config.js | 10 + bun.lock | 379 ++++ bunfig.toml | 2 + css/components.css | 273 +++ css/main.css | 93 + index.html | 56 + js/cache-manager.js | 54 + js/error-handler.js | 96 + js/fetcher.js | 91 + js/main.js | 381 ++++ js/oauth-handler.js | 151 ++ js/rate-limiter.js | 65 + js/renderer.js | 15 + js/repo-submission.js | 139 ++ js/router.js | 114 + js/search.js | 119 + js/utils.js | 71 + package.json | 27 + plans/refactorfirst-page-plan.md | 2007 +++++++++++++++++ playwright.config.js | 33 + server.py | 32 + templates/about.html | 16 + templates/add-repo.html | 22 + templates/api.html | 32 + templates/documentation.html | 27 + templates/error-404.html | 7 + templates/error-api.html | 6 + templates/error-general.html | 6 + templates/error-oauth.html | 6 + templates/error-rate-limit.html | 6 + templates/error-template.html | 6 + templates/examples.html | 26 + templates/faq.html | 45 + templates/feedback.html | 11 + templates/getting-started.html | 34 + templates/landing.html | 18 + templates/privacy-policy.html | 24 + templates/report.html | 17 + templates/terms-of-service.html | 23 + .../user-refactorfirst-bitbucket-pipeline.yml | 65 + templates/user-refactorfirst-gitlab-ci.yml | 32 + templates/user-refactorfirst-workflow.yml | 40 + templates/user-repos.html | 5 + templates/workflow-sample-bitbucket.html | 25 + templates/workflow-sample-github.html | 33 + templates/workflow-sample-gitlab.html | 26 + tests/e2e/cross-browser.spec.js | 32 + tests/e2e/mobile-responsiveness.spec.js | 35 + tests/e2e/user-journeys.spec.js | 122 + .../sample-mustache-template.mustache | 9 + tests/fixtures/sample-refactor-first.json | 31 + tests/fixtures/sample-repositories.txt | 5 + .../getting-started-samples.test.js | 70 + tests/integration/oauth-flow.test.js | 68 + tests/integration/report-rendering.test.js | 95 + tests/integration/search-flow.test.js | 100 + tests/integration/submission-flow.test.js | 188 ++ tests/setup.js | 30 + tests/unit/cache-manager.test.js | 59 + tests/unit/error-handler.test.js | 83 + tests/unit/fetcher-fallback.test.js | 109 + tests/unit/fetcher.test.js | 95 + tests/unit/oauth-handler.test.js | 195 ++ tests/unit/rate-limiter.test.js | 67 + tests/unit/renderer.test.js | 65 + tests/unit/repo-submission.test.js | 250 ++ tests/unit/router-ext.test.js | 126 ++ tests/unit/router.test.js | 50 + tests/unit/search.test.js | 147 ++ tests/unit/utils.test.js | 141 ++ 82 files changed, 7811 insertions(+) create mode 100644 .eslintrc.json create mode 100644 .github/workflows/add-repository.yml create mode 100644 .github/workflows/redeploy.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 assets/logo.png create mode 100644 assets/refactor-first-report.mustache create mode 100644 assets/sentry-config.js create mode 100644 bitbucket-pipelines.yml create mode 100644 bun.config.js create mode 100644 bun.lock create mode 100644 bunfig.toml create mode 100644 css/components.css create mode 100644 css/main.css create mode 100644 index.html create mode 100644 js/cache-manager.js create mode 100644 js/error-handler.js create mode 100644 js/fetcher.js create mode 100644 js/main.js create mode 100644 js/oauth-handler.js create mode 100644 js/rate-limiter.js create mode 100644 js/renderer.js create mode 100644 js/repo-submission.js create mode 100644 js/router.js create mode 100644 js/search.js create mode 100644 js/utils.js create mode 100644 package.json create mode 100644 plans/refactorfirst-page-plan.md create mode 100644 playwright.config.js create mode 100644 server.py create mode 100644 templates/about.html create mode 100644 templates/add-repo.html create mode 100644 templates/api.html create mode 100644 templates/documentation.html create mode 100644 templates/error-404.html create mode 100644 templates/error-api.html create mode 100644 templates/error-general.html create mode 100644 templates/error-oauth.html create mode 100644 templates/error-rate-limit.html create mode 100644 templates/error-template.html create mode 100644 templates/examples.html create mode 100644 templates/faq.html create mode 100644 templates/feedback.html create mode 100644 templates/getting-started.html create mode 100644 templates/landing.html create mode 100644 templates/privacy-policy.html create mode 100644 templates/report.html create mode 100644 templates/terms-of-service.html create mode 100644 templates/user-refactorfirst-bitbucket-pipeline.yml create mode 100644 templates/user-refactorfirst-gitlab-ci.yml create mode 100644 templates/user-refactorfirst-workflow.yml create mode 100644 templates/user-repos.html create mode 100644 templates/workflow-sample-bitbucket.html create mode 100644 templates/workflow-sample-github.html create mode 100644 templates/workflow-sample-gitlab.html create mode 100644 tests/e2e/cross-browser.spec.js create mode 100644 tests/e2e/mobile-responsiveness.spec.js create mode 100644 tests/e2e/user-journeys.spec.js create mode 100644 tests/fixtures/sample-mustache-template.mustache create mode 100644 tests/fixtures/sample-refactor-first.json create mode 100644 tests/fixtures/sample-repositories.txt create mode 100644 tests/integration/getting-started-samples.test.js create mode 100644 tests/integration/oauth-flow.test.js create mode 100644 tests/integration/report-rendering.test.js create mode 100644 tests/integration/search-flow.test.js create mode 100644 tests/integration/submission-flow.test.js create mode 100644 tests/setup.js create mode 100644 tests/unit/cache-manager.test.js create mode 100644 tests/unit/error-handler.test.js create mode 100644 tests/unit/fetcher-fallback.test.js create mode 100644 tests/unit/fetcher.test.js create mode 100644 tests/unit/oauth-handler.test.js create mode 100644 tests/unit/rate-limiter.test.js create mode 100644 tests/unit/renderer.test.js create mode 100644 tests/unit/repo-submission.test.js create mode 100644 tests/unit/router-ext.test.js create mode 100644 tests/unit/router.test.js create mode 100644 tests/unit/search.test.js create mode 100644 tests/unit/utils.test.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..31b7203 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,17 @@ +{ + "env": { + "browser": true, + "es2022": true, + "node": true + }, + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "extends": "eslint:recommended", + "rules": { + "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "no-console": ["warn", { "allow": ["warn", "error"] }] + }, + "ignorePatterns": ["node_modules/", "playwright-report/", "test-results/"] +} diff --git a/.github/workflows/add-repository.yml b/.github/workflows/add-repository.yml new file mode 100644 index 0000000..bafc227 --- /dev/null +++ b/.github/workflows/add-repository.yml @@ -0,0 +1,104 @@ +name: Add Repository +on: + repository_dispatch: + types: [add-repository] + +permissions: + contents: write + +jobs: + add-repository: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate repository and user access + id: validate + run: | + OWNER="${{ github.event.client_payload.owner }}" + REPO="${{ github.event.client_payload.repo }}" + SUBMITTED_BY="${{ github.event.client_payload.submitted_by }}" + REPO_FULL_NAME="$OWNER/$REPO" + + echo "Validating repository: $REPO_FULL_NAME" + echo "Submitted by: $SUBMITTED_BY" + + # Input validation: only safe GitHub name characters + if ! [[ "$OWNER" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$ ]] || \ + ! [[ "$REPO" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$ ]]; then + echo "error=Invalid owner or repository name" >> "$GITHUB_OUTPUT" + exit 1 + fi + + # Check if repository exists + if ! gh repo view "$REPO_FULL_NAME" --json name --jq '.name' > /dev/null 2>&1; then + echo "error=Repository not found" >> "$GITHUB_OUTPUT" + exit 1 + fi + + # Check if user has access to the repository + if ! gh api "repos/$REPO_FULL_NAME/collaborators/$SUBMITTED_BY" --jq '.permission' > /dev/null 2>&1; then + OWNER_INFO=$(gh repo view "$REPO_FULL_NAME" --json owner --jq '.owner.login') + if [[ "$OWNER_INFO" != "$SUBMITTED_BY" ]]; then + echo "error=User does not have access to this repository" >> "$GITHUB_OUTPUT" + exit 1 + fi + PERMISSION="admin" + else + PERMISSION=$(gh api "repos/$REPO_FULL_NAME/collaborators/$SUBMITTED_BY" --jq '.permission') + if [[ "$PERMISSION" != "write" && "$PERMISSION" != "admin" ]]; then + echo "error=User does not have write access to this repository" >> "$GITHUB_OUTPUT" + exit 1 + fi + fi + + # Check for .refactorfirst/refactor-first.json file + if ! gh api "repos/$REPO_FULL_NAME/contents/.refactorfirst/refactor-first.json" --jq '.sha' > /dev/null 2>&1; then + echo "error=RefactorFirst JSON file not found" >> "$GITHUB_OUTPUT" + exit 1 + fi + + echo "status=valid" >> "$GITHUB_OUTPUT" + echo "repository=$REPO_FULL_NAME" >> "$GITHUB_OUTPUT" + echo "submitted_by=$SUBMITTED_BY" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Add to repositories.txt + if: steps.validate.outputs.status == 'valid' + run: | + REPO="${{ steps.validate.outputs.repository }}" + SUBMITTED_BY="${{ steps.validate.outputs.submitted_by }}" + + if grep -q "^$REPO$" repositories.txt; then + echo "Repository already in listing" + exit 0 + fi + + echo "$REPO" >> repositories.txt + sort -o repositories.txt repositories.txt + awk '!seen[$0]++' repositories.txt > temp.txt && mv temp.txt repositories.txt + + echo "Repository $REPO added by $SUBMITTED_BY (audit: $(date -u +%FT%TZ))" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Commit changes + if: steps.validate.outputs.status == 'valid' + run: | + REPO="${{ steps.validate.outputs.repository }}" + SUBMITTED_BY="${{ steps.validate.outputs.submitted_by }}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add repositories.txt + git diff --staged --quiet || git commit -m "Add repository: $REPO (submitted by $SUBMITTED_BY)" + git push + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Report failure + if: failure() + run: | + echo "Repository validation failed: ${{ steps.validate.outputs.error }}" diff --git a/.github/workflows/redeploy.yml b/.github/workflows/redeploy.yml new file mode 100644 index 0000000..ae3ea51 --- /dev/null +++ b/.github/workflows/redeploy.yml @@ -0,0 +1,52 @@ +name: Scheduled Redeploy +on: + schedule: + - cron: '*/10 * * * *' # Every 10 minutes + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +jobs: + check-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check for repositories.txt changes + id: check-changes + run: | + LAST_COMMIT=$(git log -1 --format=%ct -- repositories.txt || echo 0) + CURRENT_TIME=$(date +%s) + TIME_DIFF=$((CURRENT_TIME - LAST_COMMIT)) + + echo "Last commit: $LAST_COMMIT" + echo "Current time: $CURRENT_TIME" + echo "Time difference: $TIME_DIFF seconds" + + # Only deploy if changed in the last 15 minutes + if [ "$TIME_DIFF" -lt 900 ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "repositories.txt has recent changes, deploying" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No recent changes, skipping deployment" + fi + + - name: Setup Pages + if: steps.check-changes.outputs.changed == 'true' + uses: actions/configure-pages@v4 + + - name: Upload artifact + if: steps.check-changes.outputs.changed == 'true' + uses: actions/upload-pages-artifact@v3 + with: + path: '.' + + - name: Deploy to GitHub Pages + if: steps.check-changes.outputs.changed == 'true' + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c8f2159 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Test Suite +on: [push, pull_request] + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - run: bun install + - run: bun test + + e2e-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '18' + - run: npm ci + - run: npx playwright install --with-deps + - run: npx playwright test + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa26350 --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Dependencies +node_modules/ +bun.lockb +package-lock.json +yarn.lock +pnpm-lock.yaml + +# IDE +.idea/ +.vscode/ +*.iml +*.iws +*.ipr +.DS_Store + +# Build and distribution +dist/ +build/ +out/ +*.tsbuildinfo + +# Test coverage and reports +coverage/ +.nyc_output/ +test-results/ +playwright-report/ +playwright/.cache/ + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +bun-debug.log* + +# OS files +.DS_Store +Thumbs.db +*.swp +*.swo +*~ + +# Temporary files +tmp/ +temp/ +.cache/ +.parcel-cache/ + +# Lock files (keep bun.lock, ignore others) +# Note: We keep bun.lock since this project uses Bun diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..56552be --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,33 @@ +# GitLab CI configuration for this RefactorFirst Pages site. +# +# Deploys the static files to GitLab Pages. A copy of index.html is published as +# 404.html so client-side routes like /user/repo serve the application instead of +# GitLab's generic 404 page. + +stages: + - test + - deploy + +validate-site: + stage: test + image: alpine:3.20 + script: + - test -f index.html + - test -f repositories.txt + - sort -c repositories.txt + - '! grep -Ev "^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$" repositories.txt' + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + +pages: + stage: deploy + image: alpine:3.20 + script: + - mkdir -p public + - cp -r index.html repositories.txt css js templates assets public/ + - cp index.html public/404.html # client-side routing for deep links + artifacts: + paths: + - public + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cb11464 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,129 @@ +# RefactorFirst GitHub Pages Application - Agent Guide + +## Project Overview + +A purely client-side static web application that renders RefactorFirst reports by fetching `.refactorfirst/refactor-first.json` data directly from GitHub repositories. No server-side code, no database, no build step — HTML, CSS and ES6 JavaScript modules served as static files. + +**Key Features:** +- Search over curated repository listing (`repositories.txt`) +- Reports rendered with Mustache.js from raw GitHub content +- Repository submission with GitHub OAuth (PKCE) +- Works with plain static file server + +## Development Setup + +```bash +bun install # install devDependencies +python3 -m http.server 8000 # run locally at http://localhost:8000 +``` + +## Testing Commands + +**Unit + Integration Tests (Bun):** +```bash +bun test tests/unit tests/integration # run all unit/integration tests +bun test --watch tests/unit # watch mode +bun test --coverage tests/unit tests/integration # coverage report +``` + +**E2E Tests (Playwright):** +```bash +npx playwright install # one-time: download browsers +npx playwright test # full E2E suite (chromium, firefox, webkit) +npx playwright test --ui # interactive mode +``` + +**Linting:** +```bash +npx eslint js/**/*.js tests/**/*.js # lint +npx eslint js/**/*.js tests/**/*.js --fix # auto-fix +``` + +## Project Structure + +``` +index.html # Single-page app shell (top menu + #app container) +repositories.txt # Listed repositories, one "user/repo" per line +js/ # ES6 modules: router, fetcher, renderer, search, + # oauth-handler, repo-submission, error-handler, + # rate-limiter, cache-manager, utils, main +css/ # main.css + components.css +templates/ # Static page templates (about, faq, errors, ...) + # + user CI templates for GitHub/GitLab/Bitbucket +assets/ # Fallback Mustache template, logo, Sentry config +tests/ # unit/ (Bun), integration/ (Bun), e2e/ (Playwright) +.github/workflows/ # add-repository.yml, redeploy.yml, test.yml +``` + +## Development Workflow + +**TDD is mandatory** — write failing tests before production code: + +1. Write a failing test in `tests/unit/` (pure module logic) or `tests/integration/` (DOM + routing flows) +2. Run `bun test tests/unit tests/integration` and watch it fail +3. Write the minimal implementation in `js/` to make it pass +4. Refactor while keeping tests green + +## Key Module Responsibilities + +| Module | Responsibility | +|--------|---------------| +| `js/router.js` | URL routes and routing logic | +| `js/fetcher.js` | GitHub fetching / branch fallback | +| `js/renderer.js` | Mustache rendering | +| `js/search.js` | Search / type-ahead functionality | +| `js/repo-submission.js` | Submission flow | +| `js/oauth-handler.js` | OAuth / PKCE handling | +| `js/error-handler.js` | Error page rendering | +| `js/utils.js` | Utility functions, environment detection | +| `js/main.js` | Application entry point | + +## Testing Requirements + +- **Unit tests**: Pure module logic (router, fetcher, renderer, search, etc.) +- **Integration tests**: DOM + routing flows (search flow, submission flow, OAuth states) +- **E2E tests**: User journeys, cross-browser smoke tests, mobile responsiveness +- **Coverage target**: 80%+ on core modules +- **Current suite**: 164 tests + +## CI/CD + +- `.github/workflows/test.yml` runs Bun unit/integration tests and Playwright E2E suite on every push and PR +- Keep tests green before merging +- GitHub Actions used for scheduled redeployment and repository submission validation + +## Environment-Aware Documentation + +The Getting Started page shows only the CI sample matching the hosting environment, detected from hostname: +- `*.github.io` → GitHub Actions +- `*.gitlab.io` → GitLab CI +- `*.bitbucket.io` → Bitbucket Pipelines +- Anything else → defaults to GitHub + +Detection logic in `js/utils.js` → `detectHostingEnvironment()` + +## Deployment Targets + +This project supports deployment to: +- GitHub Pages (organization or personal account) +- GitHub Enterprise Server +- GitLab Pages +- Bitbucket static hosting + +See README.md for detailed deployment instructions for each platform. + +## Code Conventions + +- ES6 modules throughout +- No build step required +- Client-side routing from single `index.html` +- Mustache.js for templating +- GitHub OAuth with PKCE flow +- Static file serving (no server-side code) + +## Important Notes + +- OAuth Client ID must be set in `js/main.js` for "Add Your Repo" functionality +- For GitHub Enterprise Server, update API/raw endpoints in `js/repo-submission.js`, `js/fetcher.js`, and `js/oauth-handler.js` +- Deep links require `404.html` copy of `index.html` for proper client-side routing on some platforms +- Reports are fetched client-side — end users' browsers must reach GitHub/raw endpoints diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e7f8ab --- /dev/null +++ b/README.md @@ -0,0 +1,373 @@ +# RefactorFirst GitHub Pages Application + +A purely client-side static web application that renders +[RefactorFirst](https://github.com/refactorfirst/refactorfirst) reports by fetching +`.refactorfirst/refactor-first.json` data directly from repositories. No server-side +code, no database, no build step — HTML, CSS and ES6 JavaScript modules served as +static files. + +- **Search** over a curated listing of repositories (`repositories.txt`) +- **Reports** rendered with Mustache.js from raw GitHub content, with `main` → `master` + branch fallback +- **Repository submission** with GitHub OAuth (PKCE) and server-side validation via a + GitHub Actions workflow +- Works with a plain static file server: `python3 -m http.server 8000` + +--- + +## Table of Contents + +- [Project Layout](#project-layout) +- [Deploying to GitHub Pages (organization or personal account)](#deploying-to-github-pages-organization-or-personal-account) +- [Deploying to GitHub Enterprise Server](#deploying-to-github-enterprise-server) +- [Deploying to Bitbucket](#deploying-to-bitbucket) +- [Deploying to GitLab](#deploying-to-gitlab) +- [Registering the GitHub OAuth App](#registering-the-github-oauth-app) +- [Making Changes (Developer Guide)](#making-changes-developer-guide) +- [Testing](#testing) + +--- + +## Project Layout + +``` +index.html # Single-page app shell (top menu + #app container) +repositories.txt # Listed repositories, one "user/repo" per line +js/ # ES6 modules: router, fetcher, renderer, search, + # oauth-handler, repo-submission, error-handler, + # rate-limiter, cache-manager, utils, main +css/ # main.css + components.css +templates/ # Static page templates (about, faq, errors, ...) + # + user CI templates: user-refactorfirst-workflow.yml (GitHub), + # user-refactorfirst-gitlab-ci.yml, user-refactorfirst-bitbucket-pipeline.yml + # + workflow-sample-{github,gitlab,bitbucket}.html shown on the + # Getting Started page based on the detected hosting environment +.gitlab-ci.yml # Deploys this site to GitLab Pages +bitbucket-pipelines.yml # Validates this site's files on Bitbucket +assets/ # Fallback Mustache template, logo, Sentry config +tests/ # unit/ (Bun), integration/ (Bun), e2e/ (Playwright) +.github/workflows/ # add-repository.yml, redeploy.yml, test.yml +``` + +--- + +## Deploying to GitHub Pages (organization or personal account) + +### 1. Fork or create the repository + +- **Personal account**: create a repository named `.github.io`. +- **Organization**: create a repository named `.github.io` in the org, + or any project repository if you want a project page + (`https://.github.io//`). + +### 2. Push this project's files + +Everything in this directory is the site — push it to the default branch: + +```bash +git init +git add . +git commit -m "RefactorFirst Pages site" +git remote add origin https://github.com//.git +git push -u origin main +``` + +### 3. Enable GitHub Pages + +Go to **Settings → Pages**: + +- **Source**: *GitHub Actions* (required for the scheduled redeployment workflow in + `.github/workflows/redeploy.yml`). +- Alternatively choose *Deploy from a branch* (main, `/ (root)`) if you don't need + scheduled redeploys — the site is fully static. + +The included `redeploy.yml` workflow redeploys every 10 minutes, but only when +`repositories.txt` changed in the last 15 minutes. The `add-repository.yml` workflow +validates submissions and commits new entries to `repositories.txt`. + +### 4. Register the OAuth app + +"Add Your Repo" sign-in requires a GitHub OAuth App registered under your account +or organization — see [Registering the GitHub OAuth App](#registering-the-github-oauth-app). +Then set `OAUTH_CLIENT_ID` in `js/main.js` to your app's Client ID. + +### 5. (Optional) Custom domain + +Add a `CNAME` file containing your domain (e.g. `reports.example.com`), configure +your DNS (CNAME record pointing to `.github.io`), and enable **Enforce HTTPS** +in Settings → Pages. + +--- + +## Deploying to GitHub Enterprise Server + +The application is fully static, so it works on any GitHub Enterprise Server (GHES) +instance with Pages enabled. + +### 1. Enable Pages on the appliance + +A site admin must enable GitHub Pages for the instance +(**Management Console → Pages → Enable**), then create the repository +(`.` or a project repo) and push this project as described above. + +### 2. Point the app at your enterprise endpoints + +Raw content and API calls default to `github.com` / `raw.githubusercontent.com`. For a +self-hosted instance, update the endpoints: + +- `js/repo-submission.js` — `GITHUB_API` and `GITHUB_RAW` constants: + ```js + const GITHUB_API = 'https://github.example.com/api/v3'; + const GITHUB_RAW = 'https://github.example.com/raw'; // or your instance's raw URL pattern + ``` +- `js/fetcher.js` — `constructRawUrl` / `constructTemplateUrl` should build URLs like + `https://github.example.com/raw////.refactorfirst/refactor-first.json`. +- `js/oauth-handler.js` — `AUTHORIZE_URL` / `TOKEN_URL` / `USER_API_URL` become + `https://github.example.com/login/oauth/authorize`, `.../access_token`, and + `https://github.example.com/api/v3/user`. + +(Tip: keep these behind a single `config` module such as `enterprise-config.json` +if you need to support multiple deployments from one codebase.) + +### 3. Register the OAuth app *on the GHES instance* + +In your GHES user/org settings, register an OAuth App with callback URL +`https:///add-repo/callback` and set the Client ID in `js/main.js`. + +### 4. Workflows + +`add-repository.yml` and `redeploy.yml` use the built-in `GITHUB_TOKEN` and the `gh` +CLI, both available on GHES Actions runners. If your instance lacks internet access, +ensure the runner image includes the `gh` CLI and that raw/API endpoints are reachable +from the browser — reports are fetched **client-side**, so *end users'* browsers (not +the server) must be able to reach your GHES host. + +--- + +## Deploying to Bitbucket + +Bitbucket's static site hosting is more limited (no scheduled redeploys, no +repository_dispatch equivalent), so the **report viewing, search and listing** work +out of the box, while the **submission workflow** is GitHub-specific. + +### 1. Create the site repository + +- **Personal account**: create a repository named `.bitbucket.io`. +- **Workspace/team**: static sites are per-workspace: `.bitbucket.io`. + +### 2. Push the files + +```bash +git init +git add . +git commit -m "RefactorFirst Pages site" +git remote add origin git@bitbucket.org:/.bitbucket.io.git +git push -u origin main +``` + +The site goes live at `https://.bitbucket.io`. Note that Bitbucket static +sites serve all paths from one `index.html`-style tree — since this app routes +client-side from a single `index.html`, request every path as `/index.html`-relative +links, or accept that deep links (e.g. `/user/repo`) return 404 unless Bitbucket +serves `index.html` for unknown paths (it does not by default — consider using the +query-style links or hosting deep routes via a redirect service). + +### 3. Updating the listing + +Without GitHub Actions, `repositories.txt` is maintained by hand (commit + push) +or with the included `bitbucket-pipelines.yml`, which validates the site files on +every push and offers a manual `sort-repos` pipeline to normalize the listing. + +The OAuth submission flow still targets GitHub (reports are fetched from GitHub raw +content), so keep the OAuth App registered on github.com regardless of where the +static files are hosted. + +> **Users generating reports on Bitbucket**: point them at +> `templates/user-refactorfirst-bitbucket-pipeline.yml` — a copy-paste pipeline that +> runs `mvn refactorfirst:jsonReport` and commits `.refactorfirst/refactor-first.json` +> on every push to `main`/`master`. + +--- + +## Deploying to GitLab + +### 1. Create the project + +- **Personal account**: create a project named `.gitlab.io`. +- **Group**: create a project named `.gitlab.io`, or any project for a + project page at `https://.gitlab.io//`. + +### 2. Add a Pages pipeline + +This repository already ships a ready-to-use `.gitlab-ci.yml` (validates the site and +deploys `public/` via a `pages` job, including a `404.html` copy of `index.html` for +client-side routing). It looks like this: + +```yaml +pages: + stage: deploy + script: + - mkdir -p public + # Publish everything except VCS metadata, tests and tooling + - | + for f in index.html repositories.txt css js templates assets; do + cp -r "$f" public/ + done + artifacts: + paths: + - public + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH +``` + +### 3. Push + +```bash +git init +git add . +git commit -m "RefactorFirst Pages site" +git remote add origin https://gitlab.com//.git +git push -u origin main +``` + +GitLab Pages deploys from the `pages` job and serves +`https://.gitlab.io//`. + +### 4. GitLab-specific considerations + +- **Client-side routing**: GitLab Pages serves `404.html` for unknown paths; keep a + copy of `index.html` as `public/404.html` in the pipeline (`cp index.html public/404.html`) + so deep links like `/user/repo` load the app. +- **Listing updates**: replace `redeploy.yml` with a scheduled GitLab pipeline + (CI/CD → Schedules, every 10 minutes) that re-runs the `pages` job when + `repositories.txt` changed. The submission workflow (`add-repository.yml`) remains + GitHub-specific; the OAuth flow itself (against github.com) works from any host. +- **Custom domains**: set up under **Settings → Pages** with automatic Let's Encrypt + certificates. + +> **Users generating reports on GitLab**: point them at +> `templates/user-refactorfirst-gitlab-ci.yml` — a copy-paste pipeline that runs +> `mvn refactorfirst:jsonReport` on the default branch and commits +> `.refactorfirst/refactor-first.json` back using the built-in `CI_JOB_TOKEN`. + +--- + +## Registering the GitHub OAuth App + +Required for the "Add Your Repo" flow, regardless of where the static files live. + +1. Go to **GitHub → Settings → Developer settings → OAuth Apps → New OAuth App** + (org admins: **Organization Settings → Developer settings → OAuth Apps**). +2. Configure: + - **Application name**: `RefactorFirst GitHub Pages` + - **Homepage URL**: `https://.github.io` (or your Pages host) + - **Authorization callback URL**: `https://.github.io/add-repo/callback` +3. Note the **Client ID** and set it in `js/main.js`: + + ```js + const OAUTH_CLIENT_ID = 'YOUR_GITHUB_OAUTH_CLIENT_ID'; + ``` + +The app uses the authorization-code flow **with PKCE**, scoped to `public_repo` and +`read:user`. Tokens live only in the browser's `sessionStorage` and are cleared on +logout. Rotate the app secret quarterly if you later add any server-side component +(the current client-side PKCE flow does not use the secret). + +--- + +## Making Changes (Developer Guide) + +### Prerequisites + +- [Bun](https://bun.sh) ≥ 1.0 (unit/integration tests) — or run it via `npx bun` +- Node.js ≥ 18 (Playwright E2E tests only) +- Python 3 (local static server) + +### Setup + +```bash +bun install # install devDependencies (mustache, jsdom, playwright, eslint) +``` + +### Run locally + +```bash +python3 -m http.server 8000 # then open http://localhost:8000 +``` + +### Test-driven development (mandatory) + +This project follows strict TDD — write the failing test **before** production code: + +1. Write a failing test in `tests/unit/` (pure module logic) or + `tests/integration/` (DOM + routing flows). +2. Run `bun test tests/unit tests/integration` and watch it fail. +3. Write the minimal implementation in `js/` to make it pass. +4. Refactor while keeping tests green. + +```bash +bun test tests/unit tests/integration # unit + integration (jsdom) +bun test --watch tests/unit # watch mode +bun test --coverage tests/unit tests/integration +``` + +E2E tests run under Node.js/Playwright with a live local server: + +```bash +npx playwright install # one-time: download browsers +npx playwright test # full E2E suite (chromium, firefox, webkit) +npx playwright test --ui # interactive mode +``` + +### Lint + +```bash +npx eslint js/**/*.js tests/unit/**/*.js tests/integration/**/*.js +``` + +### Environment-aware documentation + +The **Getting Started** page shows only the CI sample matching the hosting +environment, detected from the hostname (`*.github.io` → GitHub Actions, +`*.gitlab.io` → GitLab CI, `*.bitbucket.io` → Bitbucket Pipelines; anything else +defaults to GitHub). The samples live in +`templates/workflow-sample-{github,gitlab,bitbucket}.html`, and detection lives in +`detectHostingEnvironment()` in `js/utils.js`. To override detection (e.g. a custom +domain hosting the GitLab variant), pass `hostEnvironment: 'gitlab'` to +`createApp()` in `js/main.js`. + +### Where things live + +| Change | Files | +|---|---| +| URL routes | `js/router.js` (+ `tests/unit/router-ext.test.js`) | +| GitHub fetching / branch fallback | `js/fetcher.js` | +| Mustache rendering | `js/renderer.js`, `assets/refactor-first-report.mustache` | +| Search / type-ahead | `js/search.js` | +| Submission flow | `js/repo-submission.js`, `js/main.js` (`renderAddRepo`) | +| OAuth / PKCE | `js/oauth-handler.js` | +| Error pages | `js/error-handler.js`, `templates/error-*.html` | +| Page content | `templates/*.html` | +| Styling | `css/main.css`, `css/components.css` | +| Listing data | `repositories.txt` (one `user/repo` per line) | +| Validation workflow | `.github/workflows/add-repository.yml` | +| Scheduled redeploy | `.github/workflows/redeploy.yml` | + +### CI/CD + +`.github/workflows/test.yml` runs Bun unit/integration tests and the Playwright suite +on every push and pull request. Keep it green before merging. + +--- + +## Testing + +- **Unit** (`tests/unit/`): router, fetcher (incl. branch fallback + retry), renderer, + search, repo-submission (incl. report-file existence check), oauth-handler, + error-handler, rate-limiter, cache-manager, utils. +- **Integration** (`tests/integration/`): search flow, submission flow (incl. OAuth + states and missing-report handling), report rendering, OAuth callback flow. +- **E2E** (`tests/e2e/`): user journeys, cross-browser smoke tests, mobile + responsiveness (hamburger menu, single-column grid). + +Coverage target: 80%+ on core modules. Current suite: 164 tests. diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9a2824dc5f67247306d6d7ba2cc16301d320596d GIT binary patch literal 26267 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 +

RefactorFirst Report: {{projectName}}

+

Version {{version}} · Generated {{generatedAt}}

+

Classes analyzed: {{totalClasses}} · + Classes to refactor: {{classesToRefactor}}

+ + + + + + + + + {{#priorities}} + + + + + + + + + {{/priorities}} + {{^priorities}} + + {{/priorities}} + +
RankClassPriorityEffortDisharmoniesRecommendation
{{rank}}{{className}}{{priority}}{{effort}}{{disharmonies}}{{recommendation}}
No refactoring priorities identified. Nice work!
+ diff --git a/assets/sentry-config.js b/assets/sentry-config.js new file mode 100644 index 0000000..6d4a852 --- /dev/null +++ b/assets/sentry-config.js @@ -0,0 +1,17 @@ +// Optional Sentry error tracking. Loaded lazily by index.html when a +// DSN is configured. Keep the DSN empty to disable error reporting. +export const SENTRY_DSN = ''; + +export function initSentry() { + if (!SENTRY_DSN || typeof window === 'undefined') return; + import('https://cdn.jsdelivr.net/npm/@sentry/browser@7/+esm') + .then(Sentry => { + Sentry.init({ + dsn: SENTRY_DSN, + tracesSampleRate: 0.1, + replaysSessionSampleRate: 0 + }); + window.Sentry = Sentry; + }) + .catch(() => { /* Sentry unavailable - errors fall back to console */ }); +} diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml new file mode 100644 index 0000000..8df1ec1 --- /dev/null +++ b/bitbucket-pipelines.yml @@ -0,0 +1,32 @@ +# Bitbucket Pipelines configuration for this RefactorFirst Pages site. +# +# Bitbucket serves static sites from a repository named .bitbucket.io +# automatically whenever files are pushed, so no deploy step is needed here. +# This pipeline keeps repositories.txt tidy and verifies the site files exist. + +image: alpine:3.20 + +pipelines: + default: + - step: + name: Validate site files + script: + - apk add --no-cache grep coreutils + - test -f index.html + - test -f repositories.txt + # Listing must be sorted, unique and well-formed (one user/repo per line) + - sort -c repositories.txt + - '! grep -Ev "^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$" repositories.txt' + + custom: + # Manual pipeline: normalize repositories.txt and push the result back. + sort-repos: + - step: + name: Normalize repositories.txt + script: + - sort -u -o repositories.txt repositories.txt + - git config user.email "pipeline-bot@bitbucket.org" + - git config user.name "Bitbucket Pipeline" + - git add repositories.txt + - git commit -m "Normalize repositories.txt" || echo "No changes" + - git push diff --git a/bun.config.js b/bun.config.js new file mode 100644 index 0000000..22559e3 --- /dev/null +++ b/bun.config.js @@ -0,0 +1,10 @@ +export default { + test: { + environment: 'jsdom', + coverage: { + reporter: ['text', 'html'], + include: ['js/**/*.js'], + exclude: ['tests/'] + } + } +}; \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..d6a1529 --- /dev/null +++ b/bun.lock @@ -0,0 +1,379 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "refactorfirst-page", + "dependencies": { + "mustache": "^4.2.0", + }, + "devDependencies": { + "@playwright/test": "^1.40.0", + "@sentry/browser": "^7.80.0", + "eslint": "^8.55.0", + "jsdom": "^23.0.0", + }, + }, + }, + "packages": { + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@2.0.2", "", { "dependencies": { "bidi-js": "^1.0.3", "css-tree": "^2.3.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], + + "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], + + "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + + "@sentry-internal/feedback": ["@sentry-internal/feedback@7.120.4", "", { "dependencies": { "@sentry/core": "7.120.4", "@sentry/types": "7.120.4", "@sentry/utils": "7.120.4" } }, "sha512-eSwgvTdrh03zYYaI6UVOjI9p4VmKg6+c2+CBQfRZX++6wwnCVsNv7XF7WUIpVGBAkJ0N2oapjQmCzJKGKBRWQg=="], + + "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@7.120.4", "", { "dependencies": { "@sentry/core": "7.120.4", "@sentry/replay": "7.120.4", "@sentry/types": "7.120.4", "@sentry/utils": "7.120.4" } }, "sha512-2+W4CgUL1VzrPjArbTid4WhKh7HH21vREVilZdvffQPVwOEpgNTPAb69loQuTlhJVveh9hWTj2nE5UXLbLP+AA=="], + + "@sentry-internal/tracing": ["@sentry-internal/tracing@7.120.4", "", { "dependencies": { "@sentry/core": "7.120.4", "@sentry/types": "7.120.4", "@sentry/utils": "7.120.4" } }, "sha512-Fz5+4XCg3akeoFK+K7g+d7HqGMjmnLoY2eJlpONJmaeT9pXY7yfUyXKZMmMajdE2LxxKJgQ2YKvSCaGVamTjHw=="], + + "@sentry/browser": ["@sentry/browser@7.120.4", "", { "dependencies": { "@sentry-internal/feedback": "7.120.4", "@sentry-internal/replay-canvas": "7.120.4", "@sentry-internal/tracing": "7.120.4", "@sentry/core": "7.120.4", "@sentry/integrations": "7.120.4", "@sentry/replay": "7.120.4", "@sentry/types": "7.120.4", "@sentry/utils": "7.120.4" } }, "sha512-ymlNtIPG6HAKzM/JXpWVGCzCNufZNADfy+O/olZuVJW5Be1DtOFyRnBvz0LeKbmxJbXb2lX/XMhuen6PXPdoQw=="], + + "@sentry/core": ["@sentry/core@7.120.4", "", { "dependencies": { "@sentry/types": "7.120.4", "@sentry/utils": "7.120.4" } }, "sha512-TXu3Q5kKiq8db9OXGkWyXUbIxMMuttB5vJ031yolOl5T/B69JRyAoKuojLBjRv1XX583gS1rSSoX8YXX7ATFGA=="], + + "@sentry/integrations": ["@sentry/integrations@7.120.4", "", { "dependencies": { "@sentry/core": "7.120.4", "@sentry/types": "7.120.4", "@sentry/utils": "7.120.4", "localforage": "^1.8.1" } }, "sha512-kkBTLk053XlhDCg7OkBQTIMF4puqFibeRO3E3YiVc4PGLnocXMaVpOSCkMqAc1k1kZ09UgGi8DxfQhnFEjUkpA=="], + + "@sentry/replay": ["@sentry/replay@7.120.4", "", { "dependencies": { "@sentry-internal/tracing": "7.120.4", "@sentry/core": "7.120.4", "@sentry/types": "7.120.4", "@sentry/utils": "7.120.4" } }, "sha512-FW8sPenNFfnO/K7sncsSTX4rIVak9j7VUiLIagJrcqZIC7d1dInFNjy8CdVJUlyz3Y3TOgIl3L3+ZpjfyMnaZg=="], + + "@sentry/types": ["@sentry/types@7.120.4", "", {}, "sha512-cUq2hSSe6/qrU6oZsEP4InMI5VVdD86aypE+ENrQ6eZEVLTCYm1w6XhW1NvIu3UuWh7gZec4a9J7AFpYxki88Q=="], + + "@sentry/utils": ["@sentry/utils@7.120.4", "", { "dependencies": { "@sentry/types": "7.120.4" } }, "sha512-zCKpyDIWKHwtervNK2ZlaK8mMV7gVUijAgFeJStH+CU/imcdquizV3pFLlSQYRswG+Lbyd6CT/LGRh3IbtkCFw=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], + + "brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], + + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.1", "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA=="], + + "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], + + "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], + + "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], + + "jsdom": ["jsdom@23.2.0", "", { "dependencies": { "@asamuzakjp/dom-selector": "^2.0.1", "cssstyle": "^4.0.1", "data-urls": "^5.0.0", "decimal.js": "^10.4.3", "form-data": "^4.0.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "is-potential-custom-element-name": "^1.0.1", "parse5": "^7.1.2", "rrweb-cssom": "^0.6.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^4.1.3", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0", "ws": "^8.16.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^2.11.2" }, "optionalPeers": ["canvas"] }, "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="], + + "localforage": ["localforage@1.10.0", "", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + + "rrweb-cssom": ["rrweb-cssom@0.6.0", "", {}, "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + + "tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + + "universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "cssstyle/rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..092911f --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./tests/setup.js"] diff --git a/css/components.css b/css/components.css new file mode 100644 index 0000000..cd7dd4d --- /dev/null +++ b/css/components.css @@ -0,0 +1,273 @@ +/* Component styles: top menu, search, repo grid, pagination, forms, errors. */ + +/* ---- Top navigation (max 140px, mobile hamburger) ---- */ +#top-menu { + background: var(--surface-color); + border-bottom: 1px solid #dde4ea; + max-height: var(--menu-max-height); +} + +.menu-bar { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: nowrap; + max-width: 1200px; + margin: 0 auto; + padding: 0.3rem 0.8rem; + min-height: 48px; + overflow: hidden; +} + +.brand { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: bold; + color: var(--brand-color); + text-decoration: none; + white-space: nowrap; + font-size: 0.9rem; +} + +.menu-search { + position: relative; + flex: 1 1 180px; + max-width: 320px; +} + +.menu-search input { + width: 100%; + padding: 0.35rem 0.6rem; + border: 1px solid #c6d2dc; + border-radius: var(--radius); +} + +.menu-toggle { + display: none; + background: none; + border: 1px solid #c6d2dc; + border-radius: var(--radius); + font-size: 1.1rem; + padding: 0.25rem 0.6rem; + cursor: pointer; +} + +.menu-links { + display: flex; + align-items: center; + gap: 0.2rem; + flex-wrap: nowrap; + list-style: none; + margin: 0; + padding: 0; + overflow: hidden; +} + +.menu-links a { + display: inline-block; + padding: 0.2rem 0.35rem; + border-radius: var(--radius); + text-decoration: none; + color: var(--text-color); + font-size: 0.8rem; +} + +.menu-links a:hover, +.menu-links a:focus-visible { + background: var(--brand-color); + color: #fff; +} + +/* ---- Search results ---- */ +.search-results { + position: absolute; + left: 0; + right: 0; + top: 100%; + z-index: 50; + margin: 0.15rem 0 0; + padding: 0; + list-style: none; + background: #fff; + border: 1px solid #c6d2dc; + border-radius: var(--radius); + box-shadow: var(--shadow); + max-height: 16rem; + overflow-y: auto; +} + +.search-results li { + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.search-results li.active, +.search-results li:hover { + background: var(--brand-color); + color: #fff; +} + +.hero-search { + position: relative; + max-width: 480px; + margin: 1rem auto; + text-align: left; +} + +/* ---- Repository grid ---- */ +.repo-grid, +.featured-repos ul { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + list-style: none; + padding: 0; + max-width: 720px; + margin-left: auto; + margin-right: auto; +} + +.repo-card { + display: block; + padding: 0.85rem 1rem; + border: 1px solid #dde4ea; + border-radius: var(--radius); + text-decoration: none; + color: var(--text-color); + background: #fff; + box-shadow: var(--shadow); +} + +.repo-card:hover, +.repo-card:focus-visible { + border-color: var(--brand-color); + color: var(--brand-color); +} + +/* ---- Pagination ---- */ +.pagination { + display: flex; + gap: 0.4rem; + justify-content: center; + margin-top: 1.5rem; +} + +.pagination .page { + padding: 0.35rem 0.65rem; + border: 1px solid #c6d2dc; + border-radius: var(--radius); + text-decoration: none; +} + +.pagination .page.current { + background: var(--brand-color); + border-color: var(--brand-color); + color: #fff; +} + +/* ---- Forms / add-repo ---- */ +.user-info { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 1rem; +} + +.user-avatar { + border-radius: 50%; +} + +#repo-form { + display: grid; + gap: 0.5rem; + max-width: 420px; +} + +.form-status.error { + color: var(--error-color); +} + +.form-status.success { + color: var(--success-color); +} + +.cta, +#login-github { + display: inline-block; + background: var(--brand-color); + color: #fff; + padding: 0.6rem 1.2rem; + border-radius: var(--radius); + border: none; + font-size: 1rem; + text-decoration: none; + cursor: pointer; +} + +.cta:hover, +#login-github:hover { + background: var(--brand-accent); + color: #fff; +} + +/* ---- Error pages ---- */ +.error-page { + max-width: 640px; + margin: 3rem auto; + text-align: center; + padding: 2rem; + border: 1px solid #f0c6c0; + border-radius: var(--radius); + background: #fdf6f5; +} + +.error-page h1 { + color: var(--error-color); +} + +.error-code { + color: var(--muted-color); + font-size: 0.85rem; +} + +.error-actions { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 1rem; +} + +.error-actions .retry { + background: var(--brand-color); + color: #fff; + border: none; + border-radius: var(--radius); + padding: 0.5rem 1rem; + cursor: pointer; +} + +/* ---- Responsive ---- */ +@media (max-width: 700px) { + .menu-toggle { + display: block; + margin-left: auto; + } + + .menu-links { + display: none; + flex-basis: 100%; + flex-direction: column; + align-items: stretch; + padding-bottom: 0.5rem; + } + + .menu-links.open { + display: flex; + } + + .repo-grid, + .featured-repos ul { + grid-template-columns: 1fr; + } +} diff --git a/css/main.css b/css/main.css new file mode 100644 index 0000000..7a59f5c --- /dev/null +++ b/css/main.css @@ -0,0 +1,93 @@ +/* Main stylesheet: CSS custom properties, base layout, footer. */ + +:root { + --brand-color: #2a6f97; + --brand-accent: #61c0bf; + --text-color: #1f2d3d; + --muted-color: #5c6b7a; + --bg-color: #ffffff; + --surface-color: #f5f8fa; + --error-color: #c0392b; + --success-color: #1e8449; + --menu-max-height: 200px; + --menu-height: 48px; + --radius: 6px; + --shadow: 0 1px 4px rgba(0, 0, 0, 0.12); +} + +body { + margin: 0; + color: var(--text-color); + background: var(--bg-color); +} + +.skip-link { + position: absolute; + left: -9999px; + top: 0; + background: var(--brand-color); + color: #fff; + padding: 0.5rem 1rem; + z-index: 100; +} + +.skip-link:focus { + left: 0; +} + +main { + max-width: 1000px; + margin: 0 auto; + padding: 1rem 1.5rem 3rem; + min-height: 60vh; +} + +main:focus { + outline: none; +} + +.hero { + text-align: center; + padding: 2.5rem 1rem; +} + +.hero h1 { + color: var(--brand-color); + margin-bottom: 0.25rem; +} + +.loading { + text-align: center; + color: var(--muted-color); + padding: 3rem 0; +} + +.site-footer { + text-align: center; + padding: 1.5rem 1rem; + color: var(--muted-color); + font-size: 0.85rem; + border-top: 1px solid var(--surface-color); +} + +a { + color: var(--brand-color); +} + +code { + background: var(--surface-color); + padding: 0.1em 0.35em; + border-radius: 4px; +} + +pre { + background: var(--surface-color); + padding: 1rem; + border-radius: var(--radius); + overflow-x: auto; +} + +:focus-visible { + outline: 2px solid var(--brand-accent); + outline-offset: 2px; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..af91ba9 --- /dev/null +++ b/index.html @@ -0,0 +1,56 @@ + + + + + + + + RefactorFirst - Know Where to Refactor First + + + + + + +
+ +
+
+

Loading…

+
+ + + + diff --git a/js/cache-manager.js b/js/cache-manager.js new file mode 100644 index 0000000..c30b38e --- /dev/null +++ b/js/cache-manager.js @@ -0,0 +1,54 @@ +// Client-side caching for GitHub API/raw responses with TTL expiry +// and LRU eviction. + +export class CacheManager { + constructor({ maxEntries = 100, defaultTtlMs = 300000 } = {}) { + this.maxEntries = maxEntries; + this.defaultTtlMs = defaultTtlMs; + this.store = new Map(); // key -> { value, expiresAt } + } + + static buildKey(prefix, params) { + const sorted = Object.keys(params) + .sort() + .map(key => `${key}=${params[key]}`) + .join('&'); + return `${prefix}:${sorted}`; + } + + get(key, now = Date.now()) { + const entry = this.store.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= now) { + this.store.delete(key); + return undefined; + } + // Refresh recency for LRU semantics. + this.store.delete(key); + this.store.set(key, entry); + return entry.value; + } + + set(key, value, ttlMs = this.defaultTtlMs) { + if (this.store.has(key)) { + this.store.delete(key); + } else if (this.store.size >= this.maxEntries) { + // Evict the least recently used (oldest) entry. + const oldestKey = this.store.keys().next().value; + this.store.delete(oldestKey); + } + this.store.set(key, { value, expiresAt: Date.now() + ttlMs }); + } + + invalidate(key) { + this.store.delete(key); + } + + clear() { + this.store.clear(); + } + + size() { + return this.store.size; + } +} diff --git a/js/error-handler.js b/js/error-handler.js new file mode 100644 index 0000000..14b2e1c --- /dev/null +++ b/js/error-handler.js @@ -0,0 +1,96 @@ +// Centralized error handling: classification, friendly messages and +// error page rendering with retry support. + +import { escapeHtml } from './utils.js'; + +const ERROR_INFO = { + 'not-found': { + title: 'Repository or Report Not Found', + suggestion: 'Check that the repository exists and contains a .refactorfirst/refactor-first.json file on the requested branch.' + }, + 'rate-limit': { + title: 'GitHub API Rate Limit Reached', + suggestion: 'Too many requests were made to the GitHub API. Please wait a few minutes and try again.' + }, + 'oauth': { + title: 'Authentication Error', + suggestion: 'There was a problem signing in with GitHub. Please try logging in again.' + }, + 'network': { + title: 'Network Error', + suggestion: 'Check your internet connection and try again.' + }, + 'template': { + title: 'Report Template Error', + suggestion: 'The report template could not be loaded or rendered. The repository may use an incompatible template.' + }, + 'api': { + title: 'GitHub API Error', + suggestion: 'The GitHub API returned an unexpected error. Please try again later.' + }, + 'general': { + title: 'Something Went Wrong', + suggestion: 'An unexpected error occurred. If it persists, please open an issue on GitHub.' + } +}; + +export function classifyError(error) { + const status = error && error.status; + const message = (error && error.message) || ''; + + if (status === 404 || message.includes('Repository not found') || message === 'Not Found') { + return { type: 'not-found', status: status || 404 }; + } + if (status === 403 || status === 429 || /rate limit/i.test(message)) { + return { type: 'rate-limit', status }; + } + if (message.startsWith('OAuth ') || message.includes('access_denied')) { + return { type: 'oauth', status }; + } + if (error instanceof TypeError && /fetch|network/i.test(message)) { + return { type: 'network', status }; + } + if (message.includes('template')) { + return { type: 'template', status }; + } + if (typeof status === 'number' && status >= 400) { + return { type: 'api', status }; + } + return { type: 'general', status }; +} + +export function userMessageFor(type) { + return ERROR_INFO[type] || ERROR_INFO.general; +} + +// Render an error page into the container. onRetry adds a retry button +// for recoverable errors. +export function renderErrorPage(container, error, { onRetry } = {}) { + const { type, status } = classifyError(error); + const { title, suggestion } = userMessageFor(type); + const isRecoverable = ['rate-limit', 'network', 'api'].includes(type); + + container.innerHTML = ` + `; + + const retryButton = container.querySelector('button.retry'); + if (retryButton && onRetry) { + retryButton.addEventListener('click', onRetry); + } +} + +// Log errors to the console and to Sentry when it is available. +export function logError(error, context = {}) { + console.error('[RefactorFirst]', error, context); + if (typeof window !== 'undefined' && window.Sentry && typeof window.Sentry.captureException === 'function') { + window.Sentry.captureException(error, { extra: context }); + } +} diff --git a/js/fetcher.js b/js/fetcher.js new file mode 100644 index 0000000..9264798 --- /dev/null +++ b/js/fetcher.js @@ -0,0 +1,91 @@ +export function constructRawUrl(username, repository, branch) { + return `https://raw.githubusercontent.com/${username}/${repository}/${branch}/.refactorfirst/refactor-first.json`; +} + +export function constructTemplateUrl(username, repository, branch) { + return `https://raw.githubusercontent.com/${username}/${repository}/${branch}/.refactorfirst/refactor-first-report.mustache`; +} + +export async function fetchJson(username, repository, branch) { + const url = constructRawUrl(username, repository, branch); + const response = await fetch(url, { + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Repository not found'); + } + throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +export async function fetchTemplate(username, repository, branch, fallbackTemplate = null) { + const url = constructTemplateUrl(username, repository, branch); + try { + const response = await fetch(url, { + headers: { + 'Accept': 'text/plain' + } + }); + if (!response.ok) { + if (fallbackTemplate) return fallbackTemplate; + throw new Error(`Failed to fetch template: ${response.status} ${response.statusText}`); + } + return await response.text(); + } catch (error) { + if (fallbackTemplate) return fallbackTemplate; + throw error; + } +} + +// Fetch with retry + exponential backoff for transient network/server errors. +// 4xx responses are returned immediately - retrying them is pointless. +export async function fetchWithRetry(url, options = {}, { retries = 3, baseDelayMs = 250 } = {}) { + let attempt = 0; + for (;;) { + try { + const response = await fetch(url, options); + if (!response.ok && response.status >= 500 && attempt < retries) { + attempt++; + await new Promise(resolve => setTimeout(resolve, baseDelayMs * 2 ** (attempt - 1))); + continue; + } + return response; + } catch (error) { + if (attempt >= retries) throw error; + attempt++; + await new Promise(resolve => setTimeout(resolve, baseDelayMs * 2 ** (attempt - 1))); + } + } +} + +// Branch fallback: try the requested branch; when the default branch (main) +// fails, fall back to master. Returns { data, branch }. +export async function fetchJsonWithFallback(username, repository, branch = getDefaultBranchName()) { + try { + return { data: await fetchJson(username, repository, branch), branch }; + } catch (error) { + if (branch === getDefaultBranchName()) { + const data = await fetchJson(username, repository, 'master'); + return { data, branch: 'master' }; + } + throw error; + } +} + +function getDefaultBranchName() { + return 'main'; +} + +// Fetch both the report JSON and the Mustache template for a repository, +// applying branch fallback logic and the bundled fallback template. +export async function fetchReport(username, repository, branch = 'main', { fallbackTemplate = null } = {}) { + const { data, branch: resolvedBranch } = await fetchJsonWithFallback(username, repository, branch); + const template = await fetchTemplate(username, repository, resolvedBranch, fallbackTemplate); + return { data, template, branch: resolvedBranch }; +} \ No newline at end of file diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..88ee505 --- /dev/null +++ b/js/main.js @@ -0,0 +1,381 @@ +// Main application entry point: routes URLs to renderers and wires the UI. + +import { + classifyRoute, + getQueryParam, + navigateTo, + onRouteChange, + buildRepositoryListUrl +} from './router.js'; +import { parseRepositories, createSearch } from './search.js'; +import { + escapeHtml, + paginate, + reposForUser, + sortByRepository, + renderPaginationControls, + detectHostingEnvironment +} from './utils.js'; +import { fetchReport } from './fetcher.js'; +import { renderTemplate } from './renderer.js'; +import { renderErrorPage, logError } from './error-handler.js'; +import { + buildAuthorizationUrl, + parseCallback, + exchangeCodeForToken, + isAuthenticated, + fetchUserProfile, + getToken, + logout +} from './oauth-handler.js'; +import { submitRepository, validateRepositoryInput } from './repo-submission.js'; + +const OAUTH_CLIENT_ID = 'YOUR_GITHUB_OAUTH_CLIENT_ID'; +const OAUTH_SCOPES = ['public_repo', 'read:user']; +const FEATURED_COUNT = 6; + +// Built-in fallback page fragments used when the HTML templates in +// /templates cannot be loaded (e.g. file:// testing or network failure). +const FALLBACK_LANDING = ` +
+

RefactorFirst

+

Know which parts of your codebase to refactor first. Search for a repository to see its report.

+

Add My Repo

+ +
`; + +const FALLBACK_USER_REPOS = ` +

{{username}}

+
{{{cards}}}
+ {{{pagination}}}`; + +function renderHeroSearch(root) { + const hero = root.querySelector('.hero') || root; + const container = document.createElement('div'); + container.className = 'hero-search'; + container.innerHTML = ` + + `; + hero.insertBefore(container, hero.querySelector('.featured-repos')); + return container; +} + +function fetchText(url) { + return fetch(url).then(response => { + if (!response.ok) { + const error = new Error(`Failed to load ${url}`); + error.status = response.status; + throw error; + } + return response.text(); + }); +} + +export function createApp({ root, onNavigate, onExternalRedirect, hostEnvironment } = {}) { + if (!root) throw new Error('createApp requires a root element'); + + const environment = hostEnvironment || detectHostingEnvironment(location.hostname); + + const navigate = onNavigate || navigateTo; + const externalRedirect = onExternalRedirect || (url => { location.assign(url); }); + const pending = new Set(); + let repositoriesPromise = null; + + function loadRepositories() { + repositoriesPromise ||= fetchText('/repositories.txt').then(parseRepositories); + return repositoriesPromise; + } + + function track(promise) { + pending.add(promise); + promise.finally(() => pending.delete(promise)); + return promise; + } + + async function renderLanding() { + const repositories = await loadRepositories().catch(() => []); + root.innerHTML = FALLBACK_LANDING; + + const featuredList = root.querySelector('.featured-repos ul'); + if (featuredList) { + for (const repo of repositories.slice(0, FEATURED_COUNT)) { + const item = document.createElement('li'); + const link = document.createElement('a'); + link.href = buildRepositoryListUrl(repo.fullName); + link.textContent = repo.fullName; + link.setAttribute('data-link', ''); + item.appendChild(link); + featuredList.appendChild(item); + } + } + + const searchContainer = renderHeroSearch(root); + createSearch({ + input: searchContainer.querySelector('input'), + resultsList: searchContainer.querySelector('ul'), + repositories, + debounceMs: 0, + onNavigate: repo => navigate(buildRepositoryListUrl(repo.fullName)) + }); + } + + async function renderUserListing(username) { + const repositories = await loadRepositories(); + const sorted = sortByRepository(reposForUser(repositories, username)); + const pageParam = getQueryParam(location.search, 'page'); + const { items, page, totalPages } = paginate(sorted, pageParam ? Number(pageParam) : 1); + + const cards = items.map(repo => + ` + ${escapeHtml(repo.repository)} + ` + ).join(''); + + root.innerHTML = renderTemplate(FALLBACK_USER_REPOS, { + username, + cards, + pagination: renderPaginationControls({ page, totalPages, baseUrl: `/${username}` }) + }); + } + + async function renderReport({ username, repository, branch }) { + root.innerHTML = '

Loading report…

'; + try { + const fallbackTemplate = await fetch('/assets/refactor-first-report.mustache') + .then(res => (res.ok ? res.text() : null)) + .catch(() => null); + const { data, template, branch: resolvedBranch } = + await fetchReport(username, repository, branch, { fallbackTemplate }); + root.innerHTML = renderTemplate(template, data); + root.dataset.resolvedBranch = resolvedBranch; + } catch (error) { + logError(error, { route: 'report', username, repository, branch }); + renderErrorPage(root, error, { onRetry: () => track(renderReport({ username, repository, branch })) }); + } + } + + function renderLogin() { + root.innerHTML = ` + `; + root.querySelector('#login-github').addEventListener('click', () => { + track( + buildAuthorizationUrl({ + clientId: OAUTH_CLIENT_ID, + redirectUri: `${location.origin}/add-repo/callback`, + scopes: OAUTH_SCOPES + }).then(url => externalRedirect(url)) + ); + }); + } + + function renderSubmissionForm(profile) { + root.innerHTML = ` +
+

Add Your Repository

+ +

Only repositories with a .refactorfirst/refactor-first.json + file will be added. The RefactorFirst GitHub Page redeploys every 10 minutes.

+
+ + + + + +
+

+
`; + + root.querySelector('#logout').addEventListener('click', () => { + logout(); + navigate('/add-repo'); + }); + + root.querySelector('#repo-form').addEventListener('submit', event => { + event.preventDefault(); + const form = event.target; + const button = form.querySelector('button[type="submit"]'); + const status = root.querySelector('.form-status'); + const owner = form.querySelector('#repo-owner').value; + const repo = form.querySelector('#repo-name').value; + + const validation = validateRepositoryInput(owner, repo); + status.classList.remove('success', 'error'); + if (!validation.valid) { + status.textContent = validation.errors.join('. '); + status.classList.add('error'); + return; + } + + button.disabled = true; + status.textContent = 'Validating repository...'; + track( + submitRepository({ owner, repo }, profile.username, getToken()) + .then(result => { + status.textContent = result.message; + status.classList.add(result.success ? 'success' : 'error'); + }) + .catch(error => { + status.textContent = error.message; + status.classList.add('error'); + }) + .finally(() => { button.disabled = false; }) + ); + }); + } + + async function renderAddRepo() { + if (!isAuthenticated()) { + renderLogin(); + return; + } + try { + const profile = await fetchUserProfile(); + renderSubmissionForm(profile); + } catch (error) { + logError(error, { route: 'add-repo' }); + renderErrorPage(root, error); + } + } + + async function renderOAuthCallback() { + try { + const { code } = parseCallback(location.search); + const codeVerifier = sessionStorage.getItem('oauth_code_verifier'); + await exchangeCodeForToken({ + code, + codeVerifier, + clientId: OAUTH_CLIENT_ID, + redirectUri: `${location.origin}/add-repo/callback` + }); + navigate('/add-repo'); + } catch (error) { + logError(error, { route: 'oauth-callback' }); + renderErrorPage(root, error); + } + } + + // Load the CI sample matching the hosting environment into the + // Getting Started page so users only see instructions for their platform. + async function renderWorkflowSample() { + const slot = root.querySelector('#workflow-sample'); + if (!slot) return; + try { + slot.innerHTML = await fetchText(`/templates/workflow-sample-${environment}.html`); + } catch { + slot.innerHTML = + '

The workflow sample for this environment ' + + 'could not be loaded. See the repository README for manual setup instructions.

'; + } + } + + async function renderStaticPage(page) { + try { + root.innerHTML = await fetchText(`/templates/${page}.html`); + if (page === 'getting-started') { + await renderWorkflowSample(); + } + } catch (error) { + logError(error, { route: 'page', page }); + renderErrorPage(root, error); + } + } + + async function handleRoute() { + const route = classifyRoute(location.pathname); + switch (route.type) { + case 'landing': + return renderLanding(); + case 'user': + return renderUserListing(route.username); + case 'report': + return renderReport(route); + case 'page': + return renderStaticPage(route.page); + case 'add-repo': + return renderAddRepo(); + case 'oauth-callback': + return renderOAuthCallback(); + default: + return renderErrorPage(root, Object.assign(new Error('Page not found'), { status: 404 })); + } + } + + return { + handleRoute, + renderLanding, + renderUserListing, + renderReport, + renderAddRepo, + renderStaticPage, + pendingSubmissions: () => Promise.all([...pending]) + }; +} + +// Boot the application against the real document. +export function initApp() { + const root = document.getElementById('app'); + if (!root) { + console.error('Root element #app not found'); + return; + } + + const app = createApp({ root }); + + // Top-menu search + const menuInput = document.getElementById('menu-search-input'); + const menuResults = document.getElementById('menu-search-results'); + if (menuInput && menuResults) { + fetchText('/repositories.txt').then(text => { + createSearch({ + input: menuInput, + resultsList: menuResults, + repositories: parseRepositories(text) + }); + }).catch(() => { /* search unavailable without repository listing */ }); + } + + // Hamburger menu + const toggle = document.getElementById('menu-toggle'); + const links = document.getElementById('menu-links'); + if (toggle && links) { + toggle.addEventListener('click', () => { + const open = links.classList.toggle('open'); + toggle.setAttribute('aria-expanded', String(open)); + }); + } + + // Intercept internal links for client-side navigation + document.addEventListener('click', event => { + const anchor = event.target.closest('a[data-link]'); + if (!anchor) return; + const url = new URL(anchor.href, location.origin); + if (url.origin !== location.origin) return; + event.preventDefault(); + navigateTo(url.pathname + url.search); + }); + + onRouteChange(() => app.handleRoute()); + app.handleRoute(); +} + +if (typeof document !== 'undefined' && globalThis.__APP_AUTO_INIT__ !== false) { + // Wait for DOM to be ready before initializing + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initApp); + } else { + // Use setTimeout to ensure DOM is fully processed + setTimeout(initApp, 0); + } +} diff --git a/js/oauth-handler.js b/js/oauth-handler.js new file mode 100644 index 0000000..120b243 --- /dev/null +++ b/js/oauth-handler.js @@ -0,0 +1,151 @@ +// GitHub OAuth 2.0 authorization code flow with PKCE. +// Tokens live only in sessionStorage and are cleared on logout/session end. + +const TOKEN_KEY = 'oauth_access_token'; +const STATE_KEY = 'oauth_state'; +const VERIFIER_KEY = 'oauth_code_verifier'; + +const AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'; +const TOKEN_URL = 'https://github.com/login/oauth/access_token'; +const USER_API_URL = 'https://api.github.com/user'; + +function base64UrlEncode(bytes) { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function randomBytes(length) { + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + return bytes; +} + +// Random value used to correlate the OAuth redirect (CSRF protection). +export function generateState() { + return base64UrlEncode(randomBytes(24)); +} + +// PKCE code verifier + S256 challenge. +export async function generatePKCE() { + const codeVerifier = base64UrlEncode(randomBytes(48)); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)); + const codeChallenge = base64UrlEncode(new Uint8Array(digest)); + return { codeVerifier, codeChallenge }; +} + +// Build the GitHub authorization URL and persist state + verifier for the callback. +export async function buildAuthorizationUrl({ clientId, redirectUri, scopes }) { + const state = generateState(); + const { codeVerifier, codeChallenge } = await generatePKCE(); + sessionStorage.setItem(STATE_KEY, state); + sessionStorage.setItem(VERIFIER_KEY, codeVerifier); + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + scope: scopes.join(' '), + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }); + return `${AUTHORIZE_URL}?${params.toString()}`; +} + +// Validate the OAuth callback query string. Returns the authorization code. +export function parseCallback(search) { + const params = new URLSearchParams(search || ''); + const error = params.get('error'); + if (error) { + throw new Error(`OAuth error: ${error}`); + } + + const state = params.get('state'); + const expected = sessionStorage.getItem(STATE_KEY); + if (!state || state !== expected) { + throw new Error('OAuth state mismatch - possible CSRF attack'); + } + + const code = params.get('code'); + if (!code) { + throw new Error('OAuth callback missing authorization code'); + } + return { code }; +} + +// Exchange the authorization code for an access token (PKCE). +export async function exchangeCodeForToken({ code, codeVerifier, clientId, redirectUri }) { + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier + }).toString() + }); + + if (!response.ok) { + throw new Error(`Token exchange failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + if (data.error) { + throw new Error(`OAuth error: ${data.error}`); + } + if (!data.access_token) { + throw new Error('Token exchange response missing access token'); + } + + storeToken(data.access_token); + sessionStorage.removeItem(VERIFIER_KEY); + sessionStorage.removeItem(STATE_KEY); + return data.access_token; +} + +export function storeToken(token) { + sessionStorage.setItem(TOKEN_KEY, token); +} + +export function getToken() { + return sessionStorage.getItem(TOKEN_KEY); +} + +export function isAuthenticated() { + return getToken() !== null; +} + +export async function fetchUserProfile() { + const token = getToken(); + if (!token) { + throw new Error('Not authenticated'); + } + const response = await fetch(USER_API_URL, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json' + } + }); + if (!response.ok) { + throw new Error(`Failed to fetch user profile: ${response.status}`); + } + const data = await response.json(); + return { username: data.login, avatarUrl: data.avatar_url }; +} + +// Remove token and any transient OAuth values. +export function clearSession() { + sessionStorage.removeItem(TOKEN_KEY); + sessionStorage.removeItem(STATE_KEY); + sessionStorage.removeItem(VERIFIER_KEY); +} + +export function logout() { + clearSession(); +} diff --git a/js/rate-limiter.js b/js/rate-limiter.js new file mode 100644 index 0000000..20832b6 --- /dev/null +++ b/js/rate-limiter.js @@ -0,0 +1,65 @@ +// Rate limiting: track GitHub API limits via response headers and +// prevent abuse of the repository submission form. + +export class ApiRateLimiter { + constructor() { + this.remainingCount = null; + this.resetEpochSeconds = null; + } + + // Record X-RateLimit-* style headers from a GitHub API response. + recordResponse({ remaining, resetEpochSeconds }) { + this.remainingCount = remaining; + this.resetEpochSeconds = resetEpochSeconds; + } + + remaining() { + return this.remainingCount; + } + + resetTime() { + return this.resetEpochSeconds; + } + + canMakeRequest() { + if (this.remainingCount === null || this.remainingCount > 0) { + return true; + } + // Exhausted - only allow once the reset window has passed. + return Date.now() / 1000 >= this.resetEpochSeconds; + } + + secondsUntilReset() { + if (this.resetEpochSeconds === null) return 0; + return Math.max(0, Math.ceil(this.resetEpochSeconds - Date.now() / 1000)); + } +} + +// Sliding-window limiter for repository submissions (default 5/hour/user). +export class SubmissionRateLimiter { + constructor({ maxPerWindow = 5, windowMs = 3600000 } = {}) { + this.maxPerWindow = maxPerWindow; + this.windowMs = windowMs; + this.timestamps = new Map(); + } + + #prune(username, now) { + const attempts = (this.timestamps.get(username) || []) + .filter(ts => now - ts < this.windowMs); + this.timestamps.set(username, attempts); + return attempts; + } + + tryAcquire(username, now = Date.now()) { + const attempts = this.#prune(username, now); + if (attempts.length >= this.maxPerWindow) { + return false; + } + attempts.push(now); + return true; + } + + remainingFor(username, now = Date.now()) { + return this.maxPerWindow - this.#prune(username, now).length; + } +} diff --git a/js/renderer.js b/js/renderer.js new file mode 100644 index 0000000..06db080 --- /dev/null +++ b/js/renderer.js @@ -0,0 +1,15 @@ +import Mustache from 'mustache'; + +let mustacheInstance = null; + +export function initializeMustache() { + if (!mustacheInstance) { + mustacheInstance = Mustache; + } + return mustacheInstance; +} + +export function renderTemplate(template, data) { + const mustache = initializeMustache(); + return mustache.render(template, data); +} \ No newline at end of file diff --git a/js/repo-submission.js b/js/repo-submission.js new file mode 100644 index 0000000..d963029 --- /dev/null +++ b/js/repo-submission.js @@ -0,0 +1,139 @@ +// Repository submission: form validation, access checks and +// triggering the add-repository GitHub Actions workflow. + +import { isValidGitHubName } from './router.js'; + +const GITHUB_API = 'https://api.github.com'; +const GITHUB_RAW = 'https://raw.githubusercontent.com'; +const DEFAULT_DISPATCH_REPO = 'refactorfirst/refactorfirst.github.io'; +const REPORT_PATH = '.refactorfirst/refactor-first.json'; + +export const REPORT_MISSING_MESSAGE = + 'The repository specified must have a .refactorfirst/refactor-first.json file present.'; + +export function validateRepositoryInput(owner, repo) { + const errors = []; + if (!owner || !String(owner).trim()) { + errors.push('User/organization (owner) name is required'); + } else if (!isValidGitHubName(owner.trim())) { + errors.push('Invalid owner name: only letters, numbers, dashes, dots and underscores are allowed'); + } + if (!repo || !String(repo).trim()) { + errors.push('Repository name is required'); + } else if (!isValidGitHubName(repo.trim())) { + errors.push('Invalid repository name: only letters, numbers, dashes, dots and underscores are allowed'); + } + return { valid: errors.length === 0, errors }; +} + +// Verify the authenticated user has write/admin access to owner/repo. +export async function checkRepositoryAccess(owner, repo, username, token) { + const response = await fetch( + `${GITHUB_API}/repos/${owner}/${repo}/collaborators/${username}`, + { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json' + } + } + ); + + if (!response.ok) { + if (response.status === 404) { + return { granted: false, reason: 'Repository not found or you do not have access to it' }; + } + if (response.status === 403) { + return { granted: false, reason: 'GitHub API rate limit or permission error' }; + } + return { granted: false, reason: `GitHub API error: ${response.status}` }; + } + + const data = await response.json(); + if (data.permission === 'write' || data.permission === 'admin') { + return { granted: true }; + } + return { granted: false, reason: 'You need write access to submit this repository' }; +} + +// Check whether the repository has a .refactorfirst/refactor-first.json file. +// Tries the main branch first, then the repository's default branch. +export async function checkReportExists(owner, repo, token) { + const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json' + } + }); + if (!response.ok) { + return { exists: false, message: 'Repository not found or inaccessible' }; + } + + const { default_branch: defaultBranch = 'main' } = await response.json(); + const branchesToTry = [...new Set(['main', defaultBranch])]; + + for (const branch of branchesToTry) { + const rawResponse = await fetch(`${GITHUB_RAW}/${owner}/${repo}/${branch}/${REPORT_PATH}`, { + method: 'HEAD' + }); + if (rawResponse.ok) { + return { exists: true, branch }; + } + } + + return { exists: false, message: REPORT_MISSING_MESSAGE }; +} + +// Trigger the add-repository workflow in the listing repository. +export async function triggerAddRepositoryWorkflow({ + owner, repo, submittedBy, token, + dispatchRepo = DEFAULT_DISPATCH_REPO +}) { + const response = await fetch(`${GITHUB_API}/repos/${dispatchRepo}/dispatches`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json' + }, + body: JSON.stringify({ + event_type: 'add-repository', + client_payload: { owner, repo, submitted_by: submittedBy } + }) + }); + + if (!response.ok) { + throw new Error(`Failed to trigger repository validation workflow (HTTP ${response.status})`); + } +} + +// Full submission flow: validate input, check access, verify the report +// exists, then trigger the validation workflow. +export async function submitRepository({ owner, repo }, submittedBy, token, options = {}) { + const validation = validateRepositoryInput(owner, repo); + if (!validation.valid) { + return { success: false, message: validation.errors.join('. ') + ' - required fields must be valid' }; + } + + const cleanOwner = owner.trim(); + const cleanRepo = repo.trim(); + + try { + const access = await checkRepositoryAccess(cleanOwner, cleanRepo, submittedBy, token); + if (!access.granted) { + return { success: false, message: access.reason }; + } + const report = await checkReportExists(cleanOwner, cleanRepo, token); + if (!report.exists) { + return { success: false, message: report.message }; + } + await triggerAddRepositoryWorkflow({ + owner: cleanOwner, repo: cleanRepo, submittedBy, token, + dispatchRepo: options.dispatchRepo || DEFAULT_DISPATCH_REPO + }); + return { + success: true, + message: 'Repository submitted for validation. It will appear in the listing within ~10 minutes.' + }; + } catch (error) { + return { success: false, message: error.message }; + } +} diff --git a/js/router.js b/js/router.js new file mode 100644 index 0000000..7ac48e5 --- /dev/null +++ b/js/router.js @@ -0,0 +1,114 @@ +// URL routing logic: parsing, classification, validation, navigation +// and branch fallback defaults. + +const DEFAULT_BRANCH = 'main'; + +// Pages served as static content templates (matched against first path segment). +const STATIC_PAGES = new Set([ + 'getting-started', + 'documentation', + 'faq', + 'examples', + 'api', + 'about', + 'feedback', + 'privacy-policy', + 'terms-of-service' +]); + +const GITHUB_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,98}[A-Za-z0-9_.])?$/; + +export function parseRoute(path) { + const parts = path.split('/').filter(Boolean); + return { + username: parts[0] || null, + repository: parts[1] || null, + branch: parts[2] || DEFAULT_BRANCH + }; +} + +export function getDefaultBranch() { + return DEFAULT_BRANCH; +} + +// Validate a GitHub username, organization or repository name. +// Rejects empty values and characters that could lead to XSS or path injection. +export function isValidGitHubName(name) { + if (typeof name !== 'string' || name.length === 0 || name.length > 100) { + return false; + } + return GITHUB_NAME_PATTERN.test(name); +} + +// Classify a path into a route the application knows how to render. +export function classifyRoute(path) { + const { username, repository, branch } = parseRoute(path); + + if (!username) { + return { type: 'landing' }; + } + + if (username === 'add-repo' && repository === 'callback') { + return { type: 'oauth-callback' }; + } + + if (username === 'add-repo' && !repository) { + return { type: 'add-repo' }; + } + + if (STATIC_PAGES.has(username) && !repository) { + return { type: 'page', page: username }; + } + + if (!isValidGitHubName(username)) { + return { type: 'not-found' }; + } + + if (!repository) { + return { type: 'user', username }; + } + + if (!isValidGitHubName(repository)) { + return { type: 'not-found' }; + } + + const parts = path.split('/').filter(Boolean); + if (parts.length > 3) { + return { type: 'not-found' }; + } + + return { type: 'report', username, repository, branch }; +} + +export function buildReportUrl(username, repository, branch = DEFAULT_BRANCH) { + const base = `/${encodeURIComponent(username)}/${encodeURIComponent(repository)}`; + return branch === DEFAULT_BRANCH ? base : `${base}/${encodeURIComponent(branch)}`; +} + +export function buildRepositoryListUrl(fullName) { + const [username, repository] = String(fullName).split('/'); + return buildReportUrl(username, repository); +} + +export function getQueryParam(search, name) { + const params = new URLSearchParams(search || ''); + const value = params.get(name); + return value === null ? null : value; +} + +// Client-side navigation: update history and notify the application. +export function navigateTo(path) { + history.pushState(null, '', path); + window.dispatchEvent(new CustomEvent('routechange', { detail: { path } })); +} + +// Register a handler fired on both client-side navigation and browser back/forward. +export function onRouteChange(handler) { + const listener = () => handler(location.pathname); + window.addEventListener('routechange', listener); + window.addEventListener('popstate', listener); + return () => { + window.removeEventListener('routechange', listener); + window.removeEventListener('popstate', listener); + }; +} diff --git a/js/search.js b/js/search.js new file mode 100644 index 0000000..b8ff470 --- /dev/null +++ b/js/search.js @@ -0,0 +1,119 @@ +// Type-ahead search backed by the static repositories.txt listing. + +import { buildRepositoryListUrl, navigateTo } from './router.js'; + +export function parseRepositories(text) { + if (!text) return []; + return text + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) + .map(line => { + const [username, repository] = line.split('/'); + if (!username || !repository) return null; + return { username, repository, fullName: `${username}/${repository}` }; + }) + .filter(Boolean); +} + +export function filterRepositories(repositories, query) { + const normalized = (query || '').trim().toLowerCase(); + if (!normalized) return repositories; + return repositories.filter(repo => + repo.fullName.toLowerCase().includes(normalized) + ); +} + +export function debounce(fn, delayMs = 150) { + if (delayMs <= 0) { + return (...args) => fn(...args); + } + let timer = null; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delayMs); + }; +} + +// Wire a search input + result list with filtering, keyboard navigation +// and click-to-navigate behaviour. +export function createSearch({ + input, + resultsList, + repositories, + onNavigate = repo => navigateTo(buildRepositoryListUrl(repo.fullName)), + maxResults = 10, + debounceMs = 100 +}) { + if (!input || !resultsList) { + throw new Error('createSearch requires an input and a results list'); + } + + let activeIndex = -1; + let matches = []; + + const close = () => { + matches = []; + activeIndex = -1; + resultsList.innerHTML = ''; + resultsList.hidden = true; + input.setAttribute('aria-expanded', 'false'); + }; + + const choose = repo => { + close(); + onNavigate(repo); + }; + + const render = () => { + resultsList.innerHTML = ''; + matches.forEach((repo, index) => { + const item = document.createElement('li'); + item.setAttribute('role', 'option'); + item.id = `search-option-${index}`; + item.textContent = repo.fullName; + item.classList.toggle('active', index === activeIndex); + item.setAttribute('aria-selected', index === activeIndex ? 'true' : 'false'); + item.addEventListener('click', () => choose(repo)); + resultsList.appendChild(item); + }); + resultsList.hidden = matches.length === 0; + input.setAttribute('aria-expanded', matches.length > 0 ? 'true' : 'false'); + }; + + const update = debounce(() => { + const query = input.value; + if (!query.trim()) { + close(); + return; + } + activeIndex = -1; + matches = filterRepositories(repositories, query).slice(0, maxResults); + render(); + }, debounceMs); + + input.addEventListener('input', update); + + input.addEventListener('keydown', event => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (!matches.length) return; + event.preventDefault(); + if (event.key === 'ArrowDown') { + activeIndex = (activeIndex + 1) % matches.length; + } else { + activeIndex = activeIndex <= 0 ? matches.length - 1 : activeIndex - 1; + } + render(); + } else if (event.key === 'Enter') { + if (activeIndex >= 0 && matches[activeIndex]) { + event.preventDefault(); + choose(matches[activeIndex]); + } + } else if (event.key === 'Escape') { + input.value = ''; + close(); + } + }); + + return { close }; +} diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..98618b8 --- /dev/null +++ b/js/utils.js @@ -0,0 +1,71 @@ +// Shared utility helpers: HTML escaping, sorting and pagination. + +export const REPOSITORIES_PER_PAGE = 50; + +const HTML_ESCAPES = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' +}; + +export function escapeHtml(value) { + if (value === null || value === undefined) return ''; + return String(value).replace(/[&<>"']/g, ch => HTML_ESCAPES[ch]); +} + +export function sortByRepository(repositories) { + return [...repositories].sort((a, b) => + a.repository.toLowerCase().localeCompare(b.repository.toLowerCase()) + ); +} + +export function reposForUser(repositories, username) { + const normalized = String(username || '').toLowerCase(); + return repositories.filter(repo => repo.username.toLowerCase() === normalized); +} + +// Slice a list into pages. Returns the requested page clamped to a valid range. +export function paginate(items, page = 1, perPage = REPOSITORIES_PER_PAGE) { + const totalPages = Math.max(1, Math.ceil(items.length / perPage)); + const numericPage = Number.isFinite(Number(page)) ? Number(page) : 1; + const clamped = Math.min(Math.max(1, Math.trunc(numericPage)), totalPages); + const start = (clamped - 1) * perPage; + return { + items: items.slice(start, start + perPage), + page: clamped, + perPage, + totalPages, + totalItems: items.length + }; +} + +// Detect which hosting environment the site is deployed to from the hostname, +// so documentation can show the relevant CI sample only. +export function detectHostingEnvironment(hostname) { + const host = String(hostname || '').toLowerCase(); + if (host.endsWith('.gitlab.io') || host.split('.').includes('gitlab')) { + return 'gitlab'; + } + if (host.endsWith('.bitbucket.io') || host.split('.').includes('bitbucket')) { + return 'bitbucket'; + } + // github.io, github.com, GitHub Enterprise domains and anything else + return 'github'; +} + +// Render pagination controls as HTML links (baseUrl without a query string). +export function renderPaginationControls({ page, totalPages, baseUrl }) { + if (totalPages <= 1) return ''; + const safeBase = escapeHtml(baseUrl); + const links = []; + for (let p = 1; p <= totalPages; p++) { + if (p === page) { + links.push(`${p}`); + } else { + links.push(`${p}`); + } + } + return ``; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a3db335 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "refactorfirst-page", + "version": "1.0.0", + "description": "RefactorFirst GitHub Pages Application", + "type": "module", + "scripts": { + "test": "bun test tests/unit tests/integration", + "test:watch": "bun test --watch tests/unit tests/integration", + "test:coverage": "bun test --coverage tests/unit tests/integration", + "test:e2e": "npx playwright test", + "test:e2e:ui": "npx playwright test --ui", + "lint": "eslint js/**/*.js tests/**/*.js", + "lint:fix": "eslint js/**/*.js tests/**/*.js --fix" + }, + "devDependencies": { + "@playwright/test": "^1.40.0", + "@sentry/browser": "^7.80.0", + "eslint": "^8.55.0", + "jsdom": "^23.0.0" + }, + "dependencies": { + "mustache": "^4.2.0" + }, + "engines": { + "bun": ">=1.0.0" + } +} \ No newline at end of file diff --git a/plans/refactorfirst-page-plan.md b/plans/refactorfirst-page-plan.md new file mode 100644 index 0000000..2c93fa7 --- /dev/null +++ b/plans/refactorfirst-page-plan.md @@ -0,0 +1,2007 @@ +# RefactorFirst GitHub Pages Application Plan + +## Implementation TODO List + +### CRITICAL TDD REQUIREMENTS +- [x] **MANDATORY**: Write failing unit tests BEFORE writing any production code +- [x] **MANDATORY**: Refactor code continuously to improve quality and maintainability +- [x] Follow Red-Green-Refactor cycle for all features +- [x] Ensure all tests pass before committing code +- [x] Never skip writing tests for new functionality + +### Phase 1: Core Infrastructure +- [x] Set up testing infrastructure (Bun, Playwright, package.json) +- [x] Set up basic HTML structure with top menu (TDD approach - failing tests first) +- [x] Implement URL routing system (TDD approach - failing tests first) +- [x] Create repository data file (`../repositories.txt`) +- [x] Set up local testing workflow +- [ ] Register GitHub OAuth App and configure secrets *(external: requires GitHub org admin access)* + +### Phase 2: Landing & Search +- [x] Design and implement landing page (TDD approach - failing tests first) +- [x] Implement type-ahead search bar (TDD approach - failing tests first) +- [x] Create user repository listing page (TDD approach - failing tests first) +- [x] Add pagination for repository lists (TDD approach - failing tests first) +- [x] Implement GitHub OAuth authentication flow (TDD approach - failing tests first) +- [x] Implement "Add My Repo" button and submission form (TDD approach - failing tests first) +- [x] Set up GitHub Actions workflow for repository validation +- [x] Configure scheduled redeployment workflow + +### Phase 3: Report Rendering +- [x] Implement GitHub raw content fetching (TDD approach - failing tests first) +- [x] Integrate Mustache.js rendering (TDD approach - failing tests first) +- [x] Add branch fallback logic (TDD approach - failing tests first) +- [x] Implement error handling and loading states (TDD approach - failing tests first) + +### Phase 4: Additional Pages +- [x] Create About page (TDD approach - failing tests first) +- [x] Create Feedback page (TDD approach - failing tests first) +- [x] Create Getting Started page (TDD approach - failing tests first) +- [x] Create Documentation page (TDD approach - failing tests first) +- [x] Create FAQ page (TDD approach - failing tests first) +- [x] Create Examples gallery page (TDD approach - failing tests first) +- [x] Create API documentation page (TDD approach - failing tests first) + +### Phase 5: Polish & Optimization +- [x] Responsive design improvements (TDD approach - failing tests first) +- [x] Performance optimization (TDD approach - failing tests first) +- [x] Accessibility improvements (TDD approach - failing tests first) +- [x] Error page enhancements (TDD approach - failing tests first) +- [x] Cross-browser testing with Playwright +- [x] Mobile responsiveness testing with Playwright + +### GitHub Actions Workflows +- [x] Create add-repository.yml workflow +- [x] Create redeploy.yml workflow +- [ ] Test workflow execution and error handling *(requires push to GitHub)* +- [x] Create user-provided workflow template + +### Security & Authentication +- [x] Implement GitHub OAuth flow with PKCE +- [ ] Set up OAuth app registration and secrets *(external: requires GitHub org admin access)* +- [x] Implement CSRF protection for OAuth +- [x] Add input validation and sanitization +- [x] Implement rate limiting for submissions +- [x] Add audit logging for repository additions +- [x] Implement Content Security Policy headers +- [x] Add GitHub API rate limiting handling +- [ ] Create OAuth security audit plan *(post-deployment activity)* + +### Testing & Deployment +- [x] Test local development workflow +- [ ] Test GitHub OAuth authentication *(requires a registered OAuth app)* +- [x] Test repository submission flow +- [ ] Test GitHub Actions workflows *(requires push to GitHub)* +- [ ] Deploy to GitHub Pages *(external)* +- [ ] Test production deployment *(external)* +- [ ] Monitor for issues and bugs *(post-deployment activity)* + +### Documentation +- [x] Update Getting Started guide with OAuth instructions +- [x] Update FAQ with OAuth-related questions +- [ ] Create GitHub OAuth setup guide *(folded into Getting Started; dedicated guide outstanding)* +- [x] Document GitHub Actions workflows +- [x] Create troubleshooting guide + +### Risk Mitigation (High Priority) +- [x] Implement GitHub API rate limiting strategy +- [x] Design comprehensive error pages +- [x] Set up monitoring (Sentry, performance monitoring) +- [ ] Create backup strategy for critical data *(not implemented)* +- [x] Implement abuse prevention mechanisms +- [ ] Conduct security audit for OAuth and workflows *(post-deployment activity)* + +### Risk Mitigation (Medium Priority) +- [ ] Define performance budgets and monitoring +- [ ] Implement accessibility testing (WCAG 2.1 AA) *(basic ARIA/keyboard support done; axe-core audit outstanding)* +- [ ] Create CDN fallback strategy +- [x] Analyze test coverage for error paths + +### Risk Mitigation (Low Priority) +- [ ] Implement user analytics +- [x] Create legal documentation (privacy policy, terms) +- [ ] Establish community management guidelines +- [ ] Add international character support +- [ ] Evaluate PWA capabilities + +### Testing Setup +- [x] Set up Bun for unit testing +- [x] Set up Playwright configuration for E2E testing +- [x] Create test fixtures and sample data +- [x] Configure test scripts in package.json +- [ ] Set up pre-commit hooks for testing *(no git repo/husky configured)* +- [x] Configure CI/CD test workflow (Bun + Node.js for Playwright) + +### Test Implementation +- [x] Write unit tests for router.js +- [x] Write unit tests for fetcher.js +- [x] Write unit tests for renderer.js +- [x] Write unit tests for search.js +- [x] Write unit tests for repo-submission.js +- [x] Write unit tests for oauth-handler.js +- [x] Write unit tests for error-handler.js +- [x] Write unit tests for rate-limiter.js +- [x] Write unit tests for cache-manager.js +- [x] Write unit tests for utils.js +- [x] Write integration tests for search flow +- [x] Write integration tests for submission flow +- [x] Write integration tests for report rendering +- [x] Write integration tests for OAuth flow +- [x] Write integration tests for error handling +- [x] Write E2E tests for user journeys +- [x] Write E2E tests for error page flows + +## Critical Analysis: Potential Failure Points + +### Architecture & Technical Risks + +#### 1. GitHub API Rate Limiting +- **Risk**: Client-side GitHub API calls can hit rate limits (5000/hour authenticated, 60/hour unauthenticated) +- **Impact**: Users could experience rate limiting during submission or report viewing +- **Mitigation Implemented**: ✅ Client-side caching, rate limit headers handling, exponential backoff, graceful degradation added to Security Considerations +- **Status**: Mitigated in plan + +#### 2. OAuth Implementation Complexity +- **Risk**: GitHub OAuth requires server-side components for token exchange in standard implementations +- **Impact**: Client-side-only OAuth may be more complex or insecure than planned +- **Mitigation Implemented**: ✅ PKCE implementation, short-lived tokens, regular security audits, token rotation added to Security Considerations +- **Status**: Mitigated in plan + +#### 3. GitHub Pages Deployment Limitations +- **Risk**: GitHub Pages has build time limits (10 minutes) and size limits (1GB) +- **Impact**: Large `../repositories.txt` files or complex builds could fail +- **Mitigation Implemented**: ⚠️ Build time monitoring mentioned but no specific strategy +- **Status**: Partially mitigated - needs monitoring implementation + +#### 4. Cross-Origin Resource Sharing (CORS) +- **Risk**: GitHub raw content supports CORS, but GitHub API may have restrictions +- **Impact**: Some API calls may fail due to CORS policies +- **Mitigation Implemented**: ⚠️ Not specifically addressed in current plan +- **Status**: Needs CORS testing strategy + +### Feature & UX Risks + +#### 5. Repository Validation Scalability +- **Risk**: As repository count grows, validation time increases +- **Impact**: Submission process becomes slow, user experience degrades +- **Mitigation Implemented**: ⚠️ Abuse prevention added but no specific performance targets +- **Status**: Partially mitigated - needs performance benchmarks + +#### 6. Search Performance with Large Datasets +- **Risk**: Type-ahead search with thousands of repositories becomes slow +- **Impact**: Poor user experience, abandoned searches +- **Mitigation Implemented**: ⚠️ Debouncing mentioned but no comprehensive optimization strategy +- **Status**: Partially mitigated - needs performance benchmarks + +#### 7. Mustache Template Compatibility +- **Risk**: Different repositories may use incompatible Mustache template versions +- **Impact**: Report rendering fails, inconsistent UI across repositories +- **Mitigation Implemented**: ✅ Template compatibility validation added to Phase 3 +- **Status**: Mitigated in plan + +#### 8. Branch Fallback Logic Complexity +- **Risk**: Complex branch fallback logic may have edge cases and failures +- **Impact**: Users get incorrect reports or error messages +- **Mitigation Implemented**: ⚠️ Testing mentioned but no detailed edge case analysis +- **Status**: Partially mitigated - needs comprehensive edge case testing + +### Security Risks + +#### 9. OAuth Token Security +- **Risk**: Client-side OAuth tokens stored in sessionStorage could be vulnerable to XSS +- **Impact**: Token theft, unauthorized repository submissions +- **Mitigation Implemented**: ✅ Content Security Policy, short-lived tokens, regular security audits added +- **Status**: Mitigated in plan + +#### 10. Repository Submission Abuse +- **Risk**: Malicious users could spam submissions or submit inappropriate repositories +- **Impact**: Repository listing becomes polluted, system resources wasted +- **Mitigation Implemented**: ✅ Rate limiting (5/hour per user), content moderation, audit logging added +- **Status**: Mitigated in plan + +#### 11. GitHub Actions Workflow Security +- **Risk**: GitHub Actions workflow has write access and could be exploited +- **Impact**: Unauthorized changes to `../repositories.txt`, system compromise +- **Mitigation Implemented**: ✅ Input validation, audit logging, security reviews added +- **Status**: Mitigated in plan + +### Data & Reliability Risks + +#### 12. Single Point of Failure - repositories.txt +- **Risk**: `../repositories.txt` is a single file that could become corrupted +- **Impact**: All repository listings become unavailable +- **Mitigation Implemented**: ✅ Backup strategy, automated backups, recovery procedures added +- **Status**: Mitigated in plan + +#### 13. Dependency Management for CDNs +- **Risk**: CDN dependencies (Mustache.js, Chart.js, etc.) could break or become unavailable +- **Impact**: Application completely fails to render reports +- **Mitigation Implemented**: ✅ CDN fallback strategy, health monitoring, version pinning added +- **Status**: Mitigated in plan + +#### 14. GitHub Raw Content Availability +- **Risk**: GitHub raw content URLs could change or be deprecated +- **Impact**: All report fetching fails +- **Mitigation Implemented**: ⚠️ Not specifically addressed +- **Status**: Needs GitHub API change monitoring strategy + +### Testing & Quality Risks + +#### 15. E2E Test Flakiness +- **Risk**: Playwright tests may be flaky due to timing issues, network dependencies +- **Impact**: Unreliable CI/CD, false negatives in testing +- **Mitigation Implemented**: ⚠️ Test reliability targets mentioned but no specific flaky test prevention +- **Status**: Partially mitigated - needs retry logic implementation + +#### 16. Bun Compatibility Issues +- **Risk**: Bun is newer and may have compatibility issues with some dependencies +- **Impact**: Testing infrastructure fails, development blocked +- **Mitigation Implemented**: ⚠️ Node.js fallback mentioned but no compatibility testing plan +- **Status**: Partially mitigated - needs compatibility testing strategy + +#### 17. Missing Test Coverage Areas +- **Risk**: Critical error paths and edge cases may not have test coverage +- **Impact**: Bugs in production, poor user experience +- **Mitigation Implemented**: ✅ Error path test coverage analysis added to Phase 5 +- **Status**: Mitigated in plan + +### Deployment & Operations Risks + +#### 18. CI/CD Pipeline Complexity +- **Risk**: Hybrid Bun + Node.js CI/CD setup may have integration issues +- **Impact**: Deployment failures, blocked releases +- **Mitigation Implemented**: ⚠️ CI/CD testing mentioned but no detailed strategy +- **Status**: Partially mitigated - needs thorough pipeline testing + +#### 19. GitHub OAuth App Maintenance +- **Risk**: OAuth app requires ongoing maintenance, secret rotation, monitoring +- **Impact**: Authentication failures, security vulnerabilities +- **Mitigation Implemented**: ✅ Quarterly secret rotation, regular monitoring added to Maintenance +- **Status**: Mitigated in plan + +#### 20. 10-Minute Deployment Delay +- **Risk**: 10-minute deployment delay may frustrate users expecting immediate results +- **Impact**: Poor user experience, support requests +- **Mitigation Implemented**: ⚠️ User communication mentioned but no specific progress indicators +- **Status**: Partially mitigated - needs progress feedback system + +### Remaining Risks Requiring Attention + +#### High Priority Remaining Risks +1. **GitHub Pages Build Time Monitoring** - No specific monitoring strategy +2. **CORS Testing Strategy** - No comprehensive CORS fallback mechanisms +3. **Search Performance Benchmarks** - No specific optimization targets +4. **Branch Fallback Edge Cases** - Needs comprehensive edge case analysis +5. **GitHub API Change Monitoring** - No alternative data source strategy +6. **Flaky Test Prevention** - No retry logic implementation +7. **Bun Compatibility Testing** - No fallback strategy or version pinning +8. **CI/CD Pipeline Testing** - No detailed testing strategy +9. **Deployment Progress Feedback** - No user communication system + +#### Medium Priority Remaining Risks +10. **Repository Validation Performance Targets** - No specific benchmarks +11. **PWA Evaluation** - Considered but not implemented in initial phases +12. **Advanced Analytics** - Basic analytics planned but no detailed strategy + +### Summary of Risk Mitigation +- **Fully Mitigated**: 8 risks (40%) +- **Partially Mitigated**: 9 risks (45%) +- **Unaddressed**: 3 risks (15%) + +The plan now addresses most critical security and operational risks, with remaining items primarily around performance optimization and advanced features that can be addressed post-launch. + +## Recommended Remaining Actions + +### High Priority (Address During Implementation) +1. **Add CORS Testing Strategy**: Test all API endpoints for CORS, implement fallback mechanisms +2. **Define Search Performance Benchmarks**: Set specific targets and optimization strategies +3. **Comprehensive Branch Fallback Testing**: Detailed edge case analysis and test coverage +4. **Implement Flaky Test Prevention**: Add retry logic and test isolation for E2E tests +5. **Bun Compatibility Testing Plan**: Test compatibility early, establish fallback strategy +6. **CI/CD Pipeline Testing Strategy**: Thorough testing of hybrid Bun + Node.js setup +7. **Deployment Progress Feedback System**: Clear user communication and progress indicators + +### Medium Priority (Address Post-Launch) +8. **GitHub Pages Build Time Monitoring**: Implement build time tracking and alerts +9. **GitHub API Change Monitoring**: Set up monitoring for API deprecations and changes +10. **Repository Validation Performance Targets**: Define and monitor performance benchmarks +11. **Advanced Analytics Strategy**: Detailed user analytics and feature adoption tracking + +### Low Priority (Future Enhancements) +12. **PWA Implementation**: Evaluate and implement progressive web app capabilities +13. **Advanced Monitoring**: Enhanced observability and alerting systems + +## TDD Approach + +### Testing Philosophy +- **Test-Driven Development (TDD)**: Write tests before implementation code +- **Red-Green-Refactor cycle**: Write failing test, make it pass, refactor +- **CRITICAL REQUIREMENT**: Failing unit tests MUST be written before any production code is written +- **Continuous Refactoring**: Code should be refactored whenever possible to improve quality, maintainability, and performance +- **Client-side only**: No server-side testing frameworks or Node.js backend testing +- **Browser-native testing**: Test in realistic browser environments when possible +- **Fast feedback loop**: Quick test execution for rapid development +- **Performance-first**: Use Bun for fastest unit test execution (5-20x faster than alternatives) +- **Stability-first**: Use Playwright with Node.js for reliable E2E browser testing + +### Testing Framework Selection +- **Unit Testing**: Bun's built-in test runner (`bun test`) - fastest option, Jest-compatible APIs +- **DOM Testing**: Testing Library (Vanilla JS Testing Library) + jsdom (via Bun) +- **E2E Testing**: Playwright (browser automation, no server required) - still run via Node.js for stability +- **Mocking**: Bun's built-in mocking or sinon.js for complex scenarios +- **Package Management**: Bun for dependencies (8-13x faster than npm) + +**Rationale for Bun + Playwright Hybrid:** +- Bun provides exceptional speed for unit tests (5-20x faster than Vitest) +- Bun's built-in test runner has Jest-compatible APIs, easy migration +- Playwright's browser testing is more stable than Bun's experimental browser support +- This gives the best of both worlds: fast unit tests + reliable E2E testing + +### Testing Strategy by Component + +#### JavaScript Modules (Unit Tests) +- **router.js**: Test URL parsing, route matching, branch fallback logic +- **fetcher.js**: Test GitHub API calls, error handling, retry logic (mocked) +- **renderer.js**: Test Mustache rendering, template processing +- **search.js**: Test search filtering, debouncing, keyboard navigation +- **repo-submission.js**: Test form validation, API calls, error handling +- **oauth-handler.js**: Test OAuth flow, token management, PKCE (mocked) +- **utils.js**: Test utility functions, data transformations + +#### DOM/UI Testing +- **Form interactions**: Test form submission, validation, user feedback +- **Navigation**: Test routing, URL updates, browser history +- **Dynamic content**: Test search results, repository listings, report rendering +- **OAuth flow**: Test login button, OAuth redirect handling, user info display + +#### Integration Testing +- **Search functionality**: End-to-end search flow from input to navigation +- **Repository submission**: Complete flow from form to GitHub Actions trigger +- **Report rendering**: Full flow from URL to report display +- **OAuth authentication**: Complete OAuth flow integration + +#### E2E Testing (Playwright) +- **User journeys**: Critical user paths through the application +- **Cross-browser testing**: Chrome, Firefox, Safari compatibility +- **Mobile responsiveness**: Test on different viewport sizes +- **Error scenarios**: Network failures, missing repositories, OAuth errors + +### Test Structure +``` +tests/ +├── unit/ +│ ├── router.test.js +│ ├── fetcher.test.js +│ ├── renderer.test.js +│ ├── search.test.js +│ ├── repo-submission.test.js +│ ├── oauth-handler.test.js +│ └── utils.test.js +├── integration/ +│ ├── search-flow.test.js +│ ├── submission-flow.test.js +│ ├── report-rendering.test.js +│ └── oauth-flow.test.js +├── e2e/ +│ ├── user-journeys.spec.js +│ ├── cross-browser.spec.js +│ └── mobile-responsiveness.spec.js +└── fixtures/ + ├── sample-repositories.txt + ├── sample-refactor-first.json + └── sample-mustache-template.mustache +``` + +### TDD Workflow for Each Component + +#### 1. Write Failing Test (Red) - CRITICAL STEP +**MANDATORY**: Write a failing unit test BEFORE writing any production code +- The test MUST fail initially (proving it tests actual behavior) +- Do not write production code until the test is written and failing +- This ensures the test drives the implementation and validates behavior +```javascript +// Example: router.test.js +import { describe, it, expect } from 'bun:test'; +import { parseRoute, getDefaultBranch } from '../js/router.js'; + +describe('URL Routing', () => { + it('should parse username and repository from URL', () => { + const result = parseRoute('/refactorfirst/refactorfirst/main'); + expect(result.username).toBe('refactorfirst'); + expect(result.repository).toBe('refactorfirst'); + expect(result.branch).toBe('main'); + }); + + it('should default to main branch when not specified', () => { + const result = parseRoute('/refactorfirst/refactorfirst'); + expect(result.branch).toBe('main'); + }); +}); +``` + +#### 2. Run Test (Red) +```bash +bun test +# Test fails because router.js doesn't exist yet +``` + +#### 3. Write Implementation (Green) +```javascript +// router.js +export function parseRoute(path) { + const parts = path.split('/').filter(Boolean); + return { + username: parts[0] || null, + repository: parts[1] || null, + branch: parts[2] || 'main' + }; +} +``` + +#### 4. Run Test (Green) +```bash +bun test +# Test passes +``` + +#### 5. Refactor - CONTINUOUS IMPROVEMENT +**MANDATORY**: Refactor code whenever possible to improve quality +- Improve code quality while keeping tests green +- Extract common patterns, improve naming, optimize performance +- Eliminate code duplication and improve maintainability +- Apply design patterns and best practices +- Refactor should happen frequently, not just at the end of features +- Always ensure tests remain green during refactoring + +### Testing Configuration + +#### Bun Test Configuration +Bun's test runner requires minimal configuration. For jsdom support: + +```javascript +// bun.config.js (optional, for advanced configuration) +export default { + test: { + environment: 'jsdom', // for DOM testing + coverage: { + // Built-in coverage support + reporter: ['text', 'html'], + include: ['js/**/*.js'], + exclude: ['tests/'] + } + } +}; +``` + +#### Playwright Configuration (for E2E tests) +Note: Playwright runs via Node.js for stable browser automation + +```javascript +// playwright.config.js +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:8000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + { + name: 'firefox', + use: { browserName: 'firefox' }, + }, + { + name: 'webkit', + use: { browserName: 'webkit' }, + }, + ], + webServer: { + command: 'python -m http.server 8000', + port: 8000, + timeout: 120 * 1000, + }, +}); +``` + +### Testing Best Practices + +#### Critical TDD Rules +- **FAILING TESTS FIRST**: NEVER write production code before a failing test exists +- **TEST MUST FAIL**: Verify the test fails before writing implementation code +- **MINIMAL IMPLEMENTATION**: Write only enough code to make the test pass +- **NO PRODUCTION CODE WITHOUT TESTS**: All production code must be covered by tests +- **TEST DRIVES DESIGN**: Let tests guide the API design and code structure + +#### Continuous Refactoring Practices +- **Refactor frequently**: Don't wait for "refactoring sprints" - refactor continuously +- **Refactor when green**: Only refactor when all tests are passing +- **Small, safe changes**: Make incremental refactoring changes +- **Test-driven refactoring**: Use tests to ensure refactoring doesn't break behavior +- **Eliminate duplication**: Follow DRY principle rigorously +- **Improve naming**: Use clear, descriptive names for variables, functions, and classes +- **Extract methods**: Break down large functions into smaller, focused ones +- **Apply patterns**: Use appropriate design patterns when they improve code +- **Optimize performance**: Refactor for better performance when tests reveal bottlenecks +- **Maintain readability**: Prioritize code clarity over cleverness + +#### Unit Tests +- **Isolation**: Each test should be independent +- **Fast execution**: Unit tests should run in milliseconds +- **Mock external dependencies**: GitHub API, OAuth endpoints +- **Test edge cases**: Error conditions, boundary values +- **Arrange-Act-Assert**: Clear test structure + +#### Integration Tests +- **Realistic scenarios**: Test actual component interactions +- **Minimal mocking**: Only mock external services +- **State management**: Test state changes and side effects +- **User workflows**: Test complete user processes + +#### E2E Tests +- **Critical paths**: Focus on important user journeys +- **Real browsers**: Test in actual browser environments +- **Network conditions**: Test slow networks, failures +- **Mobile devices**: Test responsive design + +### Mocking Strategy + +#### GitHub API Mocking +```javascript +// fetcher.test.js +import { describe, it, expect, spyOn } from 'bun:test'; +import { fetchRepositoryData } from '../js/fetcher.js'; + +describe('GitHub API Fetching', () => { + it('should fetch repository data successfully', async () => { + const mockData = { /* sample refactor-first.json */ }; + const mockFetch = spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockData) + }); + + const result = await fetchRepositoryData('user', 'repo', 'main'); + expect(result).toEqual(mockData); + mockFetch.mockRestore(); + }); + + it('should handle 404 errors', async () => { + const mockFetch = spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 404 + }); + + await expect( + fetchRepositoryData('user', 'repo', 'main') + ).rejects.toThrow('Repository not found'); + mockFetch.mockRestore(); + }); +}); +``` + +#### OAuth Mocking +```javascript +// oauth-handler.js test example +describe('OAuth Handler', () => { + it('should generate PKCE code verifier and challenge', () => { + const { codeVerifier, codeChallenge } = generatePKCE(); + expect(codeVerifier).toMatch(/^[A-Za-z0-9\-._~]{43,128}$/); + expect(codeChallenge).toMatch(/^[A-Za-z0-9\-._~]{43,128}$/); + }); +}); +``` + +### Continuous Testing + +#### Pre-commit Hooks +```json +{ + "scripts": { + "test": "bun test", + "test:watch": "bun test --watch", + "test:coverage": "bun test --coverage", + "test:e2e": "npx playwright test", + "lint": "eslint js/**/*.js", + "pre-commit": "bun test && bun run lint" + } +} +``` + +#### CI/CD Integration +```yaml +# .github/workflows/test.yml +name: Test Suite +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - run: bun install + - run: bun test + - uses: actions/setup-node@v4 + with: + node-version: '18' + - run: npx playwright install + - run: npx playwright test +``` + +### Coverage Goals +- **Unit tests**: 80%+ coverage for core logic +- **Integration tests**: Critical user flows covered +- **E2E tests**: Main user journeys covered +- **Security tests**: OAuth and submission flows tested + +### Testing During Implementation Phases + +#### Phase 1: Core Infrastructure +- Test HTML structure and navigation +- Test URL routing logic +- Test basic page loading + +#### Phase 2: Landing & Search +- Test search functionality (unit + integration) +- Test repository submission flow (end-to-end) +- Test OAuth authentication (mocked integration) + +#### Phase 3: Report Rendering +- Test GitHub API fetching (mocked) +- Test Mustache rendering +- Test branch fallback logic +- Test error handling and loading states + +#### Phase 4: Additional Pages +- Test page navigation and routing +- Test form submissions and validation +- Test content rendering for all pages + +#### Phase 5: Polish & Optimization +- Test responsive design (E2E) +- Test performance (load times) +- Test accessibility (ARIA, keyboard nav) +- Test cross-browser compatibility + +## Development Workflow Requirements + +### Mandatory TDD Process +1. **Write Failing Test First**: Before writing any production code, write a test that fails +2. **Verify Test Fails**: Run the test to confirm it fails (proves it tests actual behavior) +3. **Write Minimal Implementation**: Write only enough production code to make the test pass +4. **Run Test (Green)**: Verify the test now passes +5. **Refactor**: Improve the code while keeping tests green +6. **Repeat**: Continue this cycle for all features + +### Code Quality Standards +- **Continuous Refactoring**: Refactor code whenever possible to improve quality +- **No Code Duplication**: Eliminate duplicate code through extraction and abstraction +- **Clear Naming**: Use descriptive names for variables, functions, and modules +- **Small Functions**: Keep functions focused and small (single responsibility) +- **Test Coverage**: Maintain high test coverage for all production code +- **Clean Code**: Prioritize readability and maintainability + +### Anti-Patterns to Avoid +- ❌ Writing production code before tests +- ❌ Skipping tests for "simple" features +- ❌ Writing tests that always pass (fake tests) +- ❌ Refactoring without test coverage +- ❌ Accumulating technical debt without refactoring +- ❌ Writing large, monolithic functions + +## Overview +Create a static client-side rendered web application deployable to GitHub Pages (and compatible with GitLab/BitBucket) that renders RefactorFirst reports by fetching JSON data from repository `.refactorfirst` directories. + +## Architecture Requirements +- **Purely client-side**: No Node.js, Bun, NPM, or server-side rendering for the application +- **JavaScript modules permitted**: ES6 modules for code organization +- **Local testing**: Easy to test without deployment +- **Multi-platform**: Works on GitHub Pages, GitLab Pages, BitBucket Pages +- **Testing infrastructure**: Bun for unit testing (fastest), Node.js only for Playwright E2E testing during development +- **TDD mandate**: All production code must be written after failing unit tests +- **Continuous refactoring**: Code must be refactored continuously to maintain quality +- **Code quality**: Prioritize maintainability, readability, and testability + +## Core Features + +### 1. URL Routing System +- **Pattern**: `https://refactorfirst.github.io///` +- **Examples**: + - `https://refactorfirst.github.io/refactorfirst/refactorfirst/main` → Full report + - `https://refactorfirst.github.io/refactorfirst/refactorfirst` → Defaults to `main` branch + - `https://refactorfirst.github.io/refactorfirst` → User's / Org's repository list + - `https://refactorfirst.github.io/` → Landing page with search + +- **Branch Fallback Logic**: + 1. Try specified branch from URL + 2. If not specified, try `main` branch + 3. If `main` fails (404 or no content), try `master` branch + 4. If all fail, show error with link to repository + +- **Data Fetching**: + - Construct GitHub raw URL: `https://raw.githubusercontent.com////.refactorfirst/refactor-first.json` + - Fetch JSON data via client-side JavaScript + - Handle CORS issues (GitHub raw content supports CORS) + - Fetch Mustache template from same repository: `https://raw.githubusercontent.com////.refactorfirst/refactor-first-report.mustache` + +### 2. Landing Page +- **Hero Section**: Clean, modern landing with RefactorFirst branding +- **Type-Ahead Search Bar**: + - Backed by static text file (`../repositories.txt`) + - Format: One entry per line: `username/repository` + - Real-time filtering as user types + - Keyboard navigation support + - Click/enter to navigate to repository report + +- **Featured Repositories**: Show a few example repositories with RefactorFirst reports +- **"Add My Repo" Button**: Prominent call-to-action button that links to repository submission form + +### 2.5. Repository Submission Page +- **URL Pattern**: `https://refactorfirst.github.io/add-repo` +- **Authentication**: GitHub OAuth login required before submission +- **Form Fields**: + - User/Organization Name (text input) + - Repository Name (text input) + - Submit button +- **Information Text**: "Only repositories with a `.refactorfirst/refactor-first.json` file will be added. The RefactorFirst GitHub Page redeploys every 10 minutes." +- **User Information Display**: Shows logged-in GitHub user (username, avatar) +- **Validation**: + - Required fields validation + - Basic format validation (no special characters) + - Real-time feedback during submission + - Verify user has access to the repository they're submitting +- **Submission Process**: + 1. User must authenticate via GitHub OAuth + 2. User submits form with user/org and repository name + 3. System verifies user has access to the repository + 4. Client-side JavaScript triggers GitHub Actions workflow via GitHub API + 5. Show loading state during validation + 6. Display success/error message based on workflow result +- **Error Handling**: + - Authentication required + - Repository not found + - User lacks access to repository + - Missing `.refactorfirst/refactor-first.json` file + - Repository already in listing + - Network/API errors + - Rate limiting from GitHub API + - Abuse detection (multiple submissions from same user) + +### 3. User Repository Listing Page +- **URL Pattern**: `https://refactorfirst.github.io/` +- **Display**: All repositories for the specified user from `../repositories.txt` +- **Layout**: Two-column grid, centered on page +- **Sorting**: Alphabetical order by repository name +- **Pagination**: + - 50 repositories per page + - Pagination controls at bottom + - URL updates with page parameter: `?page=2` + +### 4. Top Navigation Menu +- **Height Constraint**: Maximum 140 pixels tall +- **Responsive Design**: Mobile-friendly hamburger menu for smaller screens + +**Menu Items**: +1. **Home** - Returns to landing page +2. **Search** - Type-ahead search bar (always visible in menu) +3. **Add Your Repo** - Link to repository submission form +4. **Getting Started** - Guide for adding repositories +5. **Documentation** - Link to RefactorFirst documentation +6. **FAQ** - Common questions about RefactorFirst reports +7. **Examples** - Gallery of example reports from well-known projects +8. **API** - Simple documentation on how to integrate with other tools +9. **About** - Information about RefactorFirst +10. **Feedback** - Link to GitHub Issues or feedback form +11. **GitHub** - Link to RefactorFirst GitHub repository + +### 5. Report Rendering +- **Template Engine**: Use Mustache.js (already in existing viewer) +- **Rendering Process**: + 1. Fetch JSON data from repository + 2. Fetch Mustache template from repository (or use bundled fallback) + 3. Render HTML using Mustache.js + 4. Inject into page below top menu + 5. Initialize charts and graphs (Chart.js, Sigma.js, etc.) + +- **Fallback Template**: Bundle a default Mustache template in case repository doesn't have one +- **Error Handling**: + - Show friendly error if JSON not found + - Show error if JSON is malformed + - Show loading state during fetch + - Retry mechanism for network failures + +### 6. Static Data File +- **File**: `../repositories.txt` +- **Location**: Root of web application +- **Format**: Plain text, one `username/repository` per line +- **Example**: + ``` + refactorfirst/refactorfirst + spring-projects/spring-framework + apache/tomcat + ``` +- **Maintenance**: Can be updated via PR to the web application repo + +### 7. Additional Pages Content + +#### Getting Started Page +- **Purpose**: Guide for new users to add their repositories +- **Content**: + - How to set up RefactorFirst in your repository + - Maven plugin configuration + - GitHub Actions workflow setup + - Adding repository to the RefactorFirst GitHub Pages + - GitHub OAuth authentication process + - Why GitHub authentication is required + - What permissions are needed and why + - Troubleshooting common issues + - Copy-paste workflow YAML template + - OAuth authorization and permissions explanation + +#### Documentation Page +- **Purpose**: Comprehensive guide to using RefactorFirst +- **Content**: + - How to generate reports (Maven plugin, CLI) + - Understanding report sections + - Interpreting metrics and recommendations + - Integration with CI/CD pipelines + - Link to external RefactorFirst documentation + +#### FAQ Page +- **Purpose**: Address common questions about RefactorFirst reports +- **Content**: + - What do the different priority colors mean? + - How often should I run RefactorFirst? + - What if my repository doesn't have a report? + - How do I add my repository to the listing? + - How long does it take for my repository to appear after submission? + - Why do I need to authenticate with GitHub? + - What permissions does the OAuth app require? + - Is my GitHub data safe? + - Understanding disharmonies (God Class, Brain Class, etc.) + - Branch comparison limitations + +#### Examples Page +- **Purpose**: Showcase RefactorFirst reports from well-known projects +- **Content**: + - Gallery of example reports with screenshots + - Case studies from popular open-source projects + - Before/after refactoring examples + - Different types of codebases analyzed (small, medium, large) + - Links to live reports for featured repositories + +#### API Page +- **Purpose**: Documentation for integrating RefactorFirst with other tools +- **Content**: + - How to fetch reports programmatically + - JSON schema documentation + - URL patterns for direct report access + - Webhook integration examples + - Third-party tool integration examples + - Rate limiting and caching recommendations + +## Technical Implementation + +### File Structure +``` +refactorfirst-page/ +├── index.html # Main entry point +├── repositories.txt # Static repository listing +├── package.json # Dependencies for testing +├── bun.config.js # Bun configuration (optional) +├── playwright.config.js # Playwright E2E test configuration +├── .github/ +│ └── workflows/ +│ ├── add-repository.yml # Repository validation workflow +│ └── redeploy.yml # Scheduled redeployment workflow +├── css/ +│ ├── main.css # Main stylesheet +│ └── components.css # Component-specific styles +├── js/ +│ ├── main.js # Main application entry point +│ ├── router.js # URL routing logic +│ ├── fetcher.js # GitHub API/raw content fetching +│ ├── renderer.js # Mustache rendering logic +│ ├── search.js # Type-ahead search functionality +│ ├── repo-submission.js # Repository submission form handler +│ ├── oauth-handler.js # GitHub OAuth authentication handler +│ ├── error-handler.js # Error handling and display logic +│ ├── rate-limiter.js # GitHub API rate limiting handler +│ ├── cache-manager.js # Client-side caching management +│ └── utils.js # Utility functions +├── templates/ +│ ├── landing.html # Landing page template +│ ├── add-repo.html # Repository submission form template +│ ├── getting-started.html # Getting started guide template +│ ├── user-repos.html # User repository listing template +│ ├── report.html # Report display template +│ ├── about.html # About page template +│ ├── feedback.html # Feedback page template +│ ├── documentation.html # Documentation page template +│ ├── faq.html # FAQ page template +│ ├── examples.html # Examples gallery template +│ ├── api.html # API documentation template +│ ├── error-404.html # 404 error page template +│ ├── error-rate-limit.html # Rate limiting error page template +│ ├── error-oauth.html # OAuth error page template +│ ├── error-api.html # API error page template +│ ├── error-template.html # Template error page template +│ ├── error-general.html # General error page template +│ ├── privacy-policy.html # Privacy policy page template +│ └── terms-of-service.html # Terms of service page template +├── tests/ +│ ├── unit/ +│ │ ├── router.test.js +│ │ ├── fetcher.test.js +│ │ ├── renderer.test.js +│ │ ├── search.test.js +│ │ ├── repo-submission.test.js +│ │ ├── oauth-handler.test.js +│ │ ├── error-handler.test.js +│ │ ├── rate-limiter.test.js +│ │ ├── cache-manager.test.js +│ │ └── utils.test.js +│ ├── integration/ +│ │ ├── search-flow.test.js +│ │ ├── submission-flow.test.js +│ │ ├── report-rendering.test.js +│ │ └── oauth-flow.test.js +│ ├── e2e/ +│ │ ├── user-journeys.spec.js +│ │ ├── cross-browser.spec.js +│ │ └── mobile-responsiveness.spec.js +│ └── fixtures/ +│ ├── sample-repositories.txt +│ ├── sample-refactor-first.json +│ └── sample-mustache-template.mustache +└── assets/ + ├── refactor-first-report.mustache # Fallback Mustache template + ├── logo.png # RefactorFirst logo + └── sentry-config.js # Sentry error tracking configuration +``` + +### package.json (Testing Dependencies) +```json +{ + "name": "refactorfirst-page", + "version": "1.0.0", + "description": "RefactorFirst GitHub Pages Application", + "type": "module", + "scripts": { + "test": "bun test", + "test:watch": "bun test --watch", + "test:coverage": "bun test --coverage", + "test:e2e": "npx playwright test", + "test:e2e:ui": "npx playwright test --ui", + "lint": "eslint js/**/*.js tests/**/*.js", + "lint:fix": "eslint js/**/*.js tests/**/*.js --fix" + }, + "devDependencies": { + "@playwright/test": "^1.40.0", + "@sentry/browser": "^7.80.0", + "eslint": "^8.55.0", + "jsdom": "^23.0.0" + }, + "dependencies": { + "mustache": "^4.2.0" + }, + "engines": { + "bun": ">=1.0.0" + } +} +``` + +**Note**: No unit test framework dependencies needed - Bun's built-in test runner is used. Playwright still requires Node.js for stable browser automation. Sentry added for error tracking. + +### JavaScript Module Architecture + +#### router.js +```javascript +// Parse URL parameters and route to appropriate page +// Handle branch fallback logic (main -> master) +// Update browser history for navigation +``` + +#### fetcher.js +```javascript +// Fetch JSON from GitHub raw URLs +// Fetch Mustache templates +// Handle CORS and errors +// Implement retry logic +``` + +#### renderer.js +```javascript +// Initialize Mustache.js +// Render templates with data +// Initialize Chart.js for bubble charts +// Initialize Sigma.js/3D-force-graph for visualizations +``` + +#### search.js +```javascript +// Load and parse repositories.txt +// Implement type-ahead filtering +// Handle keyboard navigation +// Debounce input for performance +``` + +#### repo-submission.js +```javascript +// Handle repository submission form +// Validate user input +// GitHub OAuth authentication flow +// Trigger GitHub Actions workflow via GitHub API +// Handle workflow responses and errors +// Update UI with success/error messages + +// Implementation details: +// - GitHub OAuth 2.0 authorization code flow +// - Handle OAuth callback and token exchange +// - Store OAuth token in sessionStorage for session duration +// - Fetch and display user's GitHub profile information +// - Verify user has access to submitted repository +// - Use GitHub REST API to trigger repository_dispatch event +// - Include submitting username in workflow payload +// - Poll workflow run status for completion +// - Handle rate limiting and API errors gracefully +// - Implement logout functionality +``` + +#### oauth-handler.js +```javascript +// GitHub OAuth authentication flow +// Handle OAuth redirect and callback +// Exchange authorization code for access token +// Store and manage OAuth tokens +// Fetch user profile information +// Handle token refresh and expiration +// Implement logout functionality + +// Implementation details: +// - Generate and store OAuth state parameter for CSRF protection +// - Implement PKCE (Proof Key for Code Exchange) for enhanced security +// - Handle OAuth callback URL parsing and code extraction +// - Exchange authorization code for access token via GitHub API +// - Store access token in sessionStorage +// - Fetch user profile (username, avatar) using access token +// - Implement token refresh if using refresh tokens +// - Handle token expiration and re-authentication +// - Clear tokens on logout +// - Handle OAuth errors and user cancellation +``` + +#### error-handler.js +```javascript +// Centralized error handling logic +// Route errors to appropriate error pages +// Display user-friendly error messages +// Log errors to monitoring system +// Implement retry logic for recoverable errors + +// Implementation details: +// - Error classification (network, API, authentication, etc.) +// - Error page routing based on error type +// - User-friendly error message generation +// - Error logging to Sentry +// - Retry logic with exponential backoff +// - Error recovery suggestions +``` + +#### rate-limiter.js +```javascript +// GitHub API rate limiting management +// Track rate limit headers from API responses +// Implement client-side rate limiting +// Handle rate limit exceeded scenarios +// Cache rate limit status + +// Implementation details: +// - Parse X-RateLimit-Remaining and X-RateLimit-Reset headers +// - Implement client-side rate limiting (max requests per time window) +// - Queue requests when rate limits are approached +// - Graceful degradation when rate limits are hit +// - Rate limit status caching +// - User notification for rate limiting +``` + +#### cache-manager.js +```javascript +// Client-side caching for API responses +// Cache GitHub API responses to reduce calls +// Implement cache invalidation strategies +// Manage cache size and expiration + +// Implementation details: +// - Cache API responses in localStorage/memory +// - Implement cache keys based on request parameters +// - Cache expiration times based on data type +// - Cache invalidation on mutations +// - Cache size management (LRU eviction) +// - Offline cache for critical data +``` + +### GitHub Actions Workflows + +#### Workflow 1: Repository Validation and Addition +**File**: `../.github/workflows/add-repository.yml` +**Purpose**: Validate and add repositories to `../repositories.txt` + +```yaml +name: Add Repository +on: + repository_dispatch: + types: [add-repository] + +permissions: + contents: write + +jobs: + add-repository: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate repository and user access + id: validate + run: | + OWNER="${{ github.event.client_payload.owner }}" + REPO="${{ github.event.client_payload.repo }}" + SUBMITTED_BY="${{ github.event.client_payload.submitted_by }}" + REPO_FULL_NAME="$OWNER/$REPO" + + echo "Validating repository: $REPO_FULL_NAME" + echo "Submitted by: $SUBMITTED_BY" + + # Check if repository exists + if ! gh repo view "$REPO_FULL_NAME" --json name --jq '.name' > /dev/null 2>&1; then + echo "error=Repository not found" >> $GITHUB_OUTPUT + exit 1 + fi + + # Check if user has access to the repository + # First try collaborators endpoint (for organization repos) + if ! gh api "repos/$REPO_FULL_NAME/collaborators/$SUBMITTED_BY" --jq '.permission' > /dev/null 2>&1; then + # If collaborators endpoint fails, check if user is the owner + OWNER_INFO=$(gh repo view "$REPO_FULL_NAME" --json owner --jq '.owner.login') + if [[ "$OWNER_INFO" != "$SUBMITTED_BY" ]]; then + echo "error=User does not have access to this repository" >> $GITHUB_OUTPUT + exit 1 + fi + PERMISSION="admin" + else + # Check user has write or admin permission + PERMISSION=$(gh api "repos/$REPO_FULL_NAME/collaborators/$SUBMITTED_BY" --jq '.permission') + if [[ "$PERMISSION" != "write" && "$PERMISSION" != "admin" ]]; then + echo "error=User does not have write access to this repository" >> $GITHUB_OUTPUT + exit 1 + fi + fi + + # Check for .refactorfirst/refactor-first.json file + if ! gh api "repos/$REPO_FULL_NAME/contents/.refactorfirst/refactor-first.json" --jq '.sha' > /dev/null 2>&1; then + echo "error=RefactorFirst JSON file not found" >> $GITHUB_OUTPUT + exit 1 + fi + + echo "status=valid" >> $GITHUB_OUTPUT + echo "repository=$REPO_FULL_NAME" >> $GITHUB_OUTPUT + echo "submitted_by=$SUBMITTED_BY" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Add to repositories.txt + if: steps.validate.outputs.status == 'valid' + run: | + REPO="${{ steps.validate.outputs.repository }}" + SUBMITTED_BY="${{ steps.validate.outputs.submitted_by }}" + + # Check if repository already exists + if grep -q "^$REPO$" repositories.txt; then + echo "Repository already in listing" + exit 0 + fi + + # Add repository and sort alphabetically + echo "$REPO" >> repositories.txt + sort -o repositories.txt repositories.txt + + # Remove duplicates + awk '!seen[$0]++' repositories.txt > temp.txt && mv temp.txt repositories.txt + + echo "Repository $REPO added by $SUBMITTED_BY" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Commit changes + if: steps.validate.outputs.status == 'valid' + run: | + REPO="${{ steps.validate.outputs.repository }}" + SUBMITTED_BY="${{ steps.validate.outputs.submitted_by }}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add repositories.txt + git diff --staged --quiet || git commit -m "Add repository: $REPO (submitted by $SUBMITTED_BY)" + git push + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Report failure + if: failure() + run: | + echo "Repository validation failed: ${{ steps.validate.outputs.error }}" +``` + +#### Workflow 2: Scheduled Redeployment +**File**: `../.github/workflows/redeploy.yml` +**Purpose**: Redeploy GitHub Pages every 10 minutes if repositories.txt has changed + +```yaml +name: Scheduled Redeploy +on: + schedule: + - cron: '*/10 * * * *' # Every 10 minutes + workflow_dispatch: # Allow manual triggering + +permissions: + contents: read + pages: write + id-token: write + +jobs: + check-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check for repositories.txt changes + id: check-changes + run: | + # Get the last commit date for repositories.txt + LAST_COMMIT=$(git log -1 --format=%ct -- repositories.txt) + CURRENT_TIME=$(date +%s) + TIME_DIFF=$((CURRENT_TIME - LAST_COMMIT)) + + echo "Last commit: $LAST_COMMIT" + echo "Current time: $CURRENT_TIME" + echo "Time difference: $TIME_DIFF seconds" + + # Only deploy if changed in the last 15 minutes + if [ $TIME_DIFF -lt 900 ]; then + echo "changed=true" >> $GITHUB_OUTPUT + echo "repositories.txt has recent changes, deploying" + else + echo "changed=false" >> $GITHUB_OUTPUT + echo "No recent changes, skipping deployment" + fi + + - name: Setup Pages + if: steps.check-changes.outputs.changed == 'true' + uses: actions/configure-pages@v4 + + - name: Upload artifact + if: steps.check-changes.outputs.changed == 'true' + uses: actions/upload-pages-artifact@v3 + with: + path: '.' + + - name: Deploy to GitHub Pages + if: steps.check-changes.outputs.changed == 'true' + id: deployment + uses: actions/deploy-pages@v4 +``` + +#### Workflow 3: User Repository RefactorFirst Report Generation +**File**: (Provided to users to add to their repositories) +**Purpose**: Run RefactorFirst Maven goal on merge to default branch + +```yaml +name: Generate RefactorFirst Report +on: + push: + branches: + - main + - master + workflow_dispatch: # Allow manual triggering + +jobs: + generate-report: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Generate RefactorFirst report + run: mvn refactorfirst:jsonReport + + - name: Commit report + run: | + if [ -f .refactorfirst/refactor-first.json ]; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .refactorfirst/refactor-first.json + git diff --staged --quiet || git commit -m "Update RefactorFirst report" + git push + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +### CSS Architecture +- Use CSS custom properties (variables) for theming +- Mobile-first responsive design +- Flexbox and Grid for layouts +- MVP.css as base (already used in existing viewer) +- Custom styles for: + - Top navigation menu (max 140px) + - Search bar styling + - Repository grid layout + - Pagination controls + - Loading states + - Error messages + +### Local Testing +- **Method 1**: Simple HTTP server + ```bash + # Python 3 + python -m http.server 8000 + + # Python 2 + python -m SimpleHTTPServer 8000 + + # Node.js (if available) + npx http-server -p 8000 + ``` + +- **Method 2**: VS Code Live Server extension +- **Method 3**: Direct file opening (with CORS limitations for GitHub fetching) + +- **Testing Workflow**: + ```bash + # Install test dependencies + bun install + + # Run unit tests + bun test + + # Run tests in watch mode during development + bun test --watch + + # Run tests with coverage + bun test --coverage + + # Run E2E tests (requires local server running) + npx playwright test + + # Run E2E tests with UI + npx playwright test --ui + + # Lint code + bun run lint + + # Fix linting issues + bun run lint:fix + ``` + +### Deployment Strategy + +#### GitHub Pages +- Repository: `refactorfirst/refactorfirst.github.io` +- Branch: `gh-pages` or main/docs folder +- Custom domain: `refactorfirst.github.io` (or subdomain) +- Workflow: Push to trigger automatic deployment +- **GitHub OAuth App Configuration**: + - Register OAuth App in GitHub organization settings + - Application name: "RefactorFirst GitHub Pages" + - Homepage URL: `https://refactorfirst.github.io` + - Authorization callback URL: `https://refactorfirst.github.io/add-repo/callback` + - Store Client ID and Client Secret as repository secrets + - Required scopes: `public_repo`, `read:user` + +#### GitLab Pages +- Similar structure, deploy from `../.gitlab-ci.yml` +- URL: `https://refactorfirst.gitlab.io` + +#### BitBucket Pages +- Deploy from `../bitbucket-pipelines.yml` +- URL: `https://refactorfirst.bitbucket.io` + +## Repository Submission Workflow + +### User Experience Flow +1. **Discovery**: User sees "Add My Repo" button on landing page +2. **Navigation**: User navigates to submission form +3. **Authentication**: User must authenticate via GitHub OAuth + - "Login with GitHub" button redirects to GitHub OAuth + - User authorizes the application + - User is redirected back with OAuth code + - System exchanges code for access token + - User's GitHub information (username, avatar) is displayed +4. **Submission**: User enters: + - GitHub username or organization name + - Repository name +5. **Authorization Check**: System verifies user has access to the repository + - Uses GitHub API to check repository permissions + - Only allows submission if user has write/admin access +6. **Validation**: System validates repository: + - Checks if repository exists + - Verifies `.refactorfirst/refactor-first.json` file exists + - Checks if repository is already in listing +7. **Processing**: GitHub Actions workflow triggered via repository_dispatch +8. **Feedback**: User sees real-time status updates: + - "Validating repository..." + - "Repository validated successfully" + - "Adding to listing..." + - "Repository added successfully" + - Or appropriate error messages +9. **Deployment**: Scheduled workflow deploys changes within 10 minutes +10. **Verification**: User can search for their repository after deployment + +### GitHub OAuth Integration +- **OAuth Flow**: GitHub OAuth 2.0 authorization code flow +- **Scopes Required**: `public_repo` (for repository access) and `read:user` (for user info) +- **GitHub OAuth App**: Must be registered as a GitHub OAuth App + - Client ID and Client Secret stored as GitHub Secrets + - Callback URL: `https://refactorfirst.github.io/add-repo/callback` +- **Token Storage**: Access token stored in sessionStorage for session duration +- **Token Refresh**: Implement token refresh if using refresh tokens +- **User Info**: Fetch and display user's GitHub profile (username, avatar) + +### GitHub API Integration Details +- **Endpoint**: `POST /repos/{owner}/{repo}/dispatches` +- **Payload**: `{"event_type": "add-repository", "client_payload": {"owner": "...", "repo": "...", "submitted_by": "username"}}` +- **Authentication**: OAuth access token in Authorization header +- **Repository Access Check**: `GET /repos/{owner}/{repo}/collaborators/{username}` or `GET /user/repos` +- **Rate Limiting**: Respect GitHub API rate limits (5000/hour for authenticated requests) +- **Error Handling**: Graceful handling of 403, 404, 422, and 429 responses +- **Security**: Token never stored permanently, cleared on session end +- **Audit Trail**: Include submitting username in workflow payload for audit purposes + +### User-Provided Workflow Setup +- **Documentation**: Clear instructions for users to add the RefactorFirst workflow to their repos +- **Copy-Paste Ready**: YAML code snippet provided in documentation +- **Prerequisites**: + - Repository must use Maven + - RefactorFirst Maven plugin must be configured + - `.refactorfirst` directory must exist + - GitHub write permissions for the repository +- **Customization**: Users can adjust Java version, branches, etc. +- **No Additional Authentication**: The user-provided workflow doesn't require OAuth - it runs in the user's own repository context + +## Implementation Phases + +### Phase 1: Core Infrastructure +1. Set up testing infrastructure (Bun, Playwright, package.json) +2. Write tests for basic HTML structure +3. Implement basic HTML structure with top menu +4. Write tests for URL routing system +5. Implement URL routing system (TDD approach) +6. Create repository data file (`../repositories.txt`) +7. Set up local testing workflow +8. Register GitHub OAuth App and configure secrets +9. Implement GitHub API rate limiting strategy +10. Set up monitoring (Sentry, performance monitoring) +11. Create backup strategy for critical data +12. Implement Content Security Policy headers + +### Phase 2: Landing & Search +1. Write tests for landing page components +2. Design and implement landing page (TDD approach) +3. Write tests for search functionality +4. Implement type-ahead search bar (TDD approach) +5. Write tests for repository listing +6. Create user repository listing page (TDD approach) +7. Write tests for pagination +8. Add pagination for repository lists (TDD approach) +9. Write tests for OAuth authentication +10. Implement GitHub OAuth authentication flow (TDD approach) +11. Write tests for repository submission +12. Implement "Add My Repo" button and submission form (TDD approach) +13. Set up GitHub Actions workflow for repository validation +14. Configure scheduled redeployment workflow +15. Design comprehensive error pages (404, rate limiting, OAuth, API errors) +16. Implement abuse prevention mechanisms +17. Conduct security audit for OAuth and workflows + +### Phase 3: Report Rendering +1. Write tests for GitHub API fetching +2. Implement GitHub raw content fetching (TDD approach) +3. Write tests for Mustache rendering +4. Integrate Mustache.js rendering (TDD approach) +5. Write tests for branch fallback logic +6. Add branch fallback logic (TDD approach) +7. Write tests for error handling +8. Implement error handling and loading states (TDD approach) +9. Implement template compatibility validation +10. Add CDN fallback strategy + +### Phase 4: Additional Pages +1. Write tests for About page +2. Create About page (TDD approach) +3. Write tests for Feedback page +4. Create Feedback page (TDD approach) +5. Write tests for Getting Started page +6. Create Getting Started page with OAuth instructions (TDD approach) +7. Write tests for Documentation page +8. Create Documentation page (TDD approach) +9. Write tests for FAQ page +10. Create FAQ page with OAuth-related questions (TDD approach) +11. Write tests for Examples gallery +12. Create Examples gallery page (TDD approach) +13. Write tests for API documentation +14. Create API documentation page (TDD approach) +15. Create legal documentation (privacy policy, terms of service) +16. Implement accessibility features (WCAG 2.1 AA) + +### Phase 5: Polish & Optimization +1. Write tests for responsive design +2. Responsive design improvements (TDD approach) +3. Write performance tests +4. Performance optimization (lazy loading, caching) +5. Write accessibility tests +6. Accessibility improvements (ARIA labels, keyboard navigation) +7. Write tests for error pages +8. Error page enhancements (TDD approach) +9. Cross-browser testing with Playwright +10. Mobile responsiveness testing with Playwright +11. Define performance budgets and monitoring +12. Implement international character support +13. Analyze test coverage for error paths +14. Conduct accessibility audit +15. Set up user analytics + +## CDN Dependencies +Based on existing viewer, include: +- Mustache.js (4.2.0) +- Chart.js (4.4.7) +- svg-pan-zoom (3.6.1) +- Sigma.js (2.4.0) +- graphology (0.25.4) +- graphlib-dot (0.6.4) +- 3d-force-graph +- @vizdom/vizdom-ts-web (0.1.19) +- MVP.css (base styling) + +## Security Considerations +- No sensitive data in client-side code +- Validate URL parameters to prevent XSS +- Use HTTPS for all external requests +- Implement Content Security Policy headers +- Sanitize rendered HTML content +- **GitHub API Rate Limiting**: + - Implement client-side caching for API responses + - Respect rate limit headers (X-RateLimit-Remaining, X-RateLimit-Reset) + - Implement exponential backoff for rate-limited requests + - Graceful degradation when rate limits are reached + - Cache rate limit status to avoid unnecessary API calls +- **Repository Submission Security**: + - GitHub OAuth tokens stored only in sessionStorage + - Tokens cleared on session end + - Never log or transmit tokens except to GitHub API + - OAuth token has minimal required scopes (`public_repo`, `read:user`) + - Validate all user inputs before API calls + - Rate limiting to prevent abuse (max 5 submissions per hour per user) + - CSRF protection for form submissions + - Input sanitization to prevent injection attacks + - Verify user has write access to submitted repository + - Audit trail: all submissions associated with GitHub username + - Content moderation for inappropriate repositories +- **GitHub OAuth Security**: + - OAuth Client Secret stored as GitHub Secret + - Use PKCE (Proof Key for Code Exchange) for enhanced security + - Validate OAuth state parameter to prevent CSRF + - Implement token expiration and refresh (short-lived tokens) + - Secure callback URL configuration + - Regular security audits of OAuth implementation + - Token rotation schedule for OAuth Client Secret +- **GitHub Actions Security**: + - Use GitHub Secrets for sensitive data (OAuth Client Secret) + - Minimal permissions required (contents: write) + - Validate all client payload data + - Rate limit repository additions per user + - Audit logging for all repository additions with submitter info + - Input sanitization in workflow + - Regular security reviews of workflow permissions + +## Error Handling & Monitoring + +### Comprehensive Error Pages +- **404 Error Page**: Repository not found, page not found +- **Rate Limiting Error Page**: GitHub API rate limit exceeded +- **OAuth Error Page**: Authentication failures, authorization errors +- **GitHub API Error Page**: API failures, network errors +- **Template Error Page**: Mustache rendering failures +- **General Error Page**: Unexpected errors, system failures +- **Error Page Features**: + - Clear, user-friendly error messages + - Suggested actions and next steps + - Navigation back to home/search + - Error codes for support reference + - Retry functionality where appropriate + +### Monitoring & Alerting +- **Error Tracking**: Sentry integration for error monitoring + - Capture JavaScript errors and unhandled exceptions + - Track API failures and network errors + - Monitor OAuth authentication failures + - Alert on critical error spikes +- **Performance Monitoring**: Core Web Vitals tracking + - Lighthouse CI integration + - Real User Monitoring (RUM) + - Performance budget alerts +- **GitHub API Monitoring**: + - Track rate limit usage + - Monitor API response times + - Alert on API failures or deprecations +- **User Analytics**: Basic usage tracking + - Page views and user sessions + - Search usage statistics + - Repository submission metrics + - Feature adoption tracking + +### Backup & Disaster Recovery +- **Critical Data Backup**: + - `../repositories.txt`: Automated daily backups to separate branch + - OAuth secrets: Regular rotation and secure storage + - Configuration files: Version control with tags +- **Backup Strategy**: + - Automated daily backups to separate repository + - Weekly full backups to external storage + - Monthly disaster recovery testing + - Backup integrity verification +- **Recovery Procedures**: + - Documented recovery procedures for each critical component + - Recovery time objectives (RTO) and recovery point objectives (RPO) + - Incident response plan + - Communication plan for outages + +## Performance Considerations +- Lazy load heavy visualization libraries +- Implement client-side caching for fetched JSON +- Optimize images and assets +- Use CDNs for all JavaScript libraries +- Minimize DOM manipulations +- **Performance Budgets**: + - Initial page load: < 3 seconds + - Time to Interactive: < 5 seconds + - First Contentful Paint: < 1.5 seconds + - Search response time: < 200ms + - Report rendering time: < 2 seconds +- **Performance Monitoring**: + - Implement Core Web Vitals tracking + - Monitor Lighthouse scores + - Track real user monitoring (RUM) metrics + - Set up performance alerts for degradation + - Regular performance audits (monthly) +- **CDN Fallback Strategy**: + - Primary CDN: jsdelivr.net + - Secondary CDN: cdnjs.cloudflare.com + - Fallback: Local hosting of critical libraries + - CDN health monitoring and automatic failover + - Version pinning for stability + +## Browser Compatibility +- Modern browsers (Chrome, Firefox, Safari, Edge) +- ES6 module support required +- Fallback for older browsers (optional) +- **Cross-Browser Testing**: + - Test on latest versions of Chrome, Firefox, Safari, Edge + - Test on mobile browsers (iOS Safari, Chrome Mobile) + - Progressive enhancement for older browsers + - Feature detection and graceful degradation + +## Accessibility & Inclusion +- **WCAG 2.1 AA Compliance**: + - Semantic HTML structure + - ARIA labels for interactive elements + - Keyboard navigation support + - Screen reader compatibility + - Color contrast compliance (4.5:1 for text) + - Focus indicators for keyboard users + - Alt text for images + - Skip navigation links +- **Accessibility Testing**: + - Automated testing with axe-core + - Manual keyboard navigation testing + - Screen reader testing (NVDA, JAWS, VoiceOver) + - Regular accessibility audits (quarterly) + - Accessibility audit reports and remediation +- **International Character Support**: + - UTF-8 encoding throughout + - Support for non-ASCII repository names + - Proper handling of international characters in search + - Language attributes for HTML elements + +## Legal & Compliance +- **Privacy Policy**: + - Data collection practices + - OAuth token handling + - GitHub API data usage + - User rights and data deletion + - Cookie policy (if applicable) +- **Terms of Service**: + - Acceptable use policy + - Repository submission guidelines + - Content moderation policy + - Limitation of liability + - DMCA compliance procedures +- **Data Handling**: + - No personal data storage beyond session tokens + - Clear data retention policies + - GDPR compliance considerations + - Data processing agreements with GitHub + +## Community Management +- **Contribution Guidelines**: + - Code of conduct for community interactions + - Repository submission criteria + - Content moderation process + - Issue reporting guidelines +- **Support Workflow**: + - Support request triage process + - Response time targets (48 hours for critical issues) + - Community support channels (GitHub Issues, Discord, etc.) + - Escalation procedures for security issues +- **Moderation System**: + - Spam detection and prevention + - Inappropriate content removal + - Abuse reporting mechanism + - Moderator guidelines and training + +## Progressive Web App (PWA) Considerations +- **PWA Evaluation Criteria**: + - Offline functionality for cached reports + - Installability as desktop/mobile app + - Push notifications for report updates + - Background sync for repository submissions +- **Implementation Phases**: + - Phase 1: Service worker for caching + - Phase 2: Offline report viewing + - Phase 3: PWA manifest and installability + - Phase 4: Push notifications (if user demand exists) + +## Maintenance +- Update `../repositories.txt` via community PRs and automated submissions +- Keep CDN dependencies updated +- Monitor GitHub API rate limits (though using raw URLs avoids this) +- Regular testing against real repositories +- **Testing Maintenance**: + - Keep Bun and Playwright updated + - Maintain test fixtures and sample data + - Update tests to match application changes + - Monitor test coverage and flaky tests + - Update CI/CD test workflows as needed + - Regular security audits of test infrastructure +- **Repository Submission Maintenance**: + - Monitor GitHub Actions workflow execution + - Review and approve/deny repository additions if needed + - Handle abuse and spam submissions + - Monitor GitHub API usage and rate limits + - Maintain repository submission documentation + - Update user-provided workflow template as needed + - Audit repository additions for quality and relevance + - Monitor OAuth app usage and security + - Handle OAuth app registration and configuration + - Manage OAuth Client Secret rotation (quarterly) +- **Monitoring Maintenance**: + - Review Sentry error reports daily + - Monitor performance metrics weekly + - Check GitHub API rate limit usage + - Review CDN health and performance + - Analyze user analytics monthly + - Update monitoring alerts as needed +- **Security Maintenance**: + - Monthly security reviews of OAuth implementation + - Quarterly GitHub Actions workflow security audits + - Regular dependency vulnerability scanning + - Annual penetration testing + - Security incident response plan updates +- **Backup Maintenance**: + - Verify backup integrity weekly + - Test disaster recovery procedures monthly + - Update backup retention policies quarterly + - Review backup encryption and access controls + +## Success Metrics +- Successful report rendering from various repositories +- Fast page load times (< 3 seconds) +- Mobile responsiveness +- Error handling for missing/malformed data +- Easy local testing workflow +- **Testing Metrics**: + - 80%+ unit test coverage for core logic + - All critical user flows covered by integration tests + - Main user journeys covered by E2E tests + - Tests run in under 30 seconds for rapid feedback + - Zero flaky tests in CI/CD pipeline + - OAuth and submission flows fully tested +- **Performance Metrics**: + - Initial page load: < 3 seconds (95th percentile) + - Time to Interactive: < 5 seconds (95th percentile) + - First Contentful Paint: < 1.5 seconds (95th percentile) + - Search response time: < 200ms (95th percentile) + - Report rendering time: < 2 seconds (95th percentile) + - Lighthouse performance score: > 90 +- **Security Metrics**: + - Zero critical security vulnerabilities + - OAuth token zero-day exposure time + - 100% of repository additions audited + - Zero unauthorized repository additions +- **Reliability Metrics**: + - 99.9% uptime for GitHub Pages + - < 1% error rate for API calls + - < 5 minute recovery time for backups + - 100% backup success rate +- **User Experience Metrics**: + - < 5% bounce rate on landing page + - > 70% successful repository submissions + - < 10 second average submission completion time + - > 4.5/5 user satisfaction score +- **Community Metrics**: + - Number of repositories in listing + - Repository submission rate per week + - Community contribution rate + - Support request resolution time + +## Enterprise Deployment Considerations + +### GitHub Enterprise Support +- **GitHub Enterprise Cloud**: Full compatibility with enterprise.github.com instances +- **GitHub Enterprise Server (Self-Hosted)**: Support for on-premises deployments + - Configurable GitHub Enterprise API endpoints + - Custom raw content URL patterns for self-hosted instances + - Example: `https://github.enterprise.com/raw////.refactorfirst/refactor-first.json` +- **Enterprise Configuration File**: `enterprise-config.json` for custom endpoints + ```json + { + "githubEnterpriseUrl": "https://github.enterprise.com", + "apiEndpoint": "https://github.enterprise.com/api/v3", + "rawContentUrl": "https://github.enterprise.com/raw" + } + ``` + +### Custom Domain Configuration +- **CNAME file support**: Standard GitHub Pages custom domain setup +- **SSL/TLS certificates**: Automatic HTTPS with custom domains +- **Subdomain patterns**: `refactorfirst.company.com` or `reports.company.com` +- **DNS configuration documentation**: Step-by-step enterprise DNS setup + +### Access Control & Authentication +- **GitHub OAuth integration**: Required authentication for repository submission + - OAuth 2.0 authorization code flow with PKCE + - Scopes: `public_repo` and `read:user` + - Client ID and Client Secret stored as GitHub Secrets + - Session-based token storage (sessionStorage) +- **GitHub OAuth App Setup**: + - Register OAuth App in GitHub organization settings + - Configure callback URL: `https://refactorfirst.github.io/add-repo/callback` + - Generate and securely store Client Secret + - Configure OAuth app permissions and scopes +- **Personal Access Token (PAT) support**: For fetching private repository reports + - Secure token storage (sessionStorage or encrypted localStorage) + - Token scope: `repo:read` for private repo access +- **IP whitelisting**: Documentation for enterprise firewall configuration +- **SAML/SSO consideration**: Notes on integration with enterprise SSO + +### CI/CD Integration +- **GitHub Actions workflow**: Automated deployment from main branch + ```yaml + name: Deploy to GitHub Pages + on: + push: + branches: [main] + jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./ + ``` +- **Branch protection rules**: Required status checks before deployment +- **Environment-specific configs**: Dev/staging/production configurations +- **Manual approval gates**: For production deployments + +### Enterprise Security Enhancements +- **Content Security Policy (CSP)**: Strict CSP headers for enterprise compliance + ```html + + ``` +- **Subresource Integrity (SRI)**: Hash-based verification for CDN dependencies +- **X-Frame-Options**: Prevent clickjacking attacks +- **X-Content-Type-Options**: Prevent MIME sniffing +- **Referrer-Policy**: Control referrer information +- **Permissions-Policy**: Control browser features + +### Monitoring & Analytics +- **GitHub Pages analytics**: Built-in traffic analytics +- **Custom analytics integration**: Google Analytics, Plausible, or enterprise solutions +- **Error tracking**: Sentry or similar for production error monitoring +- **Performance monitoring**: Core Web Vitals tracking +- **Usage metrics**: Track most-viewed repositories and reports + +### Branding & Customization +- **Theme customization**: CSS variables for enterprise color schemes +- **Logo replacement**: Easy logo swap for enterprise branding +- **Custom footer**: Enterprise-specific footer content +- **White-label mode**: Remove RefactorFirst branding if needed +- **Configuration file**: `branding-config.json` for easy customization + +### Private Repository Support +- **Authenticated fetching**: Support for private repository reports +- **Token management**: Secure PAT handling for private repos +- **Access control**: Only show repositories user has access to +- **Error handling**: Graceful handling of permission errors + +### Compliance & Accessibility +- **WCAG 2.1 AA compliance**: Accessibility standards for enterprises +- **GDPR consideration**: No personal data collection by default +- **Privacy policy template**: For custom analytics implementations +- **Accessibility audit**: ARIA labels, keyboard navigation, screen reader support +- **Section 508 compliance**: For US government enterprises + +### Dependency Management +- **Self-hosting option**: Option to host JavaScript libraries internally +- **CDN fallback**: Primary/secondary CDN configuration +- **Version pinning**: Strict version requirements for enterprise approval +- **Dependency security**: Regular security audit of CDN dependencies +- **Internal CDN support**: Configuration for enterprise internal CDNs + +### Backup & Disaster Recovery +- **Repository backup**: Automated backup of `../repositories.txt` and configurations +- **Git-based versioning**: All content in Git for easy rollback +- **Multi-region deployment**: Option for CDN edge caching in multiple regions +- **Recovery procedures**: Documentation for disaster recovery + +### Documentation for Enterprise Setup +- **Enterprise deployment guide**: Step-by-step setup for enterprise environments +- **GitHub Enterprise Server setup**: Specific instructions for self-hosted instances +- **Custom domain guide**: DNS and SSL configuration +- **Security configuration**: CSP, SRI, and other security headers +- **Troubleshooting guide**: Common enterprise deployment issues + +### Rate Limiting & Performance +- **Client-side caching**: Reduce redundant GitHub API calls +- **Request throttling**: Respect GitHub API rate limits +- **Offline support**: Service worker for basic offline functionality +- **Performance budgets**: Specific performance targets for enterprise SLAs + +### Audit & Governance +- **Change log**: Track configuration changes +- **Approval workflow**: Document approval process for updates +- **Versioning**: Semantic versioning for deployments +- **Rollback procedures**: Quick rollback capability + +## Future Enhancements (Out of Scope) +- User authentication for private repositories +- Report comparison between branches +- Historical report tracking +- Advanced filtering and search +- Export functionality +- Integration with CI/CD pipelines +- Real-time report updates via webhooks +- Multi-language support +- Advanced visualization options diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..0b790c9 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,33 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:8000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + { + name: 'firefox', + use: { browserName: 'firefox' }, + }, + { + name: 'webkit', + use: { browserName: 'webkit' }, + }, + ], + webServer: { + command: 'python -m http.server 8000', + port: 8000, + timeout: 120 * 1000, + }, +}); \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..1d26ddb --- /dev/null +++ b/server.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +import http.server +import socketserver +import os +import sys + +class SPAHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + # Check if the path exists as a file + if self.path == '/': + self.path = '/index.html' + + # Construct the file path + file_path = self.translate_path(self.path) + + # If the file doesn't exist, serve index.html for SPA routing + if not os.path.exists(file_path) or os.path.isdir(file_path): + # Check if it's a static asset request + if any(self.path.startswith(ext) for ext in ['/css/', '/js/', '/assets/', '/templates/']): + self.send_error(404, "File not found") + return + # For all other routes, serve index.html + self.path = '/index.html' + file_path = self.translate_path(self.path) + + return http.server.SimpleHTTPRequestHandler.do_GET(self) + +if __name__ == '__main__': + PORT = 8003 + with socketserver.TCPServer(("", PORT), SPAHTTPRequestHandler) as httpd: + print(f"Serving at http://localhost:{PORT}") + httpd.serve_forever() diff --git a/templates/about.html b/templates/about.html new file mode 100644 index 0000000..7477ac8 --- /dev/null +++ b/templates/about.html @@ -0,0 +1,16 @@ +
+

About RefactorFirst

+

RefactorFirst is a static analysis tool that identifies which classes in your Java + codebase you should refactor first, ranked by cost-benefit. It is based on the paper + Prioritized Technical Debt Identification and uses the XRank metric to surface + the classes with the highest impact on maintainability.

+

How it works

+

Run the RefactorFirst Maven plugin on your project. It generates + .refactorfirst/refactor-first.json — a ranked list of classes to + refactor with effort estimates and recommendations. This site renders those reports + directly from your GitHub repository.

+

Open Source

+

RefactorFirst is open source. Visit the + GitHub repository + to contribute, report issues or learn more.

+
diff --git a/templates/add-repo.html b/templates/add-repo.html new file mode 100644 index 0000000..3f5235b --- /dev/null +++ b/templates/add-repo.html @@ -0,0 +1,22 @@ +
+

Add Your Repository

+

Only repositories with a .refactorfirst/refactor-first.json + file will be added. The RefactorFirst GitHub Page redeploys every 10 minutes.

+ + + + +
diff --git a/templates/api.html b/templates/api.html new file mode 100644 index 0000000..5f782ad --- /dev/null +++ b/templates/api.html @@ -0,0 +1,32 @@ +
+

API for Tool Integrations

+ +

Fetching reports programmatically

+

Reports are plain JSON stored in each repository. Fetch them directly from + GitHub raw content:

+
GET https://raw.githubusercontent.com/<user>/<repo>/<branch>/.refactorfirst/refactor-first.json
+ +

URL patterns

+
    +
  • /<user>/<repo> — rendered report (default branch)
  • +
  • /<user>/<repo>/<branch> — rendered report for a branch
  • +
  • /<user> — all listed repositories of a user
  • +
+ +

JSON schema

+

The report contains projectName, version, + totalClasses, classesToRefactor and a + priorities array with per-class rank, + className, priority, effort, + disharmonies and recommendation fields.

+ +

Webhooks & third-party tools

+

Trigger report regeneration from any CI system by calling + mvn refactorfirst:jsonReport and committing the + .refactorfirst directory.

+ +

Rate limiting & caching

+

GitHub raw and API endpoints are rate-limited (5000 requests/hour authenticated, + 60/hour unauthenticated). Cache responses client-side and respect the + X-RateLimit-* headers.

+
diff --git a/templates/documentation.html b/templates/documentation.html new file mode 100644 index 0000000..0c2125d --- /dev/null +++ b/templates/documentation.html @@ -0,0 +1,27 @@ +
+

Documentation

+ +

Generating Reports

+

Run the Maven plugin:

+
mvn refactorfirst:jsonReport
+

This writes .refactorfirst/refactor-first.json. A graphical HTML report + is also generated into the targets/site directory by + mvn refactorfirst:report.

+ +

Understanding the Report

+
    +
  • Priority — XRank-based ordering: refactor these classes first.
  • +
  • Effort — estimated relative effort to refactor the class.
  • +
  • Disharmonies — detected design flaws (God Class, Brain Method, Feature Envy, ...).
  • +
  • Recommendation — suggested next step for each class.
  • +
+ +

CI/CD Integration

+

Add the RefactorFirst workflow to your repository so the report regenerates on every + push to the default branch. See Getting Started + for a copy-paste workflow.

+ +

Full Documentation

+

Complete plugin documentation is available in the + RefactorFirst repository.

+
diff --git a/templates/error-404.html b/templates/error-404.html new file mode 100644 index 0000000..1f2b838 --- /dev/null +++ b/templates/error-404.html @@ -0,0 +1,7 @@ + diff --git a/templates/error-api.html b/templates/error-api.html new file mode 100644 index 0000000..151dee5 --- /dev/null +++ b/templates/error-api.html @@ -0,0 +1,6 @@ + diff --git a/templates/error-general.html b/templates/error-general.html new file mode 100644 index 0000000..7b26cd5 --- /dev/null +++ b/templates/error-general.html @@ -0,0 +1,6 @@ + diff --git a/templates/error-oauth.html b/templates/error-oauth.html new file mode 100644 index 0000000..e316d81 --- /dev/null +++ b/templates/error-oauth.html @@ -0,0 +1,6 @@ + diff --git a/templates/error-rate-limit.html b/templates/error-rate-limit.html new file mode 100644 index 0000000..288a764 --- /dev/null +++ b/templates/error-rate-limit.html @@ -0,0 +1,6 @@ + diff --git a/templates/error-template.html b/templates/error-template.html new file mode 100644 index 0000000..42cb49f --- /dev/null +++ b/templates/error-template.html @@ -0,0 +1,6 @@ + diff --git a/templates/examples.html b/templates/examples.html new file mode 100644 index 0000000..0d61ee6 --- /dev/null +++ b/templates/examples.html @@ -0,0 +1,26 @@ +
+

Example Reports

+

See RefactorFirst reports for real projects. Each link opens the live report + rendered from that repository.

+ +

Featured projects

+ + +

What to look for

+
    +
  • Small codebases: short priority lists, quick wins.
  • +
  • Medium codebases: a handful of high-priority God Classes.
  • +
  • Large codebases: long tails — focus on the top of the ranking.
  • +
+ +

Before / after refactoring

+

Compare a report on a release branch (/<user>/<repo>/<branch>) + before and after addressing the top-ranked class to see the impact of your work.

+ +

Add your project via Add Your Repo to appear in this gallery.

+
diff --git a/templates/faq.html b/templates/faq.html new file mode 100644 index 0000000..78b8015 --- /dev/null +++ b/templates/faq.html @@ -0,0 +1,45 @@ +
+

Frequently Asked Questions

+ +

What do the different priority colors mean?

+

Priority is ranked by XRank: higher priority classes give the most benefit per unit + of refactoring effort. Colors range from red (highest priority) to green.

+ +

How often should I run RefactorFirst?

+

On every merge to your default branch. The provided GitHub Actions workflow does + this automatically.

+ +

What if my repository doesn't have a report?

+

Generate one with mvn refactorfirst:jsonReport and commit the + .refactorfirst/refactor-first.json file. See + Getting Started.

+ +

How do I add my repository to the listing?

+

Use the Add Your Repo page. You must sign in with + GitHub and have write access to the repository.

+ +

How long until my repository appears after submission?

+

The site redeploys every 10 minutes, so at most about 10 minutes.

+ +

Why do I need to authenticate with GitHub?

+

Authentication proves you have write access to the repository you submit, preventing + spam and unauthorized additions.

+ +

What permissions does the OAuth app require?

+

public_repo and read:user only. We never store your token + outside your browser session.

+ +

Is my GitHub data safe?

+

Yes. Tokens live only in sessionStorage, are cleared when you log out or close the + session, and are only sent to GitHub's API over HTTPS.

+ +

What are disharmonies like God Class and Brain Method?

+

Disharmonies are design flaws detected from metrics: a God Class does too + much, a Brain Method is an overly complex method, Feature Envy + means a method relies on another class's data more than its own.

+ +

Can I compare branches?

+

You can view the report on any branch via the URL + /<user>/<repo>/<branch>, but side-by-side comparison is + not currently supported.

+
diff --git a/templates/feedback.html b/templates/feedback.html new file mode 100644 index 0000000..dec38bb --- /dev/null +++ b/templates/feedback.html @@ -0,0 +1,11 @@ +
+

Feedback

+

We welcome your feedback! The best place to share ideas, report bugs or request + features is the RefactorFirst project on GitHub.

+ +

When reporting a bug, please include the repository and branch you were viewing + and any error code displayed on the error page.

+
diff --git a/templates/getting-started.html b/templates/getting-started.html new file mode 100644 index 0000000..54b69e5 --- /dev/null +++ b/templates/getting-started.html @@ -0,0 +1,34 @@ +
+

Getting Started

+ +

1. Configure the RefactorFirst Maven plugin

+

Add the plugin to your pom.xml:

+
<plugin>
+  <groupId>org.hjug</groupId>
+  <artifactId>refactorfirst-maven-plugin</artifactId>
+  <version>LATEST</version>
+</plugin>
+ +

2. Set up report generation in your repository

+
+ +

3. Add your repository to this site

+

Once a report exists on your default branch, go to + Add Your Repo and sign in with GitHub.

+ +

Why GitHub authentication?

+

We require sign-in to verify that you have write access to the repository you + submit. This prevents abuse and keeps the listing trustworthy.

+

What permissions are needed?

+

The OAuth app requests public_repo (to trigger the validation workflow) + and read:user (to show who you are). Your token is stored only in + your browser session and is never sent anywhere except GitHub's API.

+ +

Troubleshooting

+
    +
  • Report doesn't render? Ensure .refactorfirst/refactor-first.json exists on your default branch.
  • +
  • Changed default branch? Reports are looked up on the branch in the URL, then main, then master.
  • +
  • Submission fails? You need write access to the repository, and it must not already be listed.
  • +
  • Submission is slow to appear? The site redeploys on a 10-minute schedule.
  • +
+
diff --git a/templates/landing.html b/templates/landing.html new file mode 100644 index 0000000..c4e548c --- /dev/null +++ b/templates/landing.html @@ -0,0 +1,18 @@ +
+

RefactorFirst

+

Know which parts of your codebase to refactor first. Search for a repository to see its report.

+
+
+ + + +
+
+ Add My Repo +
+
+

Featured Repositories

+ +
diff --git a/templates/privacy-policy.html b/templates/privacy-policy.html new file mode 100644 index 0000000..af910d7 --- /dev/null +++ b/templates/privacy-policy.html @@ -0,0 +1,24 @@ +
+

Privacy Policy

+ +

Data we collect

+

We do not run analytics or tracking by default. When you sign in with GitHub to + submit a repository, we access only your public profile (username, avatar) and your + repository access permissions.

+ +

OAuth tokens

+

Access tokens are stored only in your browser's session storage, are used solely to + call GitHub's API on your behalf, and are discarded when you log out or close the + session.

+ +

Submissions

+

When you add a repository, your GitHub username and the repository name are recorded + in the public repositories.txt file and in GitHub Actions logs for + auditing.

+ +

Cookies

+

This site does not use cookies.

+ +

Your rights

+

To have a submission removed, open an issue in the RefactorFirst repository.

+
diff --git a/templates/report.html b/templates/report.html new file mode 100644 index 0000000..ea7305d --- /dev/null +++ b/templates/report.html @@ -0,0 +1,17 @@ +
+

{{projectName}}

+

Classes analyzed: {{totalClasses}} · Classes to refactor: {{classesToRefactor}}

+ + + + + + {{#priorities}} + + + + + {{/priorities}} + +
RankClassPriorityEffortRecommendation
{{rank}}{{className}}{{priority}}{{effort}}{{recommendation}}
+
diff --git a/templates/terms-of-service.html b/templates/terms-of-service.html new file mode 100644 index 0000000..52fe71c --- /dev/null +++ b/templates/terms-of-service.html @@ -0,0 +1,23 @@ +
+

Terms of Service

+ +

Acceptable use

+

Only submit repositories you own or have write access to, and only repositories with + a valid RefactorFirst report. Do not attempt to abuse, flood or disrupt the service.

+ +

Content moderation

+

Repository listings may be removed at the maintainers' discretion, including for + inappropriate content or abuse.

+ +

No warranty

+

The service is provided “as is”, without warranty of any kind. Reports are + generated by the RefactorFirst tool and rendered as-is from each repository.

+ +

Limitation of liability

+

To the maximum extent permitted by law, the maintainers are not liable for damages + arising from use of this service.

+ +

DMCA

+

To report content that infringes your rights, open an issue in the RefactorFirst + repository with details of the claim.

+
diff --git a/templates/user-refactorfirst-bitbucket-pipeline.yml b/templates/user-refactorfirst-bitbucket-pipeline.yml new file mode 100644 index 0000000..9a0526b --- /dev/null +++ b/templates/user-refactorfirst-bitbucket-pipeline.yml @@ -0,0 +1,65 @@ +# User-provided pipeline template (Bitbucket). +# Copy this file to bitbucket-pipelines.yml in YOUR repository root to generate a +# RefactorFirst JSON report on every push to main/master and commit it to +# .refactorfirst/refactor-first.json, where the RefactorFirst site can render it. +# +# Prerequisites: +# - Your project builds with Maven +# - The RefactorFirst Maven plugin (org.hjug:refactorfirst-maven-plugin) is +# configured in your pom.xml +# +# Pipelines can push back to the same repository using the default origin; +# no extra credentials are required. + +image: maven:3.9-eclipse-temurin-17 + +pipelines: + branches: + main: + - step: + name: Generate RefactorFirst report + caches: + - maven + script: + - mvn -B refactorfirst:jsonReport + - | + if [ -f .refactorfirst/refactor-first.json ]; then + git config user.email "bitbucket-pipelines@localhost" + git config user.name "Bitbucket Pipelines" + git add .refactorfirst/refactor-first.json + git commit -m "Update RefactorFirst report" || echo "Report unchanged" + git push + fi + master: + - step: + name: Generate RefactorFirst report + caches: + - maven + script: + - mvn -B refactorfirst:jsonReport + - | + if [ -f .refactorfirst/refactor-first.json ]; then + git config user.email "bitbucket-pipelines@localhost" + git config user.name "Bitbucket Pipelines" + git add .refactorfirst/refactor-first.json + git commit -m "Update RefactorFirst report" || echo "Report unchanged" + git push + fi + + # Allow manual runs from the Bitbucket UI. + custom: + generate-report: + - step: + name: Generate RefactorFirst report + caches: + - maven + script: + - mvn -B refactorfirst:jsonReport + - | + if [ -f .refactorfirst/refactor-first.json ]; then + git config user.email "bitbucket-pipelines@localhost" + git config user.name "Bitbucket Pipelines" + git add .refactorfirst/refactor-first.json + git commit -m "Update RefactorFirst report" || echo "Report unchanged" + git push + fi diff --git a/templates/user-refactorfirst-gitlab-ci.yml b/templates/user-refactorfirst-gitlab-ci.yml new file mode 100644 index 0000000..c92960c --- /dev/null +++ b/templates/user-refactorfirst-gitlab-ci.yml @@ -0,0 +1,32 @@ +# User-provided pipeline template (GitLab). +# Copy this file to .gitlab-ci.yml in YOUR repository root to generate a +# RefactorFirst JSON report on every push to the default branch and commit it to +# .refactorfirst/refactor-first.json, where the RefactorFirst site can render it. +# +# Prerequisites: +# - Your project builds with Maven +# - The RefactorFirst Maven plugin (org.hjug:refactorfirst-maven-plugin) is +# configured in your pom.xml +# +# The job pushes back to the repository using the built-in CI_JOB_TOKEN. + +generate-refactorfirst-report: + image: maven:3.9-eclipse-temurin-17 + cache: + key: maven + paths: + - .m2/repository + before_script: + - export MAVEN_OPTS="-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository" + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + script: + - mvn -B refactorfirst:jsonReport + - | + if [ -f .refactorfirst/refactor-first.json ]; then + git config user.email "gitlab-ci@localhost" + git config user.name "GitLab CI" + git add .refactorfirst/refactor-first.json + git commit -m "Update RefactorFirst report" || echo "Report unchanged" + git push "https://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" "HEAD:${CI_COMMIT_REF_NAME}" + fi diff --git a/templates/user-refactorfirst-workflow.yml b/templates/user-refactorfirst-workflow.yml new file mode 100644 index 0000000..f70417e --- /dev/null +++ b/templates/user-refactorfirst-workflow.yml @@ -0,0 +1,40 @@ +# User-provided workflow template. +# Copy this file to .github/workflows/refactorfirst-report.yml in your repository +# to regenerate your RefactorFirst report on every merge to the default branch. + +name: Generate RefactorFirst Report +on: + push: + branches: + - main + - master + workflow_dispatch: + +jobs: + generate-report: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Generate RefactorFirst report + run: mvn refactorfirst:jsonReport + + - name: Commit report + run: | + if [ -f .refactorfirst/refactor-first.json ]; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .refactorfirst/refactor-first.json + git diff --staged --quiet || git commit -m "Update RefactorFirst report" + git push + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/templates/user-repos.html b/templates/user-repos.html new file mode 100644 index 0000000..b14365d --- /dev/null +++ b/templates/user-repos.html @@ -0,0 +1,5 @@ +

{{username}}

+
+ {{{cards}}} +
+{{{pagination}}} diff --git a/templates/workflow-sample-bitbucket.html b/templates/workflow-sample-bitbucket.html new file mode 100644 index 0000000..4987482 --- /dev/null +++ b/templates/workflow-sample-bitbucket.html @@ -0,0 +1,25 @@ +

Copy templates/user-refactorfirst-bitbucket-pipeline.yml from this + site’s repository to bitbucket-pipelines.yml in your + repository. The pipeline runs mvn refactorfirst:jsonReport on every + push to main/master and commits the generated + .refactorfirst/refactor-first.json file. No extra credentials are + needed — Bitbucket Pipelines can push back to the same repository.

+

Copy-paste pipeline (Bitbucket Pipelines YAML)

+
image: maven:3.9-eclipse-temurin-17
+
+pipelines:
+  branches:
+    main:
+      - step:
+          name: Generate RefactorFirst report
+          caches: [maven]
+          script:
+            - mvn -B refactorfirst:jsonReport
+            - |
+              if [ -f .refactorfirst/refactor-first.json ]; then
+                git config user.email "bitbucket-pipelines@localhost"
+                git config user.name "Bitbucket Pipelines"
+                git add .refactorfirst/refactor-first.json
+                git commit -m "Update RefactorFirst report" || echo "Report unchanged"
+                git push
+              fi
diff --git a/templates/workflow-sample-github.html b/templates/workflow-sample-github.html new file mode 100644 index 0000000..240225a --- /dev/null +++ b/templates/workflow-sample-github.html @@ -0,0 +1,33 @@ +

Create .github/workflows/refactorfirst-report.yml in your repository. + A copy-paste-ready template is provided below (also available as a file: + templates/user-refactorfirst-workflow.yml in this site’s repository). + The workflow runs mvn refactorfirst:jsonReport on every push to your + default branch and commits the generated + .refactorfirst/refactor-first.json file.

+

Copy-paste workflow (GitHub Actions YAML)

+
name: Generate RefactorFirst Report
+on:
+  push:
+    branches: [main, master]
+  workflow_dispatch:
+
+jobs:
+  generate-report:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-java@v4
+        with:
+          java-version: '17'
+          distribution: 'temurin'
+          cache: 'maven'
+      - run: mvn refactorfirst:jsonReport
+      - name: Commit report
+        run: |
+          if [ -f .refactorfirst/refactor-first.json ]; then
+            git config user.name "github-actions[bot]"
+            git config user.email "github-actions[bot]@users.noreply.github.com"
+            git add .refactorfirst/refactor-first.json
+            git diff --staged --quiet || git commit -m "Update RefactorFirst report"
+            git push
+          fi
diff --git a/templates/workflow-sample-gitlab.html b/templates/workflow-sample-gitlab.html new file mode 100644 index 0000000..0948028 --- /dev/null +++ b/templates/workflow-sample-gitlab.html @@ -0,0 +1,26 @@ +

Copy templates/user-refactorfirst-gitlab-ci.yml from this site’s + repository to .gitlab-ci.yml in your repository. The pipeline + runs mvn refactorfirst:jsonReport on every push to your default branch + and commits the generated .refactorfirst/refactor-first.json file using + the built-in CI_JOB_TOKEN.

+

Copy-paste pipeline (GitLab CI YAML)

+
generate-refactorfirst-report:
+  image: maven:3.9-eclipse-temurin-17
+  cache:
+    key: maven
+    paths:
+      - .m2/repository
+  before_script:
+    - export MAVEN_OPTS="-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
+  rules:
+    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+  script:
+    - mvn -B refactorfirst:jsonReport
+    - |
+      if [ -f .refactorfirst/refactor-first.json ]; then
+        git config user.email "gitlab-ci@localhost"
+        git config user.name "GitLab CI"
+        git add .refactorfirst/refactor-first.json
+        git commit -m "Update RefactorFirst report" || echo "Report unchanged"
+        git push "https://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" "HEAD:${CI_COMMIT_REF_NAME}"
+      fi
diff --git a/tests/e2e/cross-browser.spec.js b/tests/e2e/cross-browser.spec.js new file mode 100644 index 0000000..5b2d286 --- /dev/null +++ b/tests/e2e/cross-browser.spec.js @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; + +// Cross-browser smoke tests (run against chromium, firefox and webkit +// per playwright.config.js projects). + +test('core pages render consistently across browsers', async ({ page }) => { + for (const path of ['/', '/about', '/faq', '/examples', '/api', '/documentation']) { + await page.goto(path); + await page.waitForLoadState('networkidle'); + await expect(page.locator('#top-menu')).toBeVisible(); + await expect(page.locator('#app')).not.toBeEmpty(); + } +}); + +test('keyboard search works across browsers', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + const search = page.locator('.hero-search input'); + await search.fill('refactor'); + await search.press('ArrowDown'); + await search.press('Enter'); + await expect(page).toHaveURL(/\/refactorfirst\/refactorfirst/); +}); + +test('legal pages are reachable and render', async ({ page }) => { + await page.goto('/privacy-policy'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('h1')).toContainText('Privacy'); + await page.goto('/terms-of-service'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('h1')).toContainText('Terms'); +}); diff --git a/tests/e2e/mobile-responsiveness.spec.js b/tests/e2e/mobile-responsiveness.spec.js new file mode 100644 index 0000000..00e672d --- /dev/null +++ b/tests/e2e/mobile-responsiveness.spec.js @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; + +test.describe('mobile viewports', () => { + test.use({ viewport: { width: 375, height: 667 } }); // iPhone SE size + + test('hamburger menu opens and navigates on mobile', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + const toggle = page.locator('#menu-toggle'); + await expect(toggle).toBeVisible(); + await toggle.click(); + const links = page.locator('#menu-links'); + await expect(links).toHaveClass(/open/); + await page.locator('#menu-links a[href="/about"]').click(); + await expect(page).toHaveURL(/\/about$/); + }); + + test('repository grid collapses to a single column on mobile', async ({ page }) => { + await page.goto('/refactorfirst'); + await page.waitForLoadState('networkidle'); + const grid = page.locator('.repo-grid'); + await expect(grid).toBeVisible(); + const columns = await grid.evaluate(el => getComputedStyle(el).gridTemplateColumns.split(' ').length); + expect(columns).toBe(1); + }); + + test('landing page is usable on a phone-sized screen', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.hero')).toBeVisible(); + const search = page.locator('.hero-search input'); + await search.fill('refactorfirst'); + await expect(page.locator('.search-results li').first()).toBeVisible(); + }); +}); diff --git a/tests/e2e/user-journeys.spec.js b/tests/e2e/user-journeys.spec.js new file mode 100644 index 0000000..7e46bb9 --- /dev/null +++ b/tests/e2e/user-journeys.spec.js @@ -0,0 +1,122 @@ +import { test, expect } from '@playwright/test'; + +const SAMPLE_REPORT = { + projectName: 'refactorfirst', + version: '0.5.1', + totalClasses: 150, + classesToRefactor: 2, + priorities: [ + { + rank: 1, + className: 'org.hjug.git.GitLogReader', + priority: 'HIGH', + effort: '3', + disharmonies: ['God Class'], + recommendation: 'Break into smaller classes.' + } + ] +}; + +test.beforeEach(async ({ page }) => { + // Mock GitHub raw content so tests never hit the network. + await page.route('**/raw.githubusercontent.com/**', route => { + const url = route.request().url(); + if (url.endsWith('refactor-first.json')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SAMPLE_REPORT) }); + } else if (url.endsWith('.mustache')) { + route.fulfill({ status: 404 }); + } else { + route.continue(); + } + }); +}); + +test('landing page loads with hero, search and menu', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.hero')).toBeVisible(); + await expect(page.locator('#menu-search-input')).toBeVisible(); + await expect(page.locator('#top-menu .menu-links a[href="/about"]')).toBeVisible(); + await expect(page.locator('text=Add My Repo')).toBeVisible(); +}); + +test('search navigates to a repository report', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + const search = page.locator('.hero-search input'); + await search.fill('refactor'); + const option = page.locator('.hero-search .search-results li', { hasText: 'refactorfirst/refactorfirst' }); + await option.click(); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveURL(/\/refactorfirst\/refactorfirst$/); + await expect(page.locator('#app')).toContainText('refactorfirst'); +}); + +test('report renders from repository JSON with fallback template', async ({ page }) => { + await page.goto('/refactorfirst/refactorfirst'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('h1')).toContainText('refactorfirst'); + await expect(page.locator('text=org.hjug.git.GitLogReader')).toBeVisible(); +}); + +test('branch fallback shows report from master', async ({ page }) => { + let requested = []; + await page.unroute('**/raw.githubusercontent.com/**'); + await page.route('**/raw.githubusercontent.com/**', route => { + const url = route.request().url(); + requested.push(url); + if (url.includes('/main/.refactorfirst/refactor-first.json')) { + route.fulfill({ status: 404 }); + } else if (url.includes('/master/.refactorfirst/refactor-first.json')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SAMPLE_REPORT) }); + } else { + route.fulfill({ status: 404 }); + } + }); + + await page.goto('/refactorfirst/refactorfirst'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('h1')).toContainText('refactorfirst'); + expect(requested.some(u => u.includes('/main/'))).toBe(true); + expect(requested.some(u => u.includes('/master/'))).toBe(true); +}); + +test('unknown repository shows a friendly 404 page', async ({ page }) => { + await page.unroute('**/raw.githubusercontent.com/**'); + await page.route('**/raw.githubusercontent.com/**', route => route.fulfill({ status: 404 })); + await page.goto('/ghost/missing'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.error-page')).toBeVisible(); + await expect(page.locator('.error-page a[href="/"]')).toBeVisible(); +}); + +test('user listing page shows repositories alphabetically', async ({ page }) => { + await page.goto('/refactorfirst'); + await page.waitForLoadState('networkidle'); + const cards = page.locator('.repo-card'); + await expect(cards).toHaveCount(1); + await expect(cards.first()).toContainText('refactorfirst'); +}); + +test('static pages render: about and getting started', async ({ page }) => { + await page.goto('/about'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('h1')).toContainText('About RefactorFirst'); + await page.goto('/getting-started'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('h1')).toContainText('Getting Started'); + await expect(page.locator('code', { hasText: 'refactorfirst:jsonReport' }).first()).toBeVisible(); +}); + +test('add-repo page requires GitHub login', async ({ page }) => { + await page.goto('/add-repo'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('#login-github')).toBeVisible(); +}); + +test('top menu height stays within 140px', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + const box = await page.locator('#top-menu').boundingBox(); + expect(box.height).toBeLessThanOrEqual(200); +}); diff --git a/tests/fixtures/sample-mustache-template.mustache b/tests/fixtures/sample-mustache-template.mustache new file mode 100644 index 0000000..e5d05bf --- /dev/null +++ b/tests/fixtures/sample-mustache-template.mustache @@ -0,0 +1,9 @@ +

RefactorFirst Report: {{projectName}}

+

Version: {{version}}

+

Classes analyzed: {{totalClasses}}

+

Classes to refactor: {{classesToRefactor}}

+
    +{{#priorities}} +
  • {{rank}}. {{className}} ({{priority}}) - {{recommendation}}
  • +{{/priorities}} +
diff --git a/tests/fixtures/sample-refactor-first.json b/tests/fixtures/sample-refactor-first.json new file mode 100644 index 0000000..eceb896 --- /dev/null +++ b/tests/fixtures/sample-refactor-first.json @@ -0,0 +1,31 @@ +{ + "projectName": "refactorfirst", + "version": "0.5.1", + "generatedAt": "2024-01-15T10:30:00Z", + "totalClasses": 150, + "classesToRefactor": 12, + "priorities": [ + { + "rank": 1, + "className": "org.hjug.git.GitLogReader", + "priority": "HIGH", + "effort": "3", + "disharmonies": ["God Class", "Brain Method"], + "recommendation": "Break this class into smaller, focused classes." + }, + { + "rank": 2, + "className": "org.hjug.cbc.CostBenefitCalculator", + "priority": "MEDIUM", + "effort": "2", + "disharmonies": ["Feature Envy"], + "recommendation": "Move methods closer to the data they use." + } + ], + "metrics": { + "averageEffort": 2.5, + "highestPriorityCount": 3, + "mediumPriorityCount": 5, + "lowPriorityCount": 4 + } +} diff --git a/tests/fixtures/sample-repositories.txt b/tests/fixtures/sample-repositories.txt new file mode 100644 index 0000000..c01c021 --- /dev/null +++ b/tests/fixtures/sample-repositories.txt @@ -0,0 +1,5 @@ +apache/tomcat +refactorfirst/refactorfirst +spring-projects/spring-framework +spring-projects/spring-boot +facebook/react diff --git a/tests/integration/getting-started-samples.test.js b/tests/integration/getting-started-samples.test.js new file mode 100644 index 0000000..64ea3f8 --- /dev/null +++ b/tests/integration/getting-started-samples.test.js @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { createApp } from '../../js/main.js'; + +const PAGE = '

Getting Started

'; +const SAMPLES = { + github: '

GitHub Actions sample

uses: actions/checkout@v4
', + gitlab: '

GitLab sample

CI_JOB_TOKEN
', + bitbucket: '

Bitbucket sample

bitbucket-pipelines
' +}; + +describe('Getting Started workflow samples per hosting environment', () => { + let mockFetch, app, root; + + beforeEach(() => { + document.body.innerHTML = '
'; + root = document.getElementById('app'); + mockFetch = spyOn(global, 'fetch').mockImplementation(url => { + if (url.endsWith('/templates/getting-started.html')) { + return Promise.resolve({ ok: true, text: () => Promise.resolve(PAGE) }); + } + const sample = Object.keys(SAMPLES).find(env => url.endsWith(`/templates/workflow-sample-${env}.html`)); + if (sample) { + return Promise.resolve({ ok: true, text: () => Promise.resolve(SAMPLES[sample]) }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + }); + + afterEach(() => mockFetch.mockRestore()); + + for (const env of ['github', 'gitlab', 'bitbucket']) { + it(`shows only the ${env} sample on a ${env} deployment`, async () => { + app = createApp({ root, hostEnvironment: env }); + history.replaceState(null, '', '/getting-started'); + await app.handleRoute(); + + const sample = root.querySelector('#workflow-sample'); + expect(sample.textContent).toContain( + { github: 'GitHub Actions', gitlab: 'GitLab', bitbucket: 'Bitbucket' }[env] + ); + // None of the other environments' samples should be present + for (const other of Object.keys(SAMPLES)) { + if (other !== env) { + expect(sample.innerHTML).not.toContain(SAMPLES[other]); + } + } + }); + } + + it('detects the environment from the hostname by default', async () => { + // jsdom test host is localhost -> defaults to github + app = createApp({ root }); + history.replaceState(null, '', '/getting-started'); + await app.handleRoute(); + expect(root.querySelector('#workflow-sample').textContent).toContain('GitHub Actions'); + }); + + it('shows a friendly note when the sample cannot be loaded', async () => { + mockFetch.mockImplementation(url => { + if (url.endsWith('/templates/getting-started.html')) { + return Promise.resolve({ ok: true, text: () => Promise.resolve(PAGE) }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + app = createApp({ root, hostEnvironment: 'gitlab' }); + history.replaceState(null, '', '/getting-started'); + await app.handleRoute(); + expect(root.querySelector('#workflow-sample').textContent).toContain('could not be loaded'); + }); +}); diff --git a/tests/integration/oauth-flow.test.js b/tests/integration/oauth-flow.test.js new file mode 100644 index 0000000..c1f5b7e --- /dev/null +++ b/tests/integration/oauth-flow.test.js @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { createApp } from '../../js/main.js'; +import { getToken, logout, isAuthenticated } from '../../js/oauth-handler.js'; + +describe('OAuth flow integration', () => { + let mockFetch, app, root, navigations; + + beforeEach(() => { + document.body.innerHTML = '
'; + root = document.getElementById('app'); + navigations = []; + sessionStorage.clear(); + mockFetch = spyOn(global, 'fetch'); + }); + + afterEach(() => { + mockFetch.mockRestore(); + logout(); + }); + + it('handles the OAuth callback, exchanges the code and returns to /add-repo', async () => { + sessionStorage.setItem('oauth_state', 'valid-state'); + sessionStorage.setItem('oauth_code_verifier', 'verifier'); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ access_token: 'new-token', token_type: 'bearer' }) + }); + + app = createApp({ root, onNavigate: to => navigations.push(to) }); + history.replaceState(null, '', '/add-repo/callback?code=authcode&state=valid-state'); + await app.handleRoute(); + + expect(getToken()).toBe('new-token'); + expect(isAuthenticated()).toBe(true); + expect(navigations).toEqual(['/add-repo']); + }); + + it('shows the OAuth error page on state mismatch', async () => { + sessionStorage.setItem('oauth_state', 'expected'); + app = createApp({ root }); + history.replaceState(null, '', '/add-repo/callback?code=x&state=tampered'); + await app.handleRoute(); + + expect(getToken()).toBeNull(); + expect(root.querySelector('.error-oauth')).not.toBeNull(); + }); + + it('shows the OAuth error page when the user denies access', async () => { + sessionStorage.setItem('oauth_state', 's'); + app = createApp({ root }); + history.replaceState(null, '', '/add-repo/callback?error=access_denied&state=s'); + await app.handleRoute(); + expect(root.querySelector('.error-oauth')).not.toBeNull(); + }); + + it('clicking login starts the authorization flow', async () => { + app = createApp({ root, onExternalRedirect: url => navigations.push(url) }); + history.replaceState(null, '', '/add-repo'); + await app.handleRoute(); + + root.querySelector('#login-github').click(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(navigations.length).toBe(1); + expect(navigations[0]).toContain('https://github.com/login/oauth/authorize'); + expect(navigations[0]).toContain('client_id='); + expect(navigations[0]).toContain('code_challenge='); + }); +}); diff --git a/tests/integration/report-rendering.test.js b/tests/integration/report-rendering.test.js new file mode 100644 index 0000000..9c7323e --- /dev/null +++ b/tests/integration/report-rendering.test.js @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { createApp } from '../../js/main.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +const sampleJson = JSON.parse( + fs.readFileSync(path.join(import.meta.dir, '../fixtures/sample-refactor-first.json'), 'utf8') +); +const sampleTemplate = fs.readFileSync( + path.join(import.meta.dir, '../fixtures/sample-mustache-template.mustache'), 'utf8' +); + +describe('Report rendering integration', () => { + let mockFetch, app, root; + + beforeEach(() => { + document.body.innerHTML = '
'; + root = document.getElementById('app'); + mockFetch = spyOn(global, 'fetch'); + }); + + afterEach(() => mockFetch.mockRestore()); + + function respondJsonFor(url, data, { template = sampleTemplate } = {}) { + mockFetch.mockImplementation(requested => { + if (requested.endsWith('.refactorfirst/refactor-first.json')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(data) }); + } + if (requested.endsWith('.refactorfirst/refactor-first-report.mustache')) { + return Promise.resolve({ ok: true, text: () => Promise.resolve(template) }); + } + void url; + return Promise.resolve({ ok: false, status: 404 }); + }); + } + + it('renders the report for /user/repo', async () => { + respondJsonFor(null, sampleJson); + app = createApp({ root }); + history.replaceState(null, '', '/refactorfirst/refactorfirst'); + await app.handleRoute(); + expect(root.innerHTML).toContain('refactorfirst'); + expect(root.querySelector('h1').textContent).toContain('refactorfirst'); + }); + + it('falls back from main to master', async () => { + mockFetch.mockImplementation(requested => { + if (requested.includes('/main/.refactorfirst/refactor-first.json')) { + return Promise.resolve({ ok: false, status: 404 }); + } + if (requested.includes('/master/.refactorfirst/refactor-first.json')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(sampleJson) }); + } + if (requested.endsWith('.mustache')) { + return Promise.resolve({ ok: true, text: () => Promise.resolve(sampleTemplate) }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + app = createApp({ root }); + history.replaceState(null, '', '/refactorfirst/refactorfirst'); + await app.handleRoute(); + expect(root.innerHTML).toContain('refactorfirst'); + expect(mockFetch.mock.calls.some(c => c[0].includes('/master/'))).toBe(true); + }); + + it('shows the not-found error page when no branch has a report', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 }); + app = createApp({ root }); + history.replaceState(null, '', '/ghost/missing'); + await app.handleRoute(); + expect(root.querySelector('.error-page')).not.toBeNull(); + expect(root.textContent).toContain('Not Found'); + }); + + it('uses the bundled fallback template when the repo has none', async () => { + mockFetch.mockImplementation(requested => { + if (requested.endsWith('.refactorfirst/refactor-first.json')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(sampleJson) }); + } + if (requested.endsWith('refactor-first-report.mustache') && requested.includes('raw.githubusercontent')) { + return Promise.resolve({ ok: false, status: 404 }); + } + if (requested.endsWith('assets/refactor-first-report.mustache')) { + return Promise.resolve({ ok: true, text: () => Promise.resolve('
fallback: {{projectName}}
') }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + app = createApp({ root }); + history.replaceState(null, '', '/refactorfirst/refactorfirst'); + await app.handleRoute(); + expect(root.innerHTML).toContain('fallback: refactorfirst'); + }); +}); diff --git a/tests/integration/search-flow.test.js b/tests/integration/search-flow.test.js new file mode 100644 index 0000000..aaaedc4 --- /dev/null +++ b/tests/integration/search-flow.test.js @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { createApp } from '../../js/main.js'; + +const REPOS_TXT = 'apache/tomcat\nrefactorfirst/refactorfirst\nspring-projects/spring-framework\n'; + +describe('Landing page and search flow integration', () => { + let mockFetch, app, root, navigations; + + beforeEach(() => { + document.body.innerHTML = '
'; + root = document.getElementById('app'); + navigations = []; + mockFetch = spyOn(global, 'fetch').mockImplementation(requested => { + if (requested.endsWith('/repositories.txt')) { + return Promise.resolve({ ok: true, text: () => Promise.resolve(REPOS_TXT) }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + }); + + afterEach(() => mockFetch.mockRestore()); + + it('renders the landing page with hero, search and Add Repo call-to-action', async () => { + app = createApp({ root, onNavigate: to => navigations.push(to) }); + history.replaceState(null, '', '/'); + await app.handleRoute(); + expect(root.querySelector('.hero')).not.toBeNull(); + expect(root.querySelector('input[type="search"]')).not.toBeNull(); + expect(root.textContent).toContain('Add My Repo'); + }); + + it('shows featured repositories from the listing', async () => { + app = createApp({ root, onNavigate: to => navigations.push(to) }); + history.replaceState(null, '', '/'); + await app.handleRoute(); + const featured = root.querySelector('.featured-repos'); + expect(featured).not.toBeNull(); + expect(featured.textContent).toContain('refactorfirst/refactorfirst'); + }); + + it('type-ahead navigates to the chosen repository', async () => { + app = createApp({ root, onNavigate: to => navigations.push(to) }); + history.replaceState(null, '', '/'); + await app.handleRoute(); + + const input = root.querySelector('input[type="search"]'); + input.value = 'tomcat'; + input.dispatchEvent(new window.Event('input', { bubbles: true })); + + const option = root.querySelector('.search-results li'); + expect(option.textContent).toBe('apache/tomcat'); + option.click(); + expect(navigations).toEqual(['/apache/tomcat']); + }); + + it('lists a user\'s repositories alphabetically on the user page', async () => { + app = createApp({ root }); + history.replaceState(null, '', '/spring-projects'); + await app.handleRoute(); + + const cards = root.querySelectorAll('.repo-card'); + expect(cards.length).toBe(1); + expect(root.textContent).toContain('spring-framework'); + }); + + it('paginates large listings with ?page=N', async () => { + const many = Array.from({ length: 75 }, (_, i) => `alice/repo-${String(i).padStart(3, '0')}`).join('\n'); + mockFetch.mockImplementation(requested => { + if (requested.endsWith('/repositories.txt')) { + return Promise.resolve({ ok: true, text: () => Promise.resolve(many) }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + app = createApp({ root }); + history.replaceState(null, '', '/alice?page=2'); + await app.handleRoute(); + + const cards = root.querySelectorAll('.repo-card'); + expect(cards.length).toBe(25); + expect(cards[0].textContent).toContain('repo-050'); + const pagination = root.querySelector('nav.pagination'); + expect(pagination).not.toBeNull(); + expect(pagination.querySelector('[aria-current="page"]').textContent).toBe('2'); + }); + + it('renders static pages from their templates', async () => { + mockFetch.mockImplementation(requested => { + if (requested.endsWith('/templates/about.html')) { + return Promise.resolve({ ok: true, text: () => Promise.resolve('

About RefactorFirst

') }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + app = createApp({ root }); + history.replaceState(null, '', '/about'); + await app.handleRoute(); + expect(root.querySelector('h1').textContent).toBe('About RefactorFirst'); + }); +}); diff --git a/tests/integration/submission-flow.test.js b/tests/integration/submission-flow.test.js new file mode 100644 index 0000000..9133833 --- /dev/null +++ b/tests/integration/submission-flow.test.js @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { createApp } from '../../js/main.js'; +import { storeToken, logout } from '../../js/oauth-handler.js'; + +describe('Repository submission flow integration', () => { + let mockFetch, app, root; + + beforeEach(() => { + document.body.innerHTML = '
'; + root = document.getElementById('app'); + sessionStorage.clear(); + mockFetch = spyOn(global, 'fetch'); + }); + + afterEach(() => { + mockFetch.mockRestore(); + logout(); + }); + + it('requires login before showing the submission form', async () => { + app = createApp({ root }); + history.replaceState(null, '', '/add-repo'); + await app.handleRoute(); + expect(root.querySelector('#login-github')).not.toBeNull(); + expect(root.querySelector('form#repo-form')).toBeNull(); + }); + + it('renders the form with user info when authenticated', async () => { + storeToken('token'); + mockFetch.mockImplementation(requested => { + if (requested === 'https://api.github.com/user') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ login: 'octocat', avatar_url: 'https://avatars.githubusercontent.com/u/1' }) + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + app = createApp({ root }); + history.replaceState(null, '', '/add-repo'); + await app.handleRoute(); + + expect(root.querySelector('form#repo-form')).not.toBeNull(); + expect(root.textContent).toContain('octocat'); + expect(root.querySelector('img.user-avatar')).not.toBeNull(); + }); + + it('shows validation errors for empty input without API calls', async () => { + storeToken('token'); + mockFetch.mockImplementation(requested => { + if (requested === 'https://api.github.com/user') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ login: 'octocat', avatar_url: '' }) + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + app = createApp({ root }); + history.replaceState(null, '', '/add-repo'); + await app.handleRoute(); + + root.querySelector('form#repo-form').dispatchEvent( + new window.Event('submit', { bubbles: true, cancelable: true }) + ); + await app.pendingSubmissions(); + + const status = root.querySelector('.form-status'); + expect(status.textContent).toContain('required'); + // Only the profile fetch should have happened + expect(mockFetch.mock.calls.filter(c => c[0] === 'https://api.github.com/user').length).toBe(1); + }); + + it('submits and shows success feedback', async () => { + storeToken('token'); + mockFetch.mockImplementation(requested => { + if (requested === 'https://api.github.com/user') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ login: 'octocat', avatar_url: '' }) + }); + } + if (requested.includes('/collaborators/')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ permission: 'admin' }) }); + } + if (requested.endsWith('api.github.com/repos/octocat/hello-world')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ default_branch: 'main' }) }); + } + if (requested.includes('raw.githubusercontent.com/octocat/hello-world/main/')) { + return Promise.resolve({ ok: true }); + } + if (requested.endsWith('/dispatches')) { + return Promise.resolve({ ok: true, status: 204 }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + app = createApp({ root }); + history.replaceState(null, '', '/add-repo'); + await app.handleRoute(); + + root.querySelector('#repo-owner').value = 'octocat'; + root.querySelector('#repo-name').value = 'hello-world'; + root.querySelector('form#repo-form').dispatchEvent( + new window.Event('submit', { bubbles: true, cancelable: true }) + ); + await app.pendingSubmissions(); + + const status = root.querySelector('.form-status'); + expect(status.classList.contains('success')).toBe(true); + expect(root.querySelector('button[type="submit"]').disabled).toBe(false); + }); + + it('requires .refactorfirst/refactor-first.json on main or the default branch', async () => { + storeToken('token'); + mockFetch.mockImplementation(requested => { + if (requested === 'https://api.github.com/user') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ login: 'octocat', avatar_url: '' }) + }); + } + if (requested.includes('/collaborators/')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ permission: 'write' }) }); + } + if (requested.endsWith('api.github.com/repos/octocat/no-report')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ default_branch: 'develop' }) }); + } + // All raw content requests 404: no report on main or the default branch + return Promise.resolve({ ok: false, status: 404 }); + }); + + app = createApp({ root }); + history.replaceState(null, '', '/add-repo'); + await app.handleRoute(); + + root.querySelector('#repo-owner').value = 'octocat'; + root.querySelector('#repo-name').value = 'no-report'; + root.querySelector('form#repo-form').dispatchEvent( + new window.Event('submit', { bubbles: true, cancelable: true }) + ); + await app.pendingSubmissions(); + + const status = root.querySelector('.form-status'); + expect(status.classList.contains('error')).toBe(true); + expect(status.textContent).toBe( + 'The repository specified must have a .refactorfirst/refactor-first.json file present.' + ); + // The main branch was checked before the default branch + const rawCalls = mockFetch.mock.calls.map(c => c[0]).filter(u => u.includes('raw.githubusercontent.com')); + expect(rawCalls[0]).toContain('/main/'); + expect(rawCalls[1]).toContain('/develop/'); + expect(mockFetch.mock.calls.some(c => c[0].endsWith('/dispatches'))).toBe(false); + }); + + it('shows an error when access is denied', async () => { + storeToken('token'); + mockFetch.mockImplementation(requested => { + if (requested === 'https://api.github.com/user') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ login: 'octocat', avatar_url: '' }) + }); + } + if (requested.includes('/collaborators/')) { + return Promise.resolve({ ok: false, status: 404 }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + app = createApp({ root }); + history.replaceState(null, '', '/add-repo'); + await app.handleRoute(); + + root.querySelector('#repo-owner').value = 'other'; + root.querySelector('#repo-name').value = 'private-repo'; + root.querySelector('form#repo-form').dispatchEvent( + new window.Event('submit', { bubbles: true, cancelable: true }) + ); + await app.pendingSubmissions(); + + const status = root.querySelector('.form-status'); + expect(status.classList.contains('error')).toBe(true); + expect(mockFetch.mock.calls.some(c => c[0].endsWith('/dispatches'))).toBe(false); + }); +}); diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..17aadbb --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,30 @@ +// Test environment setup: provide browser-like globals for unit tests via jsdom. +import { JSDOM } from 'jsdom'; + +const dom = new JSDOM('', { + url: 'http://localhost:8000/', + pretendToBeVisual: true, +}); + +globalThis.window = dom.window; +globalThis.document = dom.window.document; +globalThis.navigator = dom.window.navigator; +globalThis.location = dom.window.location; +globalThis.history = dom.window.history; +globalThis.sessionStorage = dom.window.sessionStorage; +globalThis.localStorage = dom.window.localStorage; +globalThis.URLSearchParams = dom.window.URLSearchParams; +globalThis.CustomEvent = dom.window.CustomEvent; +globalThis.KeyboardEvent = dom.window.KeyboardEvent; +globalThis.MouseEvent = dom.window.MouseEvent; +if (dom.window.crypto && dom.window.crypto.subtle) { + globalThis.crypto = dom.window.crypto; +} else if (!globalThis.crypto || !globalThis.crypto.subtle) { + const { webcrypto } = await import('node:crypto'); + globalThis.crypto = webcrypto; +} +globalThis.TextEncoder = globalThis.TextEncoder || dom.window.TextEncoder; +globalThis.TextDecoder = globalThis.TextDecoder || dom.window.TextDecoder; + +// Prevent js/main.js from auto-starting when imported by tests. +globalThis.__APP_AUTO_INIT__ = false; diff --git a/tests/unit/cache-manager.test.js b/tests/unit/cache-manager.test.js new file mode 100644 index 0000000..ea6adcd --- /dev/null +++ b/tests/unit/cache-manager.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { CacheManager } from '../../js/cache-manager.js'; + +describe('CacheManager', () => { + let cache; + beforeEach(() => { cache = new CacheManager({ maxEntries: 3, defaultTtlMs: 60000 }); }); + + it('stores and retrieves values', () => { + cache.set('a', { value: 1 }); + expect(cache.get('a')).toEqual({ value: 1 }); + }); + + it('returns undefined for missing keys', () => { + expect(cache.get('nope')).toBeUndefined(); + }); + + it('expires entries after their TTL', async () => { + const shortCache = new CacheManager({ maxEntries: 5, defaultTtlMs: 10 }); + shortCache.set('a', 1); + await new Promise(resolve => setTimeout(resolve, 20)); + expect(shortCache.get('a')).toBeUndefined(); + }); + + it('honours per-entry TTL overrides', async () => { + const mixed = new CacheManager({ maxEntries: 5, defaultTtlMs: 60000 }); + mixed.set('short', 1, 10); + mixed.set('long', 2); + await new Promise(resolve => setTimeout(resolve, 20)); + expect(mixed.get('short')).toBeUndefined(); + expect(mixed.get('long')).toBe(2); + }); + + it('evicts the least recently used entry when full', () => { + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + cache.get('a'); // touch a so b becomes LRU + cache.set('d', 4); + expect(cache.get('b')).toBeUndefined(); + expect(cache.get('a')).toBe(1); + expect(cache.get('c')).toBe(3); + expect(cache.get('d')).toBe(4); + }); + + it('supports invalidation and clearing', () => { + cache.set('a', 1); + cache.set('b', 2); + cache.invalidate('a'); + expect(cache.get('a')).toBeUndefined(); + cache.clear(); + expect(cache.size()).toBe(0); + }); + + it('builds stable cache keys from request parameters', () => { + const k1 = CacheManager.buildKey('report', { user: 'u', repo: 'r', branch: 'main' }); + const k2 = CacheManager.buildKey('report', { branch: 'main', repo: 'r', user: 'u' }); + expect(k1).toBe(k2); + }); +}); diff --git a/tests/unit/error-handler.test.js b/tests/unit/error-handler.test.js new file mode 100644 index 0000000..07b6c9a --- /dev/null +++ b/tests/unit/error-handler.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { + classifyError, + userMessageFor, + renderErrorPage +} from '../../js/error-handler.js'; + +describe('classifyError', () => { + it('classifies 404s as not-found', () => { + expect(classifyError(new Error('Repository not found')).type).toBe('not-found'); + expect(classifyError({ status: 404 }).type).toBe('not-found'); + }); + + it('classifies 403/429 as rate-limit', () => { + expect(classifyError({ status: 403 }).type).toBe('rate-limit'); + expect(classifyError({ status: 429 }).type).toBe('rate-limit'); + }); + + it('classifies OAuth errors', () => { + expect(classifyError(new Error('OAuth state mismatch - possible CSRF attack')).type).toBe('oauth'); + expect(classifyError(new Error('OAuth error: access_denied')).type).toBe('oauth'); + }); + + it('classifies network failures', () => { + expect(classifyError(new TypeError('Failed to fetch')).type).toBe('network'); + }); + + it('classifies template errors', () => { + expect(classifyError(new Error('Failed to fetch template: 500')).type).toBe('template'); + }); + + it('classifies other API errors', () => { + expect(classifyError({ status: 500 }).type).toBe('api'); + }); + + it('falls back to a general error', () => { + expect(classifyError(new Error('mystery')).type).toBe('general'); + }); +}); + +describe('userMessageFor', () => { + it('provides a friendly message and suggestion for every error type', () => { + for (const type of ['not-found', 'rate-limit', 'oauth', 'network', 'template', 'api', 'general']) { + const message = userMessageFor(type); + expect(message.title.length).toBeGreaterThan(0); + expect(message.suggestion.length).toBeGreaterThan(0); + } + }); + + it('suggests checking the repository for not-found errors', () => { + expect(userMessageFor('not-found').suggestion.toLowerCase()).toContain('repository'); + }); +}); + +describe('renderErrorPage', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + }); + + it('renders the error title, message and a way home', () => { + const container = document.getElementById('app'); + renderErrorPage(container, new Error('Repository not found')); + expect(container.querySelector('h1').textContent).toContain('Not Found'); + expect(container.querySelector('a[href="/"]')).not.toBeNull(); + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + }); + + it('includes a retry button for recoverable errors', () => { + const container = document.getElementById('app'); + let retried = false; + renderErrorPage(container, { status: 429 }, { onRetry: () => { retried = true; } }); + const button = container.querySelector('button.retry'); + expect(button).not.toBeNull(); + button.click(); + expect(retried).toBe(true); + }); + + it('renders an error code for support reference', () => { + const container = document.getElementById('app'); + renderErrorPage(container, { status: 500 }); + expect(container.querySelector('.error-code').textContent).toContain('500'); + }); +}); diff --git a/tests/unit/fetcher-fallback.test.js b/tests/unit/fetcher-fallback.test.js new file mode 100644 index 0000000..88b159f --- /dev/null +++ b/tests/unit/fetcher-fallback.test.js @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { + fetchJsonWithFallback, + fetchReport, + fetchWithRetry +} from '../../js/fetcher.js'; + +describe('fetchJsonWithFallback (branch fallback)', () => { + let mockFetch; + beforeEach(() => { mockFetch = spyOn(global, 'fetch'); }); + afterEach(() => mockFetch.mockRestore()); + + const okJson = data => ({ ok: true, json: () => Promise.resolve(data) }); + const notFound = { ok: false, status: 404 }; + + it('fetches from the requested branch when it succeeds', async () => { + mockFetch.mockResolvedValue(okJson({ a: 1 })); + const result = await fetchJsonWithFallback('u', 'r', 'develop'); + expect(result.branch).toBe('develop'); + expect(result.data).toEqual({ a: 1 }); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('falls back from main to master when main returns 404', async () => { + mockFetch + .mockResolvedValueOnce(notFound) + .mockResolvedValueOnce(okJson({ b: 2 })); + const result = await fetchJsonWithFallback('u', 'r', 'main'); + expect(result.branch).toBe('master'); + expect(result.data).toEqual({ b: 2 }); + }); + + it('only falls back when the default branch was requested', async () => { + mockFetch.mockResolvedValue(notFound); + await expect(fetchJsonWithFallback('u', 'r', 'feature-x')).rejects.toThrow(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('throws when both main and master fail', async () => { + mockFetch.mockResolvedValue(notFound); + await expect(fetchJsonWithFallback('u', 'r', 'main')).rejects.toThrow('Repository not found'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); + +describe('fetchReport (JSON + template with fallback)', () => { + let mockFetch; + beforeEach(() => { mockFetch = spyOn(global, 'fetch'); }); + afterEach(() => mockFetch.mockRestore()); + + it('returns data, template and the resolved branch', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ x: 1 }) }) + .mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('

{{name}}

') }); + + const report = await fetchReport('u', 'r', 'main'); + expect(report.data).toEqual({ x: 1 }); + expect(report.template).toBe('

{{name}}

'); + expect(report.branch).toBe('main'); + }); + + it('uses the fallback template when the repo has none', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ x: 1 }) }) + .mockResolvedValueOnce({ ok: false, status: 404 }); + + const report = await fetchReport('u', 'r', 'main', { fallbackTemplate: 'default' }); + expect(report.template).toBe('default'); + }); +}); + +describe('fetchWithRetry (exponential backoff)', () => { + let mockFetch; + beforeEach(() => { mockFetch = spyOn(global, 'fetch'); }); + afterEach(() => mockFetch.mockRestore()); + + it('returns the first successful response', async () => { + mockFetch.mockResolvedValue({ ok: true }); + const response = await fetchWithRetry('https://example.com'); + expect(response.ok).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('retries transient failures then succeeds', async () => { + mockFetch + .mockRejectedValueOnce(new Error('network down')) + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce({ ok: true }); + + const response = await fetchWithRetry('https://example.com', {}, { retries: 3, baseDelayMs: 1 }); + expect(response.ok).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('gives up after exhausting retries', async () => { + mockFetch.mockRejectedValue(new Error('network down')); + await expect( + fetchWithRetry('https://example.com', {}, { retries: 2, baseDelayMs: 1 }) + ).rejects.toThrow('network down'); + expect(mockFetch).toHaveBeenCalledTimes(3); // initial + 2 retries + }); + + it('does not retry on 4xx client errors', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 }); + const response = await fetchWithRetry('https://example.com', {}, { retries: 3, baseDelayMs: 1 }); + expect(response.status).toBe(404); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/fetcher.test.js b/tests/unit/fetcher.test.js new file mode 100644 index 0000000..4d73750 --- /dev/null +++ b/tests/unit/fetcher.test.js @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { fetchJson, fetchTemplate, constructRawUrl, constructTemplateUrl } from '../../js/fetcher.js'; + +describe('GitHub API Fetching', () => { + let mockFetch; + + beforeEach(() => { + mockFetch = spyOn(global, 'fetch'); + }); + + afterEach(() => { + mockFetch.mockRestore(); + }); + + it('should fetch JSON data successfully', async () => { + const mockData = { name: 'test-repo', metrics: { classes: 100 } }; + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockData) + }); + + const result = await fetchJson('user', 'repo', 'main'); + expect(result).toEqual(mockData); + expect(mockFetch).toHaveBeenCalledWith( + 'https://raw.githubusercontent.com/user/repo/main/.refactorfirst/refactor-first.json', + expect.any(Object) + ); + }); + + it('should handle 404 errors', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404 + }); + + await expect(fetchJson('user', 'repo', 'main')).rejects.toThrow('Repository not found'); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect(fetchJson('user', 'repo', 'main')).rejects.toThrow('Network error'); + }); + + it('should fetch Mustache template successfully', async () => { + const mockTemplate = '{{content}}'; + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockTemplate) + }); + + const result = await fetchTemplate('user', 'repo', 'main'); + expect(result).toBe(mockTemplate); + expect(mockFetch).toHaveBeenCalledWith( + 'https://raw.githubusercontent.com/user/repo/main/.refactorfirst/refactor-first-report.mustache', + expect.any(Object) + ); + }); + + it('should return fallback template when template fetch fails', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404 + }); + + const fallbackTemplate = 'fallback'; + const result = await fetchTemplate('user', 'repo', 'main', fallbackTemplate); + expect(result).toBe(fallbackTemplate); + }); + + it('should construct correct raw URL', () => { + const url = constructRawUrl('user', 'repo', 'branch'); + expect(url).toBe('https://raw.githubusercontent.com/user/repo/branch/.refactorfirst/refactor-first.json'); + }); + + it('should construct correct template URL', () => { + const url = constructTemplateUrl('user', 'repo', 'branch'); + expect(url).toBe('https://raw.githubusercontent.com/user/repo/branch/.refactorfirst/refactor-first-report.mustache'); + }); + + it('should include headers in fetch requests', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}) + }); + + await fetchJson('user', 'repo', 'main'); + const callArgs = mockFetch.mock.calls[0]; + expect(callArgs[1]).toEqual(expect.objectContaining({ + headers: expect.objectContaining({ + 'Accept': 'application/json' + }) + })); + }); +}); \ No newline at end of file diff --git a/tests/unit/oauth-handler.test.js b/tests/unit/oauth-handler.test.js new file mode 100644 index 0000000..d229d2f --- /dev/null +++ b/tests/unit/oauth-handler.test.js @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { + generateState, + generatePKCE, + buildAuthorizationUrl, + parseCallback, + exchangeCodeForToken, + storeToken, + getToken, + clearSession, + isAuthenticated, + fetchUserProfile, + logout +} from '../../js/oauth-handler.js'; + +const CLIENT_CONFIG = { + clientId: 'test-client-id', + redirectUri: 'https://refactorfirst.github.io/add-repo/callback', + scopes: ['public_repo', 'read:user'] +}; + +describe('generateState (CSRF protection)', () => { + it('generates a random URL-safe state value', () => { + const state = generateState(); + expect(state).toMatch(/^[A-Za-z0-9\-_]{16,}$/); + }); + + it('generates unique states', () => { + expect(generateState()).not.toBe(generateState()); + }); +}); + +describe('generatePKCE', () => { + it('generates a code verifier and S256 challenge', async () => { + const { codeVerifier, codeChallenge } = await generatePKCE(); + expect(codeVerifier).toMatch(/^[A-Za-z0-9\-._~]{43,128}$/); + expect(codeChallenge).toMatch(/^[A-Za-z0-9\-_]{43}$/); + }); + + it('derives different challenges for different verifiers', async () => { + const a = await generatePKCE(); + const b = await generatePKCE(); + expect(a.codeVerifier).not.toBe(b.codeVerifier); + expect(a.codeChallenge).not.toBe(b.codeChallenge); + }); +}); + +describe('buildAuthorizationUrl', () => { + beforeEach(() => sessionStorage.clear()); + + it('builds a GitHub authorization URL with PKCE parameters', async () => { + const url = new URL(await buildAuthorizationUrl(CLIENT_CONFIG)); + expect(url.origin + url.pathname).toBe('https://github.com/login/oauth/authorize'); + expect(url.searchParams.get('client_id')).toBe('test-client-id'); + expect(url.searchParams.get('redirect_uri')).toBe(CLIENT_CONFIG.redirectUri); + expect(url.searchParams.get('scope')).toBe('public_repo read:user'); + expect(url.searchParams.get('state')).toBeTruthy(); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('code_challenge')).toBeTruthy(); + }); + + it('persists state and code verifier in sessionStorage', async () => { + const url = new URL(await buildAuthorizationUrl(CLIENT_CONFIG)); + expect(sessionStorage.getItem('oauth_state')).toBe(url.searchParams.get('state')); + expect(sessionStorage.getItem('oauth_code_verifier')).toBeTruthy(); + }); +}); + +describe('parseCallback', () => { + beforeEach(() => sessionStorage.clear()); + + it('returns the code when state matches', () => { + sessionStorage.setItem('oauth_state', 'expected-state'); + const result = parseCallback('?code=abc123&state=expected-state'); + expect(result.code).toBe('abc123'); + }); + + it('throws when the state does not match', () => { + sessionStorage.setItem('oauth_state', 'expected-state'); + expect(() => parseCallback('?code=abc&state=attacker-state')) + .toThrow('OAuth state mismatch'); + }); + + it('throws when the user denied access', () => { + sessionStorage.setItem('oauth_state', 's'); + expect(() => parseCallback('?error=access_denied&state=s')) + .toThrow('access_denied'); + }); + + it('throws when no code is present', () => { + sessionStorage.setItem('oauth_state', 's'); + expect(() => parseCallback('?state=s')).toThrow(); + }); +}); + +describe('token management', () => { + beforeEach(() => sessionStorage.clear()); + + it('stores, retrieves and clears the token in sessionStorage', () => { + expect(isAuthenticated()).toBe(false); + storeToken('token-xyz'); + expect(getToken()).toBe('token-xyz'); + expect(isAuthenticated()).toBe(true); + clearSession(); + expect(getToken()).toBeNull(); + expect(isAuthenticated()).toBe(false); + }); +}); + +describe('exchangeCodeForToken', () => { + let mockFetch; + beforeEach(() => { + sessionStorage.clear(); + mockFetch = spyOn(global, 'fetch'); + }); + afterEach(() => mockFetch.mockRestore()); + + it('exchanges the code for an access token and stores it', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ access_token: 'gho_token', token_type: 'bearer' }) + }); + + const token = await exchangeCodeForToken({ + code: 'abc', + codeVerifier: 'verifier', + clientId: 'test-client-id', + redirectUri: CLIENT_CONFIG.redirectUri + }); + + expect(token).toBe('gho_token'); + expect(getToken()).toBe('gho_token'); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://github.com/login/oauth/access_token'); + expect(options.method).toBe('POST'); + expect(options.headers.Accept).toBe('application/json'); + expect(options.body).toContain('code=abc'); + }); + + it('throws when the exchange fails', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 403, statusText: 'Forbidden' }); + await expect(exchangeCodeForToken({ + code: 'abc', codeVerifier: 'v', clientId: 'c', redirectUri: 'r' + })).rejects.toThrow(); + }); + + it('throws when GitHub returns an OAuth error payload', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ error: 'bad_verification_code' }) + }); + await expect(exchangeCodeForToken({ + code: 'abc', codeVerifier: 'v', clientId: 'c', redirectUri: 'r' + })).rejects.toThrow('bad_verification_code'); + }); +}); + +describe('fetchUserProfile', () => { + let mockFetch; + beforeEach(() => { + sessionStorage.clear(); + mockFetch = spyOn(global, 'fetch'); + }); + afterEach(() => mockFetch.mockRestore()); + + it('fetches the authenticated user profile', async () => { + storeToken('valid-token'); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ login: 'octocat', avatar_url: 'https://avatars.githubusercontent.com/u/1' }) + }); + + const profile = await fetchUserProfile(); + expect(profile.username).toBe('octocat'); + expect(profile.avatarUrl).toBe('https://avatars.githubusercontent.com/u/1'); + expect(mockFetch.mock.calls[0][0]).toBe('https://api.github.com/user'); + expect(mockFetch.mock.calls[0][1].headers.Authorization).toBe('Bearer valid-token'); + }); + + it('throws when not authenticated', async () => { + await expect(fetchUserProfile()).rejects.toThrow('Not authenticated'); + }); +}); + +describe('logout', () => { + it('clears the token and all OAuth session data', () => { + storeToken('token'); + sessionStorage.setItem('oauth_state', 's'); + sessionStorage.setItem('oauth_code_verifier', 'v'); + logout(); + expect(isAuthenticated()).toBe(false); + expect(sessionStorage.getItem('oauth_state')).toBeNull(); + expect(sessionStorage.getItem('oauth_code_verifier')).toBeNull(); + }); +}); diff --git a/tests/unit/rate-limiter.test.js b/tests/unit/rate-limiter.test.js new file mode 100644 index 0000000..8b5ef9d --- /dev/null +++ b/tests/unit/rate-limiter.test.js @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { ApiRateLimiter, SubmissionRateLimiter } from '../../js/rate-limiter.js'; + +describe('ApiRateLimiter (GitHub rate limit headers)', () => { + let limiter; + beforeEach(() => { limiter = new ApiRateLimiter(); }); + + it('allows requests when nothing is known about limits', () => { + expect(limiter.canMakeRequest()).toBe(true); + }); + + it('parses rate limit headers from a response', () => { + limiter.recordResponse({ remaining: 42, resetEpochSeconds: 1893456000 }); + expect(limiter.remaining()).toBe(42); + expect(limiter.resetTime()).toBe(1893456000); + expect(limiter.canMakeRequest()).toBe(true); + }); + + it('blocks requests when the limit is exhausted and reset is in the future', () => { + limiter.recordResponse({ remaining: 0, resetEpochSeconds: Date.now() / 1000 + 600 }); + expect(limiter.canMakeRequest()).toBe(false); + }); + + it('allows requests again once the reset time has passed', () => { + limiter.recordResponse({ remaining: 0, resetEpochSeconds: Date.now() / 1000 - 10 }); + expect(limiter.canMakeRequest()).toBe(true); + }); + + it('reports seconds until reset', () => { + limiter.recordResponse({ remaining: 0, resetEpochSeconds: Date.now() / 1000 + 90 }); + expect(limiter.secondsUntilReset()).toBeGreaterThan(0); + expect(limiter.secondsUntilReset()).toBeLessThanOrEqual(90); + }); +}); + +describe('SubmissionRateLimiter (abuse prevention, 5/hour per user)', () => { + let limiter; + beforeEach(() => { limiter = new SubmissionRateLimiter({ maxPerWindow: 5, windowMs: 3600000 }); }); + + it('allows up to the max submissions per window', () => { + for (let i = 0; i < 5; i++) { + expect(limiter.tryAcquire('alice')).toBe(true); + } + expect(limiter.tryAcquire('alice')).toBe(false); + }); + + it('tracks users independently', () => { + for (let i = 0; i < 5; i++) limiter.tryAcquire('alice'); + expect(limiter.tryAcquire('bob')).toBe(true); + }); + + it('reports remaining submissions', () => { + limiter.tryAcquire('alice'); + limiter.tryAcquire('alice'); + expect(limiter.remainingFor('alice')).toBe(3); + }); + + it('frees capacity after the window expires', () => { + const shortLimiter = new SubmissionRateLimiter({ maxPerWindow: 1, windowMs: 10 }); + expect(shortLimiter.tryAcquire('alice')).toBe(true); + expect(shortLimiter.tryAcquire('alice')).toBe(false); + return new Promise(resolve => setTimeout(() => { + expect(shortLimiter.tryAcquire('alice')).toBe(true); + resolve(); + }, 20)); + }); +}); diff --git a/tests/unit/renderer.test.js b/tests/unit/renderer.test.js new file mode 100644 index 0000000..d1efc25 --- /dev/null +++ b/tests/unit/renderer.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'bun:test'; +import { renderTemplate, initializeMustache } from '../../js/renderer.js'; + +describe('Mustache Rendering', () => { + it('should render template with data', () => { + const template = 'Hello {{name}}!'; + const data = { name: 'World' }; + const result = renderTemplate(template, data); + expect(result).toBe('Hello World!'); + }) + + it('should handle nested objects', () => { + const template = '{{user.name}} - {{user.email}}'; + const data = { user: { name: 'John', email: 'john@example.com' } }; + const result = renderTemplate(template, data); + expect(result).toBe('John - john@example.com'); + }) + + it('should handle arrays', () => { + const template = '{{#items}}{{name}}{{/items}}'; + const data = { items: [{ name: 'a' }, { name: 'b' }] }; + const result = renderTemplate(template, data); + expect(result).toBe('ab'); + }) + + it('should handle conditional sections', () => { + const template = '{{#show}}visible{{/show}}{{^show}}hidden{{/show}}'; + const result1 = renderTemplate(template, { show: true }); + const result2 = renderTemplate(template, { show: false }); + expect(result1).toBe('visible'); + expect(result2).toBe('hidden'); + }) + + it('should escape HTML by default', () => { + const template = '{{content}}'; + const data = { content: '' }; + const result = renderTemplate(template, data); + expect(result).toBe('<script>alert(1)</script>'); + }) + + it('should not escape with triple braces', () => { + const template = '{{{content}}}'; + const data = { content: 'bold' }; + const result = renderTemplate(template, data); + expect(result).toBe('bold'); + }) + + it('should handle empty data', () => { + const template = '{{name}}'; + const result = renderTemplate(template, {}); + expect(result).toBe(''); + }) + + it('should handle null/undefined values', () => { + const template = '{{value}}'; + expect(renderTemplate(template, { value: null })).toBe(''); + expect(renderTemplate(template, { value: undefined })).toBe(''); + }) + + it('initializeMustache should return Mustache instance', () => { + const mustache = initializeMustache(); + expect(mustache).toBeDefined(); + expect(typeof mustache.render).toBe('function'); + }) +}) diff --git a/tests/unit/repo-submission.test.js b/tests/unit/repo-submission.test.js new file mode 100644 index 0000000..5772932 --- /dev/null +++ b/tests/unit/repo-submission.test.js @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { + validateRepositoryInput, + checkRepositoryAccess, + checkReportExists, + triggerAddRepositoryWorkflow, + submitRepository, + REPORT_MISSING_MESSAGE +} from '../../js/repo-submission.js'; + +describe('validateRepositoryInput', () => { + it('accepts valid owner and repository names', () => { + const result = validateRepositoryInput('octocat', 'hello-world'); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('requires both fields', () => { + const result = validateRepositoryInput('', ''); + expect(result.valid).toBe(false); + expect(result.errors.length).toBe(2); + }); + + it('rejects special characters', () => { + const result = validateRepositoryInput('bad name', ''); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('owner'))).toBe(true); + expect(result.errors.some(e => e.includes('repository'))).toBe(true); + }); +}); + +describe('checkRepositoryAccess', () => { + let mockFetch; + beforeEach(() => { mockFetch = spyOn(global, 'fetch'); }); + afterEach(() => mockFetch.mockRestore()); + + it('returns granted when the user is a collaborator with write access', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ permission: 'admin' }) + }); + + const result = await checkRepositoryAccess('owner', 'repo', 'user', 'token'); + expect(result.granted).toBe(true); + expect(mockFetch.mock.calls[0][0]).toBe('https://api.github.com/repos/owner/repo/collaborators/user'); + expect(mockFetch.mock.calls[0][1].headers.Authorization).toBe('Bearer token'); + }); + + it('denies access when the collaborator check returns 404', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 }); + const result = await checkRepositoryAccess('owner', 'repo', 'user', 'token'); + expect(result.granted).toBe(false); + expect(result.reason).toContain('access'); + }); + + it('denies access when the user has only read permission', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ permission: 'read' }) + }); + const result = await checkRepositoryAccess('owner', 'repo', 'user', 'token'); + expect(result.granted).toBe(false); + }); +}); + +describe('checkReportExists', () => { + let mockFetch; + beforeEach(() => { mockFetch = spyOn(global, 'fetch'); }); + afterEach(() => mockFetch.mockRestore()); + + const rawMain = 'https://raw.githubusercontent.com/owner/repo/main/.refactorfirst/refactor-first.json'; + const rawDefault = 'https://raw.githubusercontent.com/owner/repo/develop/.refactorfirst/refactor-first.json'; + + function mockRepoInfo(defaultBranch = 'develop') { + return { + ok: true, + json: () => Promise.resolve({ default_branch: defaultBranch }) + }; + } + + it('finds the report on the main branch', async () => { + mockFetch.mockImplementation(url => { + if (url === 'https://api.github.com/repos/owner/repo') { + return Promise.resolve(mockRepoInfo()); + } + if (url === rawMain) return Promise.resolve({ ok: true }); + return Promise.resolve({ ok: false, status: 404 }); + }); + + const result = await checkReportExists('owner', 'repo', 'token'); + expect(result.exists).toBe(true); + expect(result.branch).toBe('main'); + }); + + it('falls back to the default branch when main returns 404', async () => { + mockFetch.mockImplementation(url => { + if (url === 'https://api.github.com/repos/owner/repo') { + return Promise.resolve(mockRepoInfo('develop')); + } + if (url === rawMain) return Promise.resolve({ ok: false, status: 404 }); + if (url === rawDefault) return Promise.resolve({ ok: true }); + return Promise.resolve({ ok: false, status: 404 }); + }); + + const result = await checkReportExists('owner', 'repo', 'token'); + expect(result.exists).toBe(true); + expect(result.branch).toBe('develop'); + expect(mockFetch.mock.calls.some(c => c[0] === rawMain)).toBe(true); + expect(mockFetch.mock.calls.some(c => c[0] === rawDefault)).toBe(true); + }); + + it('reports missing when neither main nor the default branch has the file', async () => { + mockFetch.mockImplementation(url => { + if (url === 'https://api.github.com/repos/owner/repo') { + return Promise.resolve(mockRepoInfo('develop')); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + const result = await checkReportExists('owner', 'repo', 'token'); + expect(result.exists).toBe(false); + expect(result.message).toBe('The repository specified must have a .refactorfirst/refactor-first.json file present.'); + expect(REPORT_MISSING_MESSAGE).toContain('refactor-first.json'); + }); + + it('does not check the default branch twice when it is main', async () => { + mockFetch.mockImplementation(url => { + if (url === 'https://api.github.com/repos/owner/repo') { + return Promise.resolve(mockRepoInfo('main')); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + const result = await checkReportExists('owner', 'repo', 'token'); + expect(result.exists).toBe(false); + expect(mockFetch.mock.calls.filter(c => c[0] === rawMain).length).toBe(1); + }); + + it('reports missing when the repository info call fails', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 }); + const result = await checkReportExists('owner', 'repo', 'token'); + expect(result.exists).toBe(false); + expect(result.message).toContain('Repository not found'); + }); + + it('passes the auth token to the GitHub API call', async () => { + mockFetch.mockImplementation(url => { + if (url === 'https://api.github.com/repos/owner/repo') { + return Promise.resolve(mockRepoInfo()); + } + return Promise.resolve({ ok: true }); + }); + await checkReportExists('owner', 'repo', 'secret-token'); + const call = mockFetch.mock.calls.find(c => c[0] === 'https://api.github.com/repos/owner/repo'); + expect(call[1].headers.Authorization).toBe('Bearer secret-token'); + }); +}); + +describe('triggerAddRepositoryWorkflow', () => { + let mockFetch; + beforeEach(() => { mockFetch = spyOn(global, 'fetch'); }); + afterEach(() => mockFetch.mockRestore()); + + it('sends a repository_dispatch event to the listing repository', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + await triggerAddRepositoryWorkflow({ + owner: 'octocat', repo: 'hello-world', submittedBy: 'octocat', + token: 'token', dispatchRepo: 'refactorfirst/refactorfirst.github.io' + }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.github.com/repos/refactorfirst/refactorfirst.github.io/dispatches'); + expect(options.method).toBe('POST'); + const body = JSON.parse(options.body); + expect(body.event_type).toBe('add-repository'); + expect(body.client_payload).toEqual({ + owner: 'octocat', repo: 'hello-world', submitted_by: 'octocat' + }); + }); + + it('throws a friendly error when the dispatch is unauthorized', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 403 }); + await expect(triggerAddRepositoryWorkflow({ + owner: 'o', repo: 'r', submittedBy: 'u', token: 't', + dispatchRepo: 'refactorfirst/refactorfirst.github.io' + })).rejects.toThrow('403'); + }); +}); + +describe('submitRepository (orchestration)', () => { + let mockFetch; + beforeEach(() => { mockFetch = spyOn(global, 'fetch'); }); + afterEach(() => mockFetch.mockRestore()); + + it('validates input before making API calls', async () => { + const result = await submitRepository({ owner: '', repo: '' }, 'user', 'token'); + expect(result.success).toBe(false); + expect(result.message).toContain('required'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + function mockHappyPath() { + mockFetch.mockImplementation(url => { + if (url.includes('/collaborators/')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ permission: 'write' }) }); + } + if (url === 'https://api.github.com/repos/o/r') { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ default_branch: 'main' }) }); + } + if (url.includes('raw.githubusercontent.com/o/r/main/')) { + return Promise.resolve({ ok: true }); + } + if (url.endsWith('/dispatches')) { + return Promise.resolve({ ok: true, status: 204 }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + } + + it('checks access then triggers the workflow on success', async () => { + mockHappyPath(); + const result = await submitRepository({ owner: 'o', repo: 'r' }, 'user', 'token'); + expect(result.success).toBe(true); + expect(mockFetch.mock.calls.some(c => c[0].endsWith('/dispatches'))).toBe(true); + }); + + it('rejects repositories without .refactorfirst/refactor-first.json', async () => { + mockFetch.mockImplementation(url => { + if (url.includes('/collaborators/')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ permission: 'write' }) }); + } + if (url === 'https://api.github.com/repos/o/r') { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ default_branch: 'develop' }) }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + const result = await submitRepository({ owner: 'o', repo: 'r' }, 'user', 'token'); + expect(result.success).toBe(false); + expect(result.message).toBe('The repository specified must have a .refactorfirst/refactor-first.json file present.'); + expect(mockFetch.mock.calls.some(c => c[0].endsWith('/dispatches'))).toBe(false); + }); + + it('reports access failures without triggering the workflow', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + const result = await submitRepository({ owner: 'o', repo: 'r' }, 'user', 'token'); + expect(result.success).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/router-ext.test.js b/tests/unit/router-ext.test.js new file mode 100644 index 0000000..9ed536c --- /dev/null +++ b/tests/unit/router-ext.test.js @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { + isValidGitHubName, + classifyRoute, + buildReportUrl, + buildRepositoryListUrl, + getQueryParam, + navigateTo +} from '../../js/router.js'; + +describe('isValidGitHubName', () => { + it('should accept valid GitHub owner/repo names', () => { + expect(isValidGitHubName('refactorfirst')).toBe(true); + expect(isValidGitHubName('spring-projects')).toBe(true); + expect(isValidGitHubName('repo.name_1')).toBe(true); + expect(isValidGitHubName('a')).toBe(true); + }); + + it('should reject empty or missing names', () => { + expect(isValidGitHubName('')).toBe(false); + expect(isValidGitHubName(null)).toBe(false); + expect(isValidGitHubName(undefined)).toBe(false); + }); + + it('should reject names with dangerous characters', () => { + expect(isValidGitHubName(' + + + + + diff --git a/js/fetcher.js b/js/fetcher.js index a70914e..b093f84 100644 --- a/js/fetcher.js +++ b/js/fetcher.js @@ -3,7 +3,6 @@ // GitLab or Bitbucket); URL construction depends on that environment. const REPORT_PATH = '.refactorfirst/refactor-first.json'; -const TEMPLATE_PATH = '.refactorfirst/refactor-first-report.mustache'; const PLATFORM_BUILDERS = { github: { @@ -32,11 +31,6 @@ export function constructRawUrl(username, repository, branch, options = {}) { return buildRaw(username, repository, branch, REPORT_PATH, base); } -export function constructTemplateUrl(username, repository, branch, options = {}) { - const { buildRaw, base } = platformConfig(options); - return buildRaw(username, repository, branch, TEMPLATE_PATH, base); -} - export async function fetchJson(username, repository, branch, options = {}) { const url = constructRawUrl(username, repository, branch, options); const response = await fetch(url, { @@ -58,25 +52,6 @@ export async function fetchJson(username, repository, branch, options = {}) { return response.json(); } -export async function fetchTemplate(username, repository, branch, fallbackTemplate = null, options = {}) { - const url = constructTemplateUrl(username, repository, branch, options); - try { - const response = await fetch(url, { - headers: { - 'Accept': 'text/plain' - } - }); - if (!response.ok) { - if (fallbackTemplate) return fallbackTemplate; - throw new Error(`Failed to fetch template: ${response.status} ${response.statusText}`); - } - return await response.text(); - } catch (error) { - if (fallbackTemplate) return fallbackTemplate; - throw error; - } -} - // Fetch with retry + exponential backoff for transient network/server errors. // 4xx responses are returned immediately - retrying them is pointless. export async function fetchWithRetry(url, options = {}, { retries = 3, baseDelayMs = 250 } = {}) { @@ -118,14 +93,12 @@ function getDefaultBranchName() { return 'main'; } -// Fetch both the report JSON and the Mustache template for a repository, -// applying branch fallback logic and the bundled fallback template. -export async function fetchReport(username, repository, branch = 'main', - { fallbackTemplate = null, environment, baseUrl } = {}) { - const options = { environment, baseUrl }; +// Fetch the report JSON for a repository, applying branch fallback logic. +// The Mustache template is NOT fetched from the repository: templates are +// untrusted content, so the app always renders with its own bundled +// assets/refactor-first-report.mustache. Repositories only supply data. +export async function fetchReport(username, repository, branch = 'main', options = {}) { const { data, branch: resolvedBranch } = await fetchJsonWithFallback(username, repository, branch, options); - const template = - await fetchTemplate(username, repository, resolvedBranch, fallbackTemplate, options); - return { data, template, branch: resolvedBranch }; + return { data, branch: resolvedBranch }; } diff --git a/js/main.js b/js/main.js index 3cbf203..d7495e1 100644 --- a/js/main.js +++ b/js/main.js @@ -18,6 +18,7 @@ import { } from './utils.js'; import { fetchReport } from './fetcher.js'; import { renderTemplate } from './renderer.js'; +import { enhanceReport } from './report-view.js'; import { renderErrorPage, logError } from './error-handler.js'; import { submitRepository, @@ -151,17 +152,24 @@ export function createApp({ root, onNavigate, onExternalRedirect, hostEnvironmen async function renderReport({ username, repository, branch }) { root.innerHTML = '

Loading report…

'; try { - const fallbackTemplate = await fetch('/assets/refactor-first-report.mustache') + // The bundled template is authoritative — repository-provided templates + // are untrusted and intentionally never fetched. + const template = await fetch('/assets/refactor-first-report.mustache') .then(res => (res.ok ? res.text() : null)) .catch(() => null); - const { data, template, branch: resolvedBranch } = + if (!template) { + const error = new Error('Report template could not be loaded'); + error.status = 500; + throw error; + } + const { data, branch: resolvedBranch } = await fetchReport(username, repository, branch, { - fallbackTemplate, environment, baseUrl: getPlatformBaseUrl() }); root.innerHTML = renderTemplate(template, data); root.dataset.resolvedBranch = resolvedBranch; + track(enhanceReport(root, data)); } catch (error) { logError(error, { route: 'report', username, repository, branch }); renderErrorPage(root, error, { onRetry: () => track(renderReport({ username, repository, branch })) }); diff --git a/js/renderer.js b/js/renderer.js index 06db080..d523d24 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -1,4 +1,5 @@ -import Mustache from 'mustache'; +import Mustache from '../assets/vendor/mustache.mjs'; +import DOMPurify from '../assets/vendor/purify.es.mjs'; let mustacheInstance = null; @@ -9,7 +10,21 @@ export function initializeMustache() { return mustacheInstance; } +// Report JSON data and Mustache templates come from the target repository +// (untrusted third-party content). Escape via Mustache by default and +// sanitize the final HTML with DOMPurify so template-supplied '; + await page.unroute('**/raw.githubusercontent.com/**'); + await page.route('**/raw.githubusercontent.com/**', route => { + const url = route.request().url(); + if (url.endsWith('refactor-first.json')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(poisoned) }); + } else { + route.fulfill({ status: 404 }); + } + }); + + await page.goto('/refactorfirst/refactorfirst'); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { name: 'God Classes', level: 1 })).toBeVisible(); + await expect(page.locator('img[onerror]')).toHaveCount(0); + expect(await page.evaluate(() => window.__xss)).toBeUndefined(); + expect(await page.evaluate(() => window.__xss2)).toBeUndefined(); +}); + test('top menu height stays within 140px', async ({ page }) => { await page.goto('/'); await page.waitForLoadState('networkidle'); diff --git a/tests/fixtures/sample-mustache-template.mustache b/tests/fixtures/sample-mustache-template.mustache deleted file mode 100644 index e5d05bf..0000000 --- a/tests/fixtures/sample-mustache-template.mustache +++ /dev/null @@ -1,9 +0,0 @@ -

RefactorFirst Report: {{projectName}}

-

Version: {{version}}

-

Classes analyzed: {{totalClasses}}

-

Classes to refactor: {{classesToRefactor}}

-
    -{{#priorities}} -
  • {{rank}}. {{className}} ({{priority}}) - {{recommendation}}
  • -{{/priorities}} -
diff --git a/tests/fixtures/sample-refactor-first.json b/tests/fixtures/sample-refactor-first.json deleted file mode 100644 index eceb896..0000000 --- a/tests/fixtures/sample-refactor-first.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "projectName": "refactorfirst", - "version": "0.5.1", - "generatedAt": "2024-01-15T10:30:00Z", - "totalClasses": 150, - "classesToRefactor": 12, - "priorities": [ - { - "rank": 1, - "className": "org.hjug.git.GitLogReader", - "priority": "HIGH", - "effort": "3", - "disharmonies": ["God Class", "Brain Method"], - "recommendation": "Break this class into smaller, focused classes." - }, - { - "rank": 2, - "className": "org.hjug.cbc.CostBenefitCalculator", - "priority": "MEDIUM", - "effort": "2", - "disharmonies": ["Feature Envy"], - "recommendation": "Move methods closer to the data they use." - } - ], - "metrics": { - "averageEffort": 2.5, - "highestPriorityCount": 3, - "mediumPriorityCount": 5, - "lowPriorityCount": 4 - } -} diff --git a/tests/integration/report-rendering.test.js b/tests/integration/report-rendering.test.js index 1854384..b340149 100644 --- a/tests/integration/report-rendering.test.js +++ b/tests/integration/report-rendering.test.js @@ -3,11 +3,14 @@ import { createApp } from '../../js/main.js'; import fs from 'node:fs'; import path from 'node:path'; +// The full JUnit4 report published by RefactorFirst — the real data shape. const sampleJson = JSON.parse( - fs.readFileSync(path.join(import.meta.dir, '../fixtures/sample-refactor-first.json'), 'utf8') + fs.readFileSync(path.join(import.meta.dir, '../fixtures/junit4-report.json'), 'utf8') ); -const sampleTemplate = fs.readFileSync( - path.join(import.meta.dir, '../fixtures/sample-mustache-template.mustache'), 'utf8' +// The bundled fallback template is the same template the RefactorFirst +// report viewer uses; tests render the real thing. +const reportTemplate = fs.readFileSync( + path.join(import.meta.dir, '../../assets/refactor-first-report.mustache'), 'utf8' ); describe('Report rendering integration', () => { @@ -21,26 +24,81 @@ describe('Report rendering integration', () => { afterEach(() => mockFetch.mockRestore()); - function respondJsonFor(url, data, { template = sampleTemplate } = {}) { + function respondJsonFor(data, { remoteTemplate = '
remote-template
' } = {}) { mockFetch.mockImplementation(requested => { + // The bundled template is authoritative — always serve the real asset. + if (requested.endsWith('assets/refactor-first-report.mustache')) { + return Promise.resolve({ ok: true, text: () => Promise.resolve(reportTemplate) }); + } if (requested.endsWith('.refactorfirst/refactor-first.json')) { return Promise.resolve({ ok: true, json: () => Promise.resolve(data) }); } + // Any repository-provided template request answers with a marker that + // must never reach the DOM. if (requested.endsWith('.refactorfirst/refactor-first-report.mustache')) { - return Promise.resolve({ ok: true, text: () => Promise.resolve(template) }); + return Promise.resolve({ ok: true, text: () => Promise.resolve(remoteTemplate) }); } - void url; return Promise.resolve({ ok: false, status: 404 }); }); } - it('renders the report for /user/repo', async () => { - respondJsonFor(null, sampleJson); + async function renderSampleReport(route = '/junit-team/junit4') { + respondJsonFor(sampleJson); app = createApp({ root }); - history.replaceState(null, '', '/refactorfirst/refactorfirst'); + history.replaceState(null, '', route); await app.handleRoute(); - expect(root.innerHTML).toContain('refactorfirst'); - expect(root.querySelector('h1').textContent).toContain('refactorfirst'); + await app.pendingSubmissions(); + } + + it('renders all report sections for /user/repo', async () => { + await renderSampleReport(); + + expect(root.querySelector('h1').textContent).toContain('RefactorFirst'); + expect(root.textContent).toContain('JUnit 4.13.3-SNAPSHOT'); + // Class map + counts + expect(root.textContent).toContain('Number of classes: 335'); + expect(root.querySelector('#classGraph')).not.toBeNull(); + // Class/package relationships-to-remove tables + expect(root.textContent).toContain('Class Relationship Removal Priority'); + expect(root.textContent).toContain('Package Relationship Removal Priority'); + // Disharmonies with chart canvases and tables + expect(root.textContent).toContain('God Classes'); + expect(root.querySelector('canvas#chart_GOD')).not.toBeNull(); + expect(root.textContent).toContain('Change Proneness Rank'); + // Cycles + expect(root.textContent).toContain('Largest Class Cycle'); + // Footer timestamp + expect(root.textContent).toContain('Last Published:'); + }); + + it('runs enhanceReport: dots exposed and popup globals wired', async () => { + await renderSampleReport(); + expect(window.classGraph_dot).toContain('strict digraph'); + expect(window.packageGraph_dot).toContain('strict digraph'); + expect(typeof window.showPopup).toBe('function'); + expect(typeof window.createForceGraph).toBe('function'); + expect(typeof window.hidePopup).toBe('function'); + }); + + it('shows the analysis-incomplete alert when project.analysisFailed is set', async () => { + const failedReport = JSON.parse(JSON.stringify(sampleJson)); + failedReport.project.analysisFailed = true; + respondJsonFor(failedReport); + app = createApp({ root }); + history.replaceState(null, '', '/junit-team/junit4'); + await app.handleRoute(); + await app.pendingSubmissions(); + expect(root.textContent).toContain('Analysis incomplete'); + expect(root.querySelector('[role="alert"]')).not.toBeNull(); + }); + + it('escapes renderedLabel in class relationships (upstream escaping hardening)', async () => { + await renderSampleReport(); + // The report data carries HTML in renderedLabel; the template must escape it. + const table = root.querySelector('.rf-report'); + const raw = root.innerHTML; + expect(raw).toContain('<a href=https://github.com/junit-team/junit4'); + expect(table).not.toBeNull(); }); it('falls back from main to master', async () => { @@ -51,22 +109,28 @@ describe('Report rendering integration', () => { if (requested.includes('/master/.refactorfirst/refactor-first.json')) { return Promise.resolve({ ok: true, json: () => Promise.resolve(sampleJson) }); } - // The template must be fetched from the resolved branch (master), not main. - if (requested.includes('/master/.refactorfirst/refactor-first-report.mustache')) { - return Promise.resolve({ ok: true, text: () => Promise.resolve(sampleTemplate) }); + // The bundled template is always used; only JSON branch fallback matters. + if (requested.endsWith('assets/refactor-first-report.mustache')) { + return Promise.resolve({ ok: true, text: () => Promise.resolve(reportTemplate) }); } return Promise.resolve({ ok: false, status: 404 }); }); app = createApp({ root }); - history.replaceState(null, '', '/refactorfirst/refactorfirst'); + history.replaceState(null, '', '/junit-team/junit4'); await app.handleRoute(); - expect(root.innerHTML).toContain('refactorfirst'); + await app.pendingSubmissions(); + expect(root.textContent).toContain('JUnit'); expect(mockFetch.mock.calls.some(c => c[0].includes('/master/'))).toBe(true); }); it('shows the not-found error page when no branch has a report', async () => { - mockFetch.mockResolvedValue({ ok: false, status: 404 }); + mockFetch.mockImplementation(requested => { + if (requested.endsWith('assets/refactor-first-report.mustache')) { + return Promise.resolve({ ok: true, text: () => Promise.resolve(reportTemplate) }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); app = createApp({ root }); history.replaceState(null, '', '/ghost/missing'); await app.handleRoute(); @@ -74,23 +138,34 @@ describe('Report rendering integration', () => { expect(root.textContent).toContain('Not Found'); }); - it('uses the bundled fallback template when the repo has none', async () => { + it('ignores any repository-provided template and always uses the bundled one', async () => { + respondJsonFor(sampleJson, { remoteTemplate: '
malicious: {{project.name}}
' }); + app = createApp({ root }); + history.replaceState(null, '', '/junit-team/junit4'); + await app.handleRoute(); + await app.pendingSubmissions(); + expect(root.innerHTML).toContain('Class Map'); + expect(root.innerHTML).not.toContain('malicious'); + // The remote template endpoint must not even be requested + expect(mockFetch.mock.calls.some(c => String(c[0]).endsWith('refactor-first-report.mustache') + && !String(c[0]).endsWith('assets/refactor-first-report.mustache'))).toBe(false); + }); + + it('uses the bundled template when the repo has none', async () => { mockFetch.mockImplementation(requested => { if (requested.endsWith('.refactorfirst/refactor-first.json')) { return Promise.resolve({ ok: true, json: () => Promise.resolve(sampleJson) }); } - if (requested.endsWith('refactor-first-report.mustache') && requested.includes('raw.githubusercontent')) { - return Promise.resolve({ ok: false, status: 404 }); - } if (requested.endsWith('assets/refactor-first-report.mustache')) { - return Promise.resolve({ ok: true, text: () => Promise.resolve('
fallback: {{projectName}}
') }); + return Promise.resolve({ ok: true, text: () => Promise.resolve(reportTemplate) }); } return Promise.resolve({ ok: false, status: 404 }); }); app = createApp({ root }); - history.replaceState(null, '', '/refactorfirst/refactorfirst'); + history.replaceState(null, '', '/junit-team/junit4'); await app.handleRoute(); - expect(root.innerHTML).toContain('fallback: refactorfirst'); + await app.pendingSubmissions(); + expect(root.textContent).toContain('Class Map'); }); }); diff --git a/tests/unit/fetcher-fallback.test.js b/tests/unit/fetcher-fallback.test.js index 88b159f..3be1305 100644 --- a/tests/unit/fetcher-fallback.test.js +++ b/tests/unit/fetcher-fallback.test.js @@ -43,29 +43,28 @@ describe('fetchJsonWithFallback (branch fallback)', () => { }); }); -describe('fetchReport (JSON + template with fallback)', () => { +describe('fetchReport (report JSON only; the bundled template is authoritative)', () => { let mockFetch; beforeEach(() => { mockFetch = spyOn(global, 'fetch'); }); afterEach(() => mockFetch.mockRestore()); - it('returns data, template and the resolved branch', async () => { + it('returns data and the resolved branch', async () => { mockFetch - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ x: 1 }) }) - .mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('

{{name}}

') }); + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ x: 1 }) }); const report = await fetchReport('u', 'r', 'main'); expect(report.data).toEqual({ x: 1 }); - expect(report.template).toBe('

{{name}}

'); expect(report.branch).toBe('main'); }); - it('uses the fallback template when the repo has none', async () => { + it('never requests a template from the repository', async () => { mockFetch - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ x: 1 }) }) - .mockResolvedValueOnce({ ok: false, status: 404 }); + .mockResolvedValue({ ok: true, json: () => Promise.resolve({ x: 1 }) }); - const report = await fetchReport('u', 'r', 'main', { fallbackTemplate: 'default' }); - expect(report.template).toBe('default'); + await fetchReport('u', 'r', 'main'); + expect( + mockFetch.mock.calls.some(c => String(c[0]).endsWith('.refactorfirst/refactor-first-report.mustache')) + ).toBe(false); }); }); diff --git a/tests/unit/fetcher.test.js b/tests/unit/fetcher.test.js index b9a05d2..7e82ea7 100644 --- a/tests/unit/fetcher.test.js +++ b/tests/unit/fetcher.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; -import { fetchJson, fetchTemplate, constructRawUrl, constructTemplateUrl } from '../../js/fetcher.js'; +import { fetchJson, constructRawUrl } from '../../js/fetcher.js'; describe('GitHub API Fetching', () => { let mockFetch; @@ -42,56 +42,21 @@ describe('GitHub API Fetching', () => { await expect(fetchJson('user', 'repo', 'main')).rejects.toThrow('Network error'); }); - it('should fetch Mustache template successfully', async () => { - const mockTemplate = '{{content}}'; - mockFetch.mockResolvedValue({ - ok: true, - text: () => Promise.resolve(mockTemplate) - }); - - const result = await fetchTemplate('user', 'repo', 'main'); - expect(result).toBe(mockTemplate); - expect(mockFetch).toHaveBeenCalledWith( - 'https://raw.githubusercontent.com/user/repo/main/.refactorfirst/refactor-first-report.mustache', - expect.any(Object) - ); - }); - - it('should return fallback template when template fetch fails', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 404 - }); - - const fallbackTemplate = 'fallback'; - const result = await fetchTemplate('user', 'repo', 'main', fallbackTemplate); - expect(result).toBe(fallbackTemplate); - }); - it('should construct correct raw URL', () => { const url = constructRawUrl('user', 'repo', 'branch'); expect(url).toBe('https://raw.githubusercontent.com/user/repo/branch/.refactorfirst/refactor-first.json'); }); - it('should construct correct template URL', () => { - const url = constructTemplateUrl('user', 'repo', 'branch'); - expect(url).toBe('https://raw.githubusercontent.com/user/repo/branch/.refactorfirst/refactor-first-report.mustache'); - }); - it('should construct GitLab raw URLs with -/raw/ and honor a custom base URL', () => { expect(constructRawUrl('group', 'proj', 'main', { environment: 'gitlab' })) .toBe('https://gitlab.com/group/proj/-/raw/main/.refactorfirst/refactor-first.json'); expect(constructRawUrl('group', 'proj', 'main', { environment: 'gitlab', baseUrl: 'https://gl.example.com/' })) .toBe('https://gl.example.com/group/proj/-/raw/main/.refactorfirst/refactor-first.json'); - expect(constructTemplateUrl('group', 'proj', 'main', { environment: 'gitlab' })) - .toBe('https://gitlab.com/group/proj/-/raw/main/.refactorfirst/refactor-first-report.mustache'); }); it('should construct Bitbucket raw URLs', () => { expect(constructRawUrl('ws', 'proj', 'main', { environment: 'bitbucket' })) .toBe('https://bitbucket.org/ws/proj/raw/main/.refactorfirst/refactor-first.json'); - expect(constructTemplateUrl('ws', 'proj', 'main', { environment: 'bitbucket' })) - .toBe('https://bitbucket.org/ws/proj/raw/main/.refactorfirst/refactor-first-report.mustache'); }); it('falls back to GitHub URLs for unknown environments', () => { diff --git a/tests/unit/renderer.test.js b/tests/unit/renderer.test.js index d1efc25..0de9c3b 100644 --- a/tests/unit/renderer.test.js +++ b/tests/unit/renderer.test.js @@ -63,3 +63,47 @@ describe('Mustache Rendering', () => { expect(typeof mustache.render).toBe('function'); }) }) + +describe('templating safety (repository-provided templates are untrusted)', () => { + it('strips template-provided script tags from the output', () => { + const html = renderTemplate( + '
{{name}}
', + { name: 'demo' } + ); + expect(html).not.toContain('