diff --git a/.all-contributorsrc b/.all-contributorsrc index 32a2512af..c78090324 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -3,9 +3,7 @@ "projectOwner": "PRODHOSH", "repoType": "github", "repoHost": "https://github.com", - "files": [ - "README.md" - ], + "files": ["README.md"], "imageSize": 100, "commit": true, "commitConvention": "angular", @@ -32,324 +30,238 @@ "name": "Karishma Kumari", "avatar_url": "https://avatars.githubusercontent.com/u/193392138?v=4", "profile": "https://github.com/karishma9142", - "contributions": [ - "doc" - ] + "contributions": ["doc"] }, { "login": "AnalShaju", "name": "Anal Shaju", "avatar_url": "https://avatars.githubusercontent.com/u/133645290?v=4", "profile": "https://github.com/AnalShaju", - "contributions": [ - "code", - "design" - ] + "contributions": ["code", "design"] }, { "login": "SakethSumanBathini", "name": "Saketh Suman Bathini", "avatar_url": "https://avatars.githubusercontent.com/u/178634012?v=4", "profile": "https://github.com/SakethSumanBathini", - "contributions": [ - "code", - "design" - ] + "contributions": ["code", "design"] }, { "login": "cc-c122", "name": "cc-c122", "avatar_url": "https://avatars.githubusercontent.com/u/242877454?v=4", "profile": "https://github.com/cc-c122", - "contributions": [ - "doc" - ] + "contributions": ["doc"] }, { "login": "iAwaisKhan", "name": "Awais Khan ", "avatar_url": "https://avatars.githubusercontent.com/u/236069266?v=4", "profile": "http://awaisxdevs.vercel.app", - "contributions": [ - "code", - "design" - ] + "contributions": ["code", "design"] }, { "login": "Komal-Sharma03", "name": "KOMALSHARMA", "avatar_url": "https://avatars.githubusercontent.com/u/174271565?v=4", "profile": "https://github.com/Komal-Sharma03", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "Ayushganeshi", "name": "Ayushganeshi", "avatar_url": "https://avatars.githubusercontent.com/u/174295266?v=4", "profile": "https://github.com/Ayushganeshi", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "saurabhsingh72487-hub", "name": "Saurabh Singh", "avatar_url": "https://avatars.githubusercontent.com/u/258796947?v=4", "profile": "https://my-portfolio-3d-d9c6.onrender.com/", - "contributions": [ - "code", - "design" - ] + "contributions": ["code", "design"] }, { "login": "Aditya8369", "name": "Aditya Mahajan", "avatar_url": "https://avatars.githubusercontent.com/u/178887069?v=4", "profile": "https://github.com/Aditya8369", - "contributions": [ - "bug" - ] + "contributions": ["bug"] }, { "login": "Adit-Jain-srm", "name": "Adit Jain", "avatar_url": "https://avatars.githubusercontent.com/u/195575011?v=4", "profile": "https://github.com/Adit-Jain-srm", - "contributions": [ - "code", - "bug" - ] + "contributions": ["code", "bug"] }, { "login": "all-contributors", "name": "All Contributors", "avatar_url": "https://avatars.githubusercontent.com/u/46410174?v=4", "profile": "https://allcontributors.org", - "contributions": [ - "doc", - "maintenance" - ] + "contributions": ["doc", "maintenance"] }, { "login": "abhilasha2101", "name": "Abhilasha Kumari", "avatar_url": "https://avatars.githubusercontent.com/u/144041389?v=4", "profile": "https://github.com/abhilasha2101", - "contributions": [ - "code", - "design" - ] + "contributions": ["code", "design"] }, { "login": "Parthwebde12", "name": "Parth Wakodikar", "avatar_url": "https://avatars.githubusercontent.com/u/163313000?v=4", "profile": "http://parthwebde12.github.io/Portfoliosite-master/", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "Babin123456", "name": "Babin Bid", "avatar_url": "https://avatars.githubusercontent.com/u/265290994?v=4", "profile": "https://github.com/Babin123456", - "contributions": [ - "code", - "design" - ] + "contributions": ["code", "design"] }, { "login": "tri-pti", "name": "Tripti Patel", "avatar_url": "https://avatars.githubusercontent.com/u/169473202?v=4", "profile": "https://github.com/tri-pti", - "contributions": [ - "code", - "design" - ] + "contributions": ["code", "design"] }, { "login": "JMR825", "name": "Janhavi Rathod", "avatar_url": "https://avatars.githubusercontent.com/u/152759615?v=4", "profile": "https://github.com/JMR825", - "contributions": [ - "code", - "doc" - ] + "contributions": ["code", "doc"] }, { "login": "Dhruvesh1611", "name": "Dhruvesh Shyara", "avatar_url": "https://avatars.githubusercontent.com/u/178993802?v=4", "profile": "https://github.com/Dhruvesh1611", - "contributions": [ - "code", - "design" - ] + "contributions": ["code", "design"] }, { "login": "utkarshsingh3011", "name": "Utkarsh Singh", "avatar_url": "https://avatars.githubusercontent.com/u/230796948?v=4", "profile": "https://sentinel-cyberlabs.vercel.app", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "saumyabaranwal", "name": "Saumya Baranwal", "avatar_url": "https://avatars.githubusercontent.com/u/157876101?v=4", "profile": "https://github.com/saumyabaranwal", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "AMillionDriver", "name": "Nanang Nurmansah", "avatar_url": "https://avatars.githubusercontent.com/u/157272282?v=4", "profile": "https://github.com/AMillionDriver", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "L337tooka", "name": "L337tooka", "avatar_url": "https://avatars.githubusercontent.com/u/170666324?v=4", "profile": "https://github.com/L337tooka", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "blut-agent", "name": "Blut-agent", "avatar_url": "https://avatars.githubusercontent.com/u/278569635?v=4", "profile": "https://github.com/blut-agent", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "Gaurika-05", "name": "Gaurika", "avatar_url": "https://avatars.githubusercontent.com/u/182803515?v=4", "profile": "https://github.com/Gaurika-05", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "Ayush-0918", "name": "AYUSH PANDEY", "avatar_url": "https://avatars.githubusercontent.com/u/184804819?v=4", "profile": "https://github.com/Ayush-0918", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "DebasmitaBose0", "name": "Debasmita Bose ๐Ÿงฟ", "avatar_url": "https://avatars.githubusercontent.com/u/144198639?v=4", "profile": "https://github.com/DebasmitaBose0", - "contributions": [ - "code", - "doc" - ] + "contributions": ["code", "doc"] }, { "login": "AliMahmoudDev", "name": "Ali Mahmmoud", "avatar_url": "https://avatars.githubusercontent.com/u/127209252?v=4", "profile": "https://alimahmoud-dev.vercel.app/", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "asiya2123", "name": "Shaik Asiya ", "avatar_url": "https://avatars.githubusercontent.com/u/228271347?v=4", "profile": "https://github.com/asiya2123", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "HoussemEddineChaouch", "name": "Houssem Eddine Chaouch", "avatar_url": "https://avatars.githubusercontent.com/u/121398886?v=4", "profile": "http://www.houssemeddinechaouch.me", - "contributions": [ - "code", - "design" - ] + "contributions": ["code", "design"] }, { "login": "anushka17jaiswal", "name": "ANUSHKA JAISWAL", "avatar_url": "https://avatars.githubusercontent.com/u/243538993?v=4", "profile": "https://github.com/anushka17jaiswal", - "contributions": [ - "code", - "design" - ] + "contributions": ["code", "design"] }, { "login": "mspandey", "name": "Amisha Pandey", "avatar_url": "https://avatars.githubusercontent.com/u/226774300?v=4", "profile": "https://github.com/mspandey", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "hanu-14", "name": "MOHAMMED HANAN M T P", "avatar_url": "https://avatars.githubusercontent.com/u/91409429?v=4", "profile": "https://github.com/hanu-14", - "contributions": [ - "code" - ] + "contributions": ["code"] }, { "login": "Shikha18Shukla", "name": "Shikha Shukla", "avatar_url": "https://avatars.githubusercontent.com/u/154722305?v=4", "profile": "https://github.com/Shikha18Shukla", - "contributions": [ - "code", - "design" - ] + "contributions": ["code", "design"] }, { "login": "rohitkumarnaidu", "name": "Bappadala Rohith Kumar Naidu", "avatar_url": "https://avatars.githubusercontent.com/u/198095685?v=4", "profile": "https://github.com/rohitkumarnaidu", - "contributions": [ - "code", - "doc", - "infra" - ] + "contributions": ["code", "doc", "infra"] }, { "login": "Roshan-B87", "name": "Roshan Bhagat", "avatar_url": "https://avatars.githubusercontent.com/u/201636176?v=4", "profile": "https://github.com/Roshan-B87", - "contributions": [ - "code", - "doc" - ] + "contributions": ["code", "doc"] } ], "contributionTypes": [ diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 2d4ab0748..dd9acd9a4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,9 +1,9 @@ --- name: Bug Report about: Report a bug to help us improve OSSfolio -title: "[BUG] " +title: '[BUG] ' labels: bug -assignees: "" +assignees: '' --- ## Describe the Bug diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md index a4ab6b7fd..2ad89dfb9 100644 --- a/.github/ISSUE_TEMPLATE/documentation.md +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -1,9 +1,9 @@ --- name: Documentation Task about: Improve or add to OSSfolio's documentation -title: "[DOCS] " +title: '[DOCS] ' labels: documentation -assignees: "" +assignees: '' --- ## What Needs Documenting? diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 5ad101f72..5b52f188f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,9 +1,9 @@ --- name: Feature Request about: Suggest a new feature or improvement for OSSfolio -title: "[FEAT] " +title: '[FEAT] ' labels: enhancement -assignees: "" +assignees: '' --- ## Problem Statement diff --git a/.github/ISSUE_TEMPLATE/good_first_issue.md b/.github/ISSUE_TEMPLATE/good_first_issue.md index 4976e74ff..daf7f75bf 100644 --- a/.github/ISSUE_TEMPLATE/good_first_issue.md +++ b/.github/ISSUE_TEMPLATE/good_first_issue.md @@ -1,9 +1,9 @@ --- name: Good First Issue about: A beginner-friendly task for new contributors -title: "[GOOD FIRST ISSUE] " +title: '[GOOD FIRST ISSUE] ' labels: good first issue -assignees: "" +assignees: '' --- ## Task Description diff --git a/.github/labeler.yml b/.github/labeler.yml index 7661dd2ab..f8049b4a5 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,43 +1,43 @@ frontend: - changed-files: - any-glob-to-any-file: - - "src/app/**" - - "src/components/**" - - "**/*.css" - - "tailwind.config.*" - - "postcss.config.*" - - "next.config.*" - - "public/**" + - 'src/app/**' + - 'src/components/**' + - '**/*.css' + - 'tailwind.config.*' + - 'postcss.config.*' + - 'next.config.*' + - 'public/**' UI: - changed-files: - any-glob-to-any-file: - - "src/components/ui/**" - - "src/components/home/**" - - "src/components/layout/**" - - "src/components/profile/**" - - "**/*.css" + - 'src/components/ui/**' + - 'src/components/home/**' + - 'src/components/layout/**' + - 'src/components/profile/**' + - '**/*.css' backend: - changed-files: - any-glob-to-any-file: - - "src/lib/**" - - "src/server/**" - - "src/app/api/**" - - "supabase/**" - - "src/types/**" + - 'src/lib/**' + - 'src/server/**' + - 'src/app/api/**' + - 'supabase/**' + - 'src/types/**' documentation: - changed-files: - any-glob-to-any-file: - - "**/*.md" - - "docs/**" - - ".github/PULL_REQUEST_TEMPLATE.md" - - "CONTRIBUTING.md" - - "CODE_OF_CONDUCT.md" + - '**/*.md' + - 'docs/**' + - '.github/PULL_REQUEST_TEMPLATE.md' + - 'CONTRIBUTING.md' + - 'CODE_OF_CONDUCT.md' AI: - changed-files: - any-glob-to-any-file: - - "src/lib/ai/**" - - "src/app/api/ai/**" + - 'src/lib/ai/**' + - 'src/app/api/ai/**' diff --git a/.github/labels.yml b/.github/labels.yml index ee8a0c7dd..18a75abaa 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -1,70 +1,70 @@ # GitHub Labels for OSSfolio # Apply with: gh label create --force (or use a labels sync action) -- name: "elusoc" - color: "7B2FBE" - description: "ELUSOC project submission" +- name: 'elusoc' + color: '7B2FBE' + description: 'ELUSOC project submission' -- name: "beginner" - color: "2da44e" - description: "Good for newcomers to open source" +- name: 'beginner' + color: '2da44e' + description: 'Good for newcomers to open source' -- name: "intermediate" - color: "fb8f44" - description: "Some prior experience required" +- name: 'intermediate' + color: 'fb8f44' + description: 'Some prior experience required' -- name: "advanced" - color: "d93f0b" - description: "Requires deep understanding of the codebase" +- name: 'advanced' + color: 'd93f0b' + description: 'Requires deep understanding of the codebase' -- name: "good first issue" - color: "7057ff" - description: "Great for first-time contributors" +- name: 'good first issue' + color: '7057ff' + description: 'Great for first-time contributors' -- name: "bug" - color: "d73a4a" +- name: 'bug' + color: 'd73a4a' description: "Something isn't working" -- name: "enhancement" - color: "a2eeef" - description: "New feature or improvement" +- name: 'enhancement' + color: 'a2eeef' + description: 'New feature or improvement' -- name: "frontend" - color: "0075ca" - description: "Related to UI / Next.js / Tailwind" +- name: 'frontend' + color: '0075ca' + description: 'Related to UI / Next.js / Tailwind' -- name: "backend" - color: "e4e669" - description: "Related to Supabase / API / database" +- name: 'backend' + color: 'e4e669' + description: 'Related to Supabase / API / database' -- name: "documentation" - color: "0075ca" - description: "Improvements or additions to docs" +- name: 'documentation' + color: '0075ca' + description: 'Improvements or additions to docs' -- name: "AI" - color: "f9d0c4" - description: "AI features โ€” contributor summary, scoring" +- name: 'AI' + color: 'f9d0c4' + description: 'AI features โ€” contributor summary, scoring' -- name: "UI" - color: "bfd4f2" - description: "Visual / design changes" +- name: 'UI' + color: 'bfd4f2' + description: 'Visual / design changes' -- name: "help wanted" - color: "008672" - description: "Extra attention needed โ€” open for anyone" +- name: 'help wanted' + color: '008672' + description: 'Extra attention needed โ€” open for anyone' -- name: "wontfix" - color: "ffffff" - description: "This will not be worked on" +- name: 'wontfix' + color: 'ffffff' + description: 'This will not be worked on' -- name: "duplicate" - color: "cfd3d7" - description: "This issue or PR already exists" +- name: 'duplicate' + color: 'cfd3d7' + description: 'This issue or PR already exists' -- name: "invalid" - color: "e4e669" +- name: 'invalid' + color: 'e4e669' description: "This doesn't seem right" -- name: "stale" - color: "6e7681" - description: "Extra attention needed โ€” stale and inactive" +- name: 'stale' + color: '6e7681' + description: 'Extra attention needed โ€” stale and inactive' diff --git a/.github/workflows/bundle-size-comment.yml b/.github/workflows/bundle-size-comment.yml index aa757c757..19cf69f99 100644 --- a/.github/workflows/bundle-size-comment.yml +++ b/.github/workflows/bundle-size-comment.yml @@ -12,7 +12,7 @@ name: Bundle Size Comment on: workflow_run: - workflows: ["Bundle Size"] + workflows: ['Bundle Size'] types: [completed] # Serialised per pull request. Several Bundle Size runs can finish close diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index da8178b33..a28830424 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -44,8 +44,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "22" - cache: "npm" + node-version: '22' + cache: 'npm' cache-dependency-path: head/package-lock.json - name: Build pull request diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf575a41d..74c4db016 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "22" - cache: "npm" + node-version: '22' + cache: 'npm' cache-dependency-path: package-lock.json - name: Install dependencies diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 641789223..caf83e203 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -6,7 +6,7 @@ on: pull_request: branches: [main] schedule: - - cron: "0 6 * * 1" + - cron: '0 6 * * 1' jobs: analyze: @@ -32,4 +32,4 @@ jobs: - name: Run CodeQL Analysis uses: github/codeql-action/analyze@v3 with: - category: "/language:javascript-typescript" + category: '/language:javascript-typescript' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 294b3a07d..fda4cfb62 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -15,8 +15,8 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "22" - cache: "npm" + node-version: '22' + cache: 'npm' - name: Install dependencies run: npm ci @@ -29,11 +29,11 @@ jobs: - name: Build Next.js application for E2E run: npm run build env: - NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321" - NEXT_PUBLIC_SUPABASE_ANON_KEY: "e2e-anon-key" - SUPABASE_SERVICE_ROLE_KEY: "e2e-service-role-key" - NEXT_TELEMETRY_DISABLED: "1" - NODE_OPTIONS: "--max-old-space-size=4096" + NEXT_PUBLIC_SUPABASE_URL: 'http://127.0.0.1:54321' + NEXT_PUBLIC_SUPABASE_ANON_KEY: 'e2e-anon-key' + SUPABASE_SERVICE_ROLE_KEY: 'e2e-service-role-key' + NEXT_TELEMETRY_DISABLED: '1' + NODE_OPTIONS: '--max-old-space-size=4096' # No secrets are referenced anywhere in this job, and that is deliberate: GitHub does # not expose secrets to pull requests from forks, so a suite that needed them would @@ -46,8 +46,8 @@ jobs: - name: Run E2E tests run: npx playwright test env: - NEXT_TELEMETRY_DISABLED: "1" - NODE_OPTIONS: "--max-old-space-size=4096" + NEXT_TELEMETRY_DISABLED: '1' + NODE_OPTIONS: '--max-old-space-size=4096' - name: Upload report uses: actions/upload-artifact@v4 diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index 1fa10eaf2..317e3fd4f 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -14,6 +14,6 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: "18" + node-version: '18' - run: npm install - run: npx prettier --check . diff --git a/.github/workflows/seed-actions-issues.yml b/.github/workflows/seed-actions-issues.yml index f7c19098d..f4fb2b331 100644 --- a/.github/workflows/seed-actions-issues.yml +++ b/.github/workflows/seed-actions-issues.yml @@ -31,7 +31,7 @@ jobs: # SECTION 1 - FAILING UNIT TESTS (confirmed by running npm test locally) # ----------------------------------------------------------------------- - - name: "Issue 1 - hashVisitorIp test calls async function without await" + - name: 'Issue 1 - hashVisitorIp test calls async function without await' continue-on-error: true run: | cat > /tmp/body.md << 'ENDBODY' @@ -66,7 +66,7 @@ jobs: --label "ADVENTURER,ELUSOC,bug,testing" \ --body-file /tmp/body.md - - name: "Issue 2 - buildExploreQuery leaks page param into filter links" + - name: 'Issue 2 - buildExploreQuery leaks page param into filter links' continue-on-error: true run: | cat > /tmp/body.md << 'ENDBODY' @@ -101,7 +101,7 @@ jobs: --label "ADVENTURER,ELUSOC,bug,frontend" \ --body-file /tmp/body.md - - name: "Issue 3 - describeFilters uses the wrong Unicode separator character" + - name: 'Issue 3 - describeFilters uses the wrong Unicode separator character' continue-on-error: true run: | cat > /tmp/body.md << 'ENDBODY' @@ -136,7 +136,7 @@ jobs: --label "ADVENTURER,ELUSOC,bug,frontend,testing" \ --body-file /tmp/body.md - - name: "Issue 4 - CSV export quotes all values but test expects username unquoted" + - name: 'Issue 4 - CSV export quotes all values but test expects username unquoted' continue-on-error: true run: | cat > /tmp/body.md << 'ENDBODY' @@ -172,7 +172,7 @@ jobs: --label "ADVENTURER,ELUSOC,bug,testing,backend" \ --body-file /tmp/body.md - - name: "Issue 5 - webhook-dead-letter tests fail because mock is missing a chained eq call" + - name: 'Issue 5 - webhook-dead-letter tests fail because mock is missing a chained eq call' continue-on-error: true run: | cat > /tmp/body.md << 'ENDBODY' @@ -207,7 +207,7 @@ jobs: --label "ADVENTURER,ELUSOC,bug,testing,backend" \ --body-file /tmp/body.md - - name: "Issue 6 - v1 users API tests read profile fields off the wrong level of the response" + - name: 'Issue 6 - v1 users API tests read profile fields off the wrong level of the response' continue-on-error: true run: | cat > /tmp/body.md << 'ENDBODY' @@ -248,7 +248,7 @@ jobs: # SECTION 2 - DOCS BUGS (broken links and inaccurate content) # ----------------------------------------------------------------------- - - name: "Issue 7 - CONTRIBUTING.md has hardcoded absolute file paths from another dev machine" + - name: 'Issue 7 - CONTRIBUTING.md has hardcoded absolute file paths from another dev machine' continue-on-error: true run: | cat > /tmp/body.md << 'ENDBODY' @@ -280,7 +280,7 @@ jobs: --label "ADVENTURER,ELUSOC,documentation" \ --body-file /tmp/body.md - - name: "Issue 8 - README lists shadcn/ui and wrong Node version" + - name: 'Issue 8 - README lists shadcn/ui and wrong Node version' continue-on-error: true run: | cat > /tmp/body.md << 'ENDBODY' @@ -312,7 +312,7 @@ jobs: # SECTION 3 - CI AND WORKFLOW ISSUES # ----------------------------------------------------------------------- - - name: "Issue 9 - E2E workflow runtime env vars for the server are implicit and undocumented" + - name: 'Issue 9 - E2E workflow runtime env vars for the server are implicit and undocumented' continue-on-error: true run: | cat > /tmp/body.md << 'ENDBODY' @@ -354,7 +354,7 @@ jobs: # SECTION 4 - PERFORMANCE AND ARCHITECTURE # ----------------------------------------------------------------------- - - name: "Issue 10 - supabaseAdmin creates a new client on every call instead of caching it" + - name: 'Issue 10 - supabaseAdmin creates a new client on every call instead of caching it' continue-on-error: true run: | cat > /tmp/body.md << 'ENDBODY' diff --git a/.github/workflows/sql-lint.yml b/.github/workflows/sql-lint.yml index 0b132fd60..ff3ad1158 100644 --- a/.github/workflows/sql-lint.yml +++ b/.github/workflows/sql-lint.yml @@ -14,9 +14,9 @@ name: SQL Migration Checks on: pull_request: paths: - - "supabase/**" - - "scripts/sql-lint.mjs" - - ".github/workflows/sql-lint.yml" + - 'supabase/**' + - 'scripts/sql-lint.mjs' + - '.github/workflows/sql-lint.yml' permissions: contents: read @@ -36,12 +36,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "22" + node-version: '22' - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: '3.12' - name: Install sqlfluff # Pinned so the parser cannot change underneath the repository without a diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5c98d87bc..b201714de 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,8 +1,8 @@ -name: "Mark stale issues and PRs" +name: 'Mark stale issues and PRs' on: schedule: - - cron: "0 0 * * *" + - cron: '0 0 * * *' workflow_dispatch: permissions: @@ -17,20 +17,20 @@ jobs: uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: "This issue has seen no activity for 30 days. It has been marked as stale and will be closed in 7 days if no further activity occurs." - stale-pr-message: "This pull request has seen no activity for 30 days. It has been marked as stale and will be closed in 7 days if no further activity occurs. Thank you for your contributions!" - close-issue-message: "This issue has been automatically closed due to inactivity." - close-pr-message: "This pull request has been automatically closed due to inactivity." + stale-issue-message: 'This issue has seen no activity for 30 days. It has been marked as stale and will be closed in 7 days if no further activity occurs.' + stale-pr-message: 'This pull request has seen no activity for 30 days. It has been marked as stale and will be closed in 7 days if no further activity occurs. Thank you for your contributions!' + close-issue-message: 'This issue has been automatically closed due to inactivity.' + close-pr-message: 'This pull request has been automatically closed due to inactivity.' days-before-stale: 30 days-before-close: 7 - stale-issue-label: "stale" - stale-pr-label: "stale" + stale-issue-label: 'stale' + stale-pr-label: 'stale' # Newcomer-facing and deliberately-parked work must survive the sweep. # A "good first issue" is meant to sit unclaimed until someone new picks # it up, so the 30-day timer would close exactly the issues the repo # wants to keep open. Comma-separated, per the action's input format. - exempt-issue-labels: "good first issue,pinned" - exempt-pr-labels: "pinned" + exempt-issue-labels: 'good first issue,pinned' + exempt-pr-labels: 'pinned' # Already the action's default; stated explicitly because closing a # stale PR must never destroy the contributor's branch. delete-branch: false diff --git a/.prettierrc b/.prettierrc index 6b3628293..3a4be2d82 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1 +1 @@ -๏ปฟ{"semi":true,"singleQuote":true,"printWidth":80,"tabWidth":2} +๏ปฟ{ "semi": true, "singleQuote": true, "printWidth": 80, "tabWidth": 2 } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b6e31976a..8d7af5546 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -306,18 +306,18 @@ Current security mechanisms include: # Folder Responsibilities -| Folder | Responsibility | -|---------|----------------| -| `src/app` | Next.js App Router pages and API routes | -| `src/components` | Reusable UI components | -| `src/components/home` | Landing page sections | -| `src/components/layout` | Navigation and layout | -| `src/components/profile` | Profile-related UI | -| `src/lib` | API clients, utilities, helper functions | -| `src/types` | TypeScript definitions | -| `supabase/migrations` | Database migrations | -| `supabase/schema.sql` | Database schema | -| `public` | Static assets | +| Folder | Responsibility | +| ------------------------ | ---------------------------------------- | +| `src/app` | Next.js App Router pages and API routes | +| `src/components` | Reusable UI components | +| `src/components/home` | Landing page sections | +| `src/components/layout` | Navigation and layout | +| `src/components/profile` | Profile-related UI | +| `src/lib` | API clients, utilities, helper functions | +| `src/types` | TypeScript definitions | +| `supabase/migrations` | Database migrations | +| `supabase/schema.sql` | Database schema | +| `public` | Static assets | --- diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index d65b0e7b1..6d8a898de 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -23,7 +23,6 @@ Code of Conduct โ””โ”€โ”€ Attribution ``` - ## Our Commitment We are committed to fostering a welcoming, inclusive, and respectful community where everyone can contribute, collaborate, and learn in a safe environment. We expect all members, contributors, maintainers, and participants to treat one another with professionalism, courtesy, and respect, regardless of age, body size, disability, ethnicity, gender identity or expression, level of experience, nationality, personal appearance, race, religion, sexual orientation, or any other personal characteristic. @@ -38,22 +37,22 @@ It also applies when an individual is officially representing the project in pub ### Examples of positive behavior include: -* Being respectful, considerate, and welcoming to others. -* Using inclusive and professional language in all interactions. -* Respecting differing opinions, experiences, and perspectives. -* Providing and accepting constructive feedback gracefully. -* Collaborating in good faith and focusing on what benefits the community. -* Helping create a friendly, supportive, and harassment-free environment. +- Being respectful, considerate, and welcoming to others. +- Using inclusive and professional language in all interactions. +- Respecting differing opinions, experiences, and perspectives. +- Providing and accepting constructive feedback gracefully. +- Collaborating in good faith and focusing on what benefits the community. +- Helping create a friendly, supportive, and harassment-free environment. ### Examples of unacceptable behavior include: -* Harassment, bullying, intimidation, or discrimination of any kind. -* Trolling, insulting, derogatory comments, or personal attacks. -* Hate speech or offensive remarks targeting individuals or groups. -* Sharing another person's private or confidential information without explicit permission. -* Unwelcome sexual attention, language, imagery, or advances. -* Deliberately disrupting discussions, events, or project activities. -* Any conduct that would reasonably be considered inappropriate in a professional or collaborative environment. +- Harassment, bullying, intimidation, or discrimination of any kind. +- Trolling, insulting, derogatory comments, or personal attacks. +- Hate speech or offensive remarks targeting individuals or groups. +- Sharing another person's private or confidential information without explicit permission. +- Unwelcome sexual attention, language, imagery, or advances. +- Deliberately disrupting discussions, events, or project activities. +- Any conduct that would reasonably be considered inappropriate in a professional or collaborative environment. ## Enforcement diff --git a/README.md b/README.md index e935e98f9..3b383a2ae 100644 --- a/README.md +++ b/README.md @@ -99,12 +99,12 @@ src/lib/score.ts ## Tech Stack -| Layer | Tech | -| -------- | --------------------------------------------------------------- | +| Layer | Tech | +| -------- | -------------------------------------------------------------------- | | Frontend | Next.js ยท TypeScript ยท Tailwind CSS ยท Radix UI + CVA ยท Framer Motion | -| Backend | Supabase ยท PostgreSQL | -| Data | GitHub GraphQL API | -| Hosting | Cloudflare Pages | +| Backend | Supabase ยท PostgreSQL | +| Data | GitHub GraphQL API | +| Hosting | Cloudflare Pages | --- diff --git a/e2e/critical-paths.spec.ts b/e2e/critical-paths.spec.ts index a87eb6cb0..6bfc056f7 100644 --- a/e2e/critical-paths.spec.ts +++ b/e2e/critical-paths.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from '@playwright/test'; /** * The three critical paths from the issue: the homepage renders, a profile renders, and the @@ -13,9 +13,9 @@ import { test, expect } from "@playwright/test"; * breaks when the design changes teaches everyone to ignore it. */ -test.describe("critical paths", () => { - test("the homepage renders and offers a way in", async ({ page }) => { - const response = await page.goto("/"); +test.describe('critical paths', () => { + test('the homepage renders and offers a way in', async ({ page }) => { + const response = await page.goto('/'); expect(response?.status()).toBe(200); await expect(page).toHaveTitle(/ossfolio/i); @@ -24,54 +24,54 @@ test.describe("critical paths", () => { // kind of failure this suite exists to catch. `#main-content` is the landmark the skip // link targets, so it's the right thing to assert on โ€” and it's a single element, unlike // a `main, body` selector list, which matches both and trips Playwright's strict mode. - const main = page.locator("#main-content"); + const main = page.locator('#main-content'); await expect(main).toBeVisible(); await expect(main).not.toBeEmpty(); // The primary journey out of the homepage must exist. const explore = page - .getByRole("link", { name: /explore|discover|leaderboard/i }) + .getByRole('link', { name: /explore|discover|leaderboard/i }) .first(); await expect(explore).toBeVisible(); }); - test("the leaderboard lists profiles by score", async ({ page }) => { + test('the leaderboard lists profiles by score', async ({ page }) => { // `/explore` is the leaderboard; see the note above. - const response = await page.goto("/explore"); + const response = await page.goto('/explore'); expect(response?.status()).toBe(200); // Both fixture profiles come from the mock Supabase, so this asserts the whole // server-component data path โ€” query, render, hydrate โ€” not merely that a route responds. - await expect(page.getByText("e2e-alice").first()).toBeVisible(); - await expect(page.getByText("e2e-bob").first()).toBeVisible(); + await expect(page.getByText('e2e-alice').first()).toBeVisible(); + await expect(page.getByText('e2e-bob').first()).toBeVisible(); }); test("a profile page renders that user's data", async ({ page }) => { - const response = await page.goto("/e2e-alice"); + const response = await page.goto('/e2e-alice'); expect(response?.status()).toBe(200); // e2e-alice has a stored snapshot in the fixtures, so the DB-first path from #300 should // render her straight from it โ€” no GitHub call, and no syncing state. - await expect(page.getByText("E2E Alice").first()).toBeVisible(); + await expect(page.getByText('E2E Alice').first()).toBeVisible(); await expect(page.getByText(/e2e-alice/).first()).toBeVisible(); await expect(page.getByText(/Building this profile/i)).toHaveCount(0); }); - test("a profile with no snapshot yet shows the syncing state", async ({ + test('a profile with no snapshot yet shows the syncing state', async ({ page, }) => { // e2e-bob deliberately has no snapshot row. This covers the other half of #300: a cold // profile must respond immediately with the syncing state rather than blocking on GitHub. // It's the branch most likely to rot unnoticed, since it only appears on a first-ever view. - const response = await page.goto("/e2e-bob"); + const response = await page.goto('/e2e-bob'); expect(response?.status()).toBe(200); await expect(page.getByText(/Building this profile/i)).toBeVisible(); }); - test("an unknown route 404s rather than erroring", async ({ page }) => { - const response = await page.goto("/this-route-does-not-exist-e2e"); + test('an unknown route 404s rather than erroring', async ({ page }) => { + const response = await page.goto('/this-route-does-not-exist-e2e'); // Next serves the not-found page; what matters is that it isn't a 500. expect(response?.status()).toBeLessThan(500); }); diff --git a/e2e/error-handling.spec.ts b/e2e/error-handling.spec.ts index fd5d26e0b..7eaafa665 100644 --- a/e2e/error-handling.spec.ts +++ b/e2e/error-handling.spec.ts @@ -1,10 +1,10 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from '@playwright/test'; -test.describe("structured API error responses", () => { - test("v1 users endpoint returns 404 with standard error shape for unknown user", async ({ +test.describe('structured API error responses', () => { + test('v1 users endpoint returns 404 with standard error shape for unknown user', async ({ request, }) => { - const res = await request.get("/api/v1/users/this-user-does-not-exist-e2e"); + const res = await request.get('/api/v1/users/this-user-does-not-exist-e2e'); expect(res.status()).toBe(404); const body = await res.json(); @@ -16,26 +16,26 @@ test.describe("structured API error responses", () => { }); }); - test("v1 users endpoint returns 400 with standard error shape for invalid username", async ({ + test('v1 users endpoint returns 400 with standard error shape for invalid username', async ({ request, }) => { - const res = await request.get("/api/v1/users/%00invalid"); + const res = await request.get('/api/v1/users/%00invalid'); expect(res.status()).toBe(400); const body = await res.json(); expect(body).toMatchObject({ error: expect.any(String), - code: "VALIDATION_ERROR", + code: 'VALIDATION_ERROR', status: 400, timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), }); }); - test("refresh endpoint returns 429 with retryAfterSeconds for rate limited requests", async ({ + test('refresh endpoint returns 429 with retryAfterSeconds for rate limited requests', async ({ request, }) => { // First request should succeed (or 404 if user doesn't exist) - const res1 = await request.post("/api/test/refresh"); + const res1 = await request.post('/api/test/refresh'); // We expect either 429 (rate limited) or 400 (invalid) โ€” both should have structured errors expect([400, 404, 429, 401, 200]).toContain(res1.status()); @@ -48,18 +48,18 @@ test.describe("structured API error responses", () => { timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), }); if (res1.status() === 429) { - expect(body).toHaveProperty("retryAfterSeconds"); - expect(typeof body.retryAfterSeconds).toBe("number"); + expect(body).toHaveProperty('retryAfterSeconds'); + expect(typeof body.retryAfterSeconds).toBe('number'); } } }); - test("404 route returns proper error shape", async ({ request }) => { - const res = await request.get("/api/nonexistent-route-e2e"); + test('404 route returns proper error shape', async ({ request }) => { + const res = await request.get('/api/nonexistent-route-e2e'); expect(res.status()).toBe(404); const body = await res.json(); - if (body && typeof body === "object" && "error" in body) { + if (body && typeof body === 'object' && 'error' in body) { expect(body).toMatchObject({ error: expect.any(String), status: 404, diff --git a/e2e/mock-supabase.mjs b/e2e/mock-supabase.mjs index 0d540766c..a12a7a742 100644 --- a/e2e/mock-supabase.mjs +++ b/e2e/mock-supabase.mjs @@ -23,67 +23,67 @@ * authentication state": nothing here talks to GitHub or to a real auth provider. */ -import { createServer } from "node:http"; +import { createServer } from 'node:http'; const PORT = Number(process.env.MOCK_SUPABASE_PORT ?? 54321); /** Two profiles, so the leaderboard has something to rank. */ const PROFILES = [ { - id: "11111111-1111-1111-1111-111111111111", - username: "e2e-alice", - name: "E2E Alice", - avatar_url: "https://avatars.githubusercontent.com/u/1?v=4", - github_url: "https://github.com/e2e-alice", + id: '11111111-1111-1111-1111-111111111111', + username: 'e2e-alice', + name: 'E2E Alice', + avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4', + github_url: 'https://github.com/e2e-alice', score: 1240, total_commits: 820, total_prs: 143, total_issues: 61, total_reviews: 77, followers: 210, - top_languages: ["TypeScript", "Go"], + top_languages: ['TypeScript', 'Go'], badges: [], - headline: "Alice builds things", + headline: 'Alice builds things', pinned_repos: [], custom_links: [], - visibility: "public", + visibility: 'public', flagged: false, flag_reason: null, score_delta_30_days: 40, view_count: 12, - updated_at: "2026-07-01T00:00:00.000Z", - last_refreshed_at: "2026-07-01T00:00:00.000Z", - created_at: "2025-01-01T00:00:00.000Z", + updated_at: '2026-07-01T00:00:00.000Z', + last_refreshed_at: '2026-07-01T00:00:00.000Z', + created_at: '2025-01-01T00:00:00.000Z', bio: null, - search_text: "e2e-alice", + search_text: 'e2e-alice', }, { - id: "22222222-2222-2222-2222-222222222222", - username: "e2e-bob", - name: "E2E Bob", - avatar_url: "https://avatars.githubusercontent.com/u/2?v=4", - github_url: "https://github.com/e2e-bob", + id: '22222222-2222-2222-2222-222222222222', + username: 'e2e-bob', + name: 'E2E Bob', + avatar_url: 'https://avatars.githubusercontent.com/u/2?v=4', + github_url: 'https://github.com/e2e-bob', score: 610, total_commits: 300, total_prs: 44, total_issues: 12, total_reviews: 9, followers: 30, - top_languages: ["Python"], + top_languages: ['Python'], badges: [], headline: null, pinned_repos: [], custom_links: [], - visibility: "public", + visibility: 'public', flagged: false, flag_reason: null, score_delta_30_days: 5, view_count: 3, - updated_at: "2026-06-01T00:00:00.000Z", - last_refreshed_at: "2026-06-01T00:00:00.000Z", - created_at: "2025-03-01T00:00:00.000Z", + updated_at: '2026-06-01T00:00:00.000Z', + last_refreshed_at: '2026-06-01T00:00:00.000Z', + created_at: '2025-03-01T00:00:00.000Z', bio: null, - search_text: "e2e-bob", + search_text: 'e2e-bob', }, ]; @@ -94,34 +94,34 @@ const PROFILES = [ */ const SNAPSHOTS = [ { - username: "e2e-alice", + username: 'e2e-alice', synced_at: new Date().toISOString(), sync_started_at: new Date().toISOString(), snapshot: { user: { - login: "e2e-alice", - name: "E2E Alice", - bio: "Alice builds things", - avatar_url: "https://avatars.githubusercontent.com/u/1?v=4", - html_url: "https://github.com/e2e-alice", + login: 'e2e-alice', + name: 'E2E Alice', + bio: 'Alice builds things', + avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4', + html_url: 'https://github.com/e2e-alice', public_repos: 24, followers: 210, following: 12, blog: null, - location: "Testville", + location: 'Testville', twitter_username: null, }, repos: [ { id: 1, - name: "alice-cli", - description: "A command line tool", - html_url: "https://github.com/e2e-alice/alice-cli", + name: 'alice-cli', + description: 'A command line tool', + html_url: 'https://github.com/e2e-alice/alice-cli', stargazers_count: 412, forks_count: 33, - language: "TypeScript", - topics: ["cli"], - pushed_at: "2026-06-20T00:00:00.000Z", + language: 'TypeScript', + topics: ['cli'], + pushed_at: '2026-06-20T00:00:00.000Z', }, ], liveStats: { @@ -143,18 +143,18 @@ const SNAPSHOTS = [ function eqValue(params, column) { const raw = params.get(column); if (!raw) return null; - return raw.startsWith("eq.") ? decodeURIComponent(raw.slice(3)) : null; + return raw.startsWith('eq.') ? decodeURIComponent(raw.slice(3)) : null; } const server = createServer((req, res) => { - const url = new URL(req.url ?? "/", `http://localhost:${PORT}`); + const url = new URL(req.url ?? '/', `http://localhost:${PORT}`); - res.setHeader("Content-Type", "application/json"); - res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); // supabase-js asks for a single object (rather than an array) via this header. - const wantsSingle = (req.headers["accept"] ?? "").includes( - "vnd.pgrst.object", + const wantsSingle = (req.headers['accept'] ?? '').includes( + 'vnd.pgrst.object', ); const respond = (rows) => { @@ -162,7 +162,7 @@ const server = createServer((req, res) => { // `.maybeSingle()` expects one object, or 406 when there's nothing. if (rows.length === 0) { res.statusCode = 406; - res.end(JSON.stringify({ message: "no rows" })); + res.end(JSON.stringify({ message: 'no rows' })); return; } res.statusCode = 200; @@ -173,15 +173,15 @@ const server = createServer((req, res) => { res.end(JSON.stringify(rows)); }; - if (url.pathname === "/" || url.pathname === "/health") { + if (url.pathname === '/' || url.pathname === '/health') { res.statusCode = 200; - res.end(JSON.stringify({ status: "ok" })); + res.end(JSON.stringify({ status: 'ok' })); return; } - if (url.pathname.startsWith("/rest/v1/profiles")) { - const username = eqValue(url.searchParams, "username"); - const id = eqValue(url.searchParams, "id"); + if (url.pathname.startsWith('/rest/v1/profiles')) { + const username = eqValue(url.searchParams, 'username'); + const id = eqValue(url.searchParams, 'id'); let rows = PROFILES; if (username) rows = rows.filter((p) => p.username === username); if (id) rows = rows.filter((p) => p.id === id); @@ -189,8 +189,8 @@ const server = createServer((req, res) => { return; } - if (url.pathname.startsWith("/rest/v1/profile_snapshots")) { - const username = eqValue(url.searchParams, "username"); + if (url.pathname.startsWith('/rest/v1/profile_snapshots')) { + const username = eqValue(url.searchParams, 'username'); const rows = username ? SNAPSHOTS.filter((s) => s.username === username) : SNAPSHOTS; @@ -203,7 +203,7 @@ const server = createServer((req, res) => { respond([]); }); -server.listen(PORT, "0.0.0.0", () => { +server.listen(PORT, '0.0.0.0', () => { // Playwright's `webServer` waits for this port, so the message is only for humans. console.log(`[mock-supabase] listening on http://127.0.0.1:${PORT}`); }); diff --git a/e2e/settings-resilience.spec.ts b/e2e/settings-resilience.spec.ts index bce906e39..59dbcfd8d 100644 --- a/e2e/settings-resilience.spec.ts +++ b/e2e/settings-resilience.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page, type Route } from "@playwright/test"; +import { test, expect, type Page, type Route } from '@playwright/test'; /** * Failure injection against the settings form. @@ -25,23 +25,26 @@ import { test, expect, type Page, type Route } from "@playwright/test"; * but never becomes clickable. */ -const SUPABASE_STORAGE_KEY = "sb-127-auth-token"; +const SUPABASE_STORAGE_KEY = 'sb-127-auth-token'; const seedSupabaseSession = async (page: Page) => { const session = { - access_token: "e2e-access-token", - token_type: "bearer", + access_token: 'e2e-access-token', + token_type: 'bearer', expires_in: 3600, expires_at: Math.floor(Date.now() / 1000) + 3600, - refresh_token: "e2e-refresh-token", + refresh_token: 'e2e-refresh-token', user: { - id: "11111111-1111-1111-1111-111111111111", - aud: "authenticated", - role: "authenticated", - email: "e2e-alice@example.com", - app_metadata: { provider: "github", providers: ["github"] }, - user_metadata: { user_name: "e2e-alice", preferred_username: "e2e-alice" }, - created_at: "2024-01-01T00:00:00.000Z", + id: '11111111-1111-1111-1111-111111111111', + aud: 'authenticated', + role: 'authenticated', + email: 'e2e-alice@example.com', + app_metadata: { provider: 'github', providers: ['github'] }, + user_metadata: { + user_name: 'e2e-alice', + preferred_username: 'e2e-alice', + }, + created_at: '2024-01-01T00:00:00.000Z', }, }; @@ -56,11 +59,11 @@ const seedSupabaseSession = async (page: Page) => { }; const EMPTY_SETTINGS = { - headline: "", + headline: '', pinned_repos: [], custom_links: [], badges: [], - visibility: "public", + visibility: 'public', funding_links: [], sponsors: [], }; @@ -74,37 +77,38 @@ const EMPTY_SETTINGS = { * stub unreachable. */ const routeSettings = async (page: Page, onPut: (route: Route) => unknown) => { - await page.route("**/api/settings", (route) => { - if (route.request().method() === "GET") { + await page.route('**/api/settings', (route) => { + if (route.request().method() === 'GET') { return route.fulfill({ status: 200, - contentType: "application/json", + contentType: 'application/json', body: JSON.stringify(EMPTY_SETTINGS), }); } - if (route.request().method() === "PUT") { + if (route.request().method() === 'PUT') { return onPut(route); } return route.continue(); }); }; -const HEADLINE = 'input[placeholder="Your custom tagline (replaces GitHub bio)"]'; +const HEADLINE = + 'input[placeholder="Your custom tagline (replaces GitHub bio)"]'; const SAVE = 'button:has-text("Save Changes")'; -const TYPED = "a tagline that must survive a failed save"; +const TYPED = 'a tagline that must survive a failed save'; const openFormAndType = async (page: Page) => { - await page.goto("/settings"); + await page.goto('/settings'); await expect(page.locator(SAVE)).toBeEnabled(); await page.fill(HEADLINE, TYPED); }; -test.describe("settings form under network failure", () => { +test.describe('settings form under network failure', () => { test.beforeEach(async ({ page }) => { await seedSupabaseSession(page); }); - test("the session seed mounts the form and enables saving", async ({ + test('the session seed mounts the form and enables saving', async ({ page, }) => { // Guards every test below. If the storage key or session shape stops @@ -112,21 +116,21 @@ test.describe("settings form under network failure", () => { // failure-injection tests would pass without exercising anything. await routeSettings(page, (route) => route.continue()); - await page.goto("/settings"); + await page.goto('/settings'); await expect( - page.getByText("Sign in to customize your profile"), + page.getByText('Sign in to customize your profile'), ).toHaveCount(0); await expect(page.locator(SAVE)).toBeEnabled(); }); - test("a 503 during save shows an error and keeps what was typed", async ({ + test('a 503 during save shows an error and keeps what was typed', async ({ page, }) => { await routeSettings(page, (route) => route.fulfill({ status: 503, - contentType: "application/json", - body: JSON.stringify({ error: "Service temporarily unavailable" }), + contentType: 'application/json', + body: JSON.stringify({ error: 'Service temporarily unavailable' }), }), ); @@ -135,29 +139,29 @@ test.describe("settings form under network failure", () => { // The server's own message is surfaced rather than a generic one. await expect( - page.getByText("Service temporarily unavailable"), + page.getByText('Service temporarily unavailable'), ).toBeVisible(); await expect(page.locator(HEADLINE)).toHaveValue(TYPED); }); - test("an aborted request shows an error and keeps what was typed", async ({ + test('an aborted request shows an error and keeps what was typed', async ({ page, }) => { // A connection that never answers rejects the fetch rather than resolving // it with a status โ€” a different path through handleSave than the 503 // above, and the one that previously showed nothing at all. - await routeSettings(page, (route) => route.abort("connectionfailed")); + await routeSettings(page, (route) => route.abort('connectionfailed')); await openFormAndType(page); await page.click(SAVE); await expect( - page.getByText("Network error. Your changes were not saved."), + page.getByText('Network error. Your changes were not saved.'), ).toBeVisible(); await expect(page.locator(HEADLINE)).toHaveValue(TYPED); }); - test("a malformed error body still produces a readable message", async ({ + test('a malformed error body still produces a readable message', async ({ page, }) => { // handleSave does resp.json().catch(() => ({})), so a non-JSON body must @@ -165,8 +169,8 @@ test.describe("settings form under network failure", () => { await routeSettings(page, (route) => route.fulfill({ status: 500, - contentType: "text/html", - body: "proxy error", + contentType: 'text/html', + body: 'proxy error', }), ); @@ -174,19 +178,19 @@ test.describe("settings form under network failure", () => { await page.click(SAVE); await expect( - page.getByText("Failed to save. Please try again."), + page.getByText('Failed to save. Please try again.'), ).toBeVisible(); await expect(page.locator(HEADLINE)).toHaveValue(TYPED); }); - test("the save button recovers after a failure", async ({ page }) => { - await routeSettings(page, (route) => route.abort("connectionfailed")); + test('the save button recovers after a failure', async ({ page }) => { + await routeSettings(page, (route) => route.abort('connectionfailed')); await openFormAndType(page); await page.click(SAVE); await expect( - page.getByText("Network error. Your changes were not saved."), + page.getByText('Network error. Your changes were not saved.'), ).toBeVisible(); // `saving` is cleared in a finally block, so the button must be usable diff --git a/eslint.config.mjs b/eslint.config.mjs index ea0c2f8ce..853faab0d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,17 +1,17 @@ -import { createRequire } from "module"; +import { createRequire } from 'module'; const require = createRequire(import.meta.url); -const nextConfig = require("eslint-config-next"); +const nextConfig = require('eslint-config-next'); export default [ ...nextConfig, { rules: { - "no-console": ["warn", { allow: ["warn", "error"] }], - "prefer-const": "error", - "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], - "no-duplicate-imports": "error", + 'no-console': ['warn', { allow: ['warn', 'error'] }], + 'prefer-const': 'error', + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'no-duplicate-imports': 'error', }, }, ]; diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index 756040922..6e21f913f 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -6,6 +6,6 @@ // options), which would fail on almost every commit. The function form below returns a single // project-wide command and ignores the staged filenames lint-staged would otherwise append. export default { - "*.{js,jsx,ts,tsx}": "eslint --fix", - "*.{ts,tsx}": () => "tsc --noEmit", + '*.{js,jsx,ts,tsx}': 'eslint --fix', + '*.{ts,tsx}': () => 'tsc --noEmit', }; diff --git a/next.config.ts b/next.config.ts index fb7715a4e..9abd420e1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,12 +1,12 @@ -import type { NextConfig } from "next"; -import createNextIntlPlugin from "next-intl/plugin"; +import type { NextConfig } from 'next'; +import createNextIntlPlugin from 'next-intl/plugin'; const nextConfig: NextConfig = { images: { unoptimized: true, remotePatterns: [ - { protocol: "https", hostname: "avatars.githubusercontent.com" }, - { protocol: "https", hostname: "github.com" }, + { protocol: 'https', hostname: 'avatars.githubusercontent.com' }, + { protocol: 'https', hostname: 'github.com' }, ], }, @@ -15,10 +15,10 @@ const nextConfig: NextConfig = { async headers() { return [ { - source: "/sw.js", + source: '/sw.js', headers: [ - { key: "Cache-Control", value: "public, max-age=0, must-revalidate" }, - { key: "Service-Worker-Allowed", value: "/" }, + { key: 'Cache-Control', value: 'public, max-age=0, must-revalidate' }, + { key: 'Service-Worker-Allowed', value: '/' }, ], }, ]; @@ -27,8 +27,8 @@ const nextConfig: NextConfig = { // Build-time environment validation const requiredEnvVars = [ - "NEXT_PUBLIC_SUPABASE_URL", - "NEXT_PUBLIC_SUPABASE_ANON_KEY", + 'NEXT_PUBLIC_SUPABASE_URL', + 'NEXT_PUBLIC_SUPABASE_ANON_KEY', ] as const; function validateEnv() { @@ -41,15 +41,15 @@ function validateEnv() { if (missing.length > 0) { // Log build-time environment warnings for local dev and CI stages console.warn( - `\nโš ๏ธ Missing required environment variables:\n ${missing.join(", ")}\n` + - " Copy .env.example to .env.local and fill in the values.\n" + - " See CONTRIBUTING.md for setup instructions.\n", + `\nโš ๏ธ Missing required environment variables:\n ${missing.join(', ')}\n` + + ' Copy .env.example to .env.local and fill in the values.\n' + + ' See CONTRIBUTING.md for setup instructions.\n', ); } } validateEnv(); -const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); +const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); export default withNextIntl(nextConfig); diff --git a/open-next.config.ts b/open-next.config.ts index b57149b10..c0a54332d 100644 --- a/open-next.config.ts +++ b/open-next.config.ts @@ -1,28 +1,28 @@ -import type { OpenNextConfig } from "@opennextjs/cloudflare"; +import type { OpenNextConfig } from '@opennextjs/cloudflare'; const config: OpenNextConfig = { default: { override: { - wrapper: "cloudflare-node", - converter: "edge", - proxyExternalRequest: "fetch", - incrementalCache: "dummy", - tagCache: "dummy", - queue: "dummy", + wrapper: 'cloudflare-node', + converter: 'edge', + proxyExternalRequest: 'fetch', + incrementalCache: 'dummy', + tagCache: 'dummy', + queue: 'dummy', }, }, - edgeExternals: ["node:crypto"], + edgeExternals: ['node:crypto'], middleware: { external: true, override: { - wrapper: "cloudflare-edge", - converter: "edge", - proxyExternalRequest: "fetch", - incrementalCache: "dummy", - tagCache: "dummy", - queue: "dummy", + wrapper: 'cloudflare-edge', + converter: 'edge', + proxyExternalRequest: 'fetch', + incrementalCache: 'dummy', + tagCache: 'dummy', + queue: 'dummy', }, }, }; diff --git a/playwright.config.ts b/playwright.config.ts index c79db776f..e196fd2f8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, devices } from "@playwright/test"; +import { defineConfig, devices } from '@playwright/test'; /** * End-to-end tests. @@ -19,38 +19,40 @@ import { defineConfig, devices } from "@playwright/test"; * the app is built here with the mock's URL already set rather than having it injected later. */ export default defineConfig({ - testDir: "./e2e", + testDir: './e2e', // Fail the build rather than quietly skip, if someone leaves a `test.only` behind. forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, // One worker on CI: the suite shares a single app server and a single fixture server, so // parallel workers would be racing over the same state for no real gain at this size. workers: process.env.CI ? 1 : undefined, - reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list", + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', use: { - baseURL: "http://127.0.0.1:3000", - trace: "on-first-retry", - screenshot: "only-on-failure", + baseURL: 'http://127.0.0.1:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', }, projects: [ { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, }, ], webServer: [ { - command: "node e2e/mock-supabase.mjs", - url: "http://127.0.0.1:54321/health", + command: 'node e2e/mock-supabase.mjs', + url: 'http://127.0.0.1:54321/health', reuseExistingServer: !process.env.CI, - stdout: "pipe", + stdout: 'pipe', }, { - command: process.env.CI ? "npm run start" : "npm run build && npm run start", - url: "http://127.0.0.1:3000", + command: process.env.CI + ? 'npm run start' + : 'npm run build && npm run start', + url: 'http://127.0.0.1:3000', // A cold Next build is slow; the default 60s is not enough on a CI runner. timeout: 240_000, reuseExistingServer: !process.env.CI, @@ -59,12 +61,12 @@ export default defineConfig({ // Next.js server when Playwright launches `npm run start` in CI. If you add a new server-side // environment variable read at request time, configure it here so the server receives it. env: { - NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:54321", + NEXT_PUBLIC_SUPABASE_URL: 'http://127.0.0.1:54321', // Not real credentials, and not secret โ€” the fixture server ignores them entirely. // They exist only because the client refuses to construct without a key. - NEXT_PUBLIC_SUPABASE_ANON_KEY: "e2e-anon-key", - SUPABASE_SERVICE_ROLE_KEY: "e2e-service-role-key", - NEXT_TELEMETRY_DISABLED: "1", + NEXT_PUBLIC_SUPABASE_ANON_KEY: 'e2e-anon-key', + SUPABASE_SERVICE_ROLE_KEY: 'e2e-service-role-key', + NEXT_TELEMETRY_DISABLED: '1', }, }, ], diff --git a/postcss.config.mjs b/postcss.config.mjs index 61e36849c..297374d80 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,6 +1,6 @@ const config = { plugins: { - "@tailwindcss/postcss": {}, + '@tailwindcss/postcss': {}, }, }; diff --git a/public/sw.js b/public/sw.js index f4ea0a314..dccc4b7fa 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,20 +1,20 @@ -const CACHE_VERSION = "v1"; +const CACHE_VERSION = 'v1'; const STATIC_CACHE = `ossfolio-static-${CACHE_VERSION}`; const API_CACHE = `ossfolio-api-${CACHE_VERSION}`; -const OFFLINE_QUEUE_KEY = "ossfolio-offline-queue"; +const OFFLINE_QUEUE_KEY = 'ossfolio-offline-queue'; -const STATIC_ASSETS = ["/", "/offline", "/logo.png", "/manifest.json"]; +const STATIC_ASSETS = ['/', '/offline', '/logo.png', '/manifest.json']; const API_PATTERNS = [/\/api\//]; -self.addEventListener("install", (event) => { +self.addEventListener('install', (event) => { event.waitUntil( caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS)), ); self.skipWaiting(); }); -self.addEventListener("activate", (event) => { +self.addEventListener('activate', (event) => { event.waitUntil( caches .keys() @@ -29,7 +29,7 @@ self.addEventListener("activate", (event) => { self.clients.claim(); }); -self.addEventListener("fetch", (event) => { +self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); @@ -51,7 +51,7 @@ async function cacheFirst(request) { } return response; } catch { - return caches.match("/offline"); + return caches.match('/offline'); } } @@ -68,8 +68,8 @@ async function networkFirstWithQueue(request) { if (cached) return cached; await addToQueue(request); return new Response( - JSON.stringify({ error: "You are offline. Request will be retried." }), - { status: 503, headers: { "Content-Type": "application/json" } }, + JSON.stringify({ error: 'You are offline. Request will be retried.' }), + { status: 503, headers: { 'Content-Type': 'application/json' } }, ); } } @@ -79,7 +79,7 @@ async function addToQueue(request) { const client = await self.clients.get(self.clientId); if (client) { client.postMessage({ - type: "QUEUE_OFFLINE_REQUEST", + type: 'QUEUE_OFFLINE_REQUEST', payload: { url: request.url, method: request.method }, }); } @@ -87,18 +87,18 @@ async function addToQueue(request) { } function getQueue() { - return JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY) || "[]"); + return JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY) || '[]'); } function setQueue(queue) { localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue)); } -self.addEventListener("message", (event) => { - if (event.data?.type === "SKIP_WAITING") { +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { self.skipWaiting(); } - if (event.data?.type === "RETRY_QUEUE") { + if (event.data?.type === 'RETRY_QUEUE') { retryQueue(); } }); diff --git a/scripts/__tests__/bundle-size.test.ts b/scripts/__tests__/bundle-size.test.ts index 449b646a9..76c5c2b22 100644 --- a/scripts/__tests__/bundle-size.test.ts +++ b/scripts/__tests__/bundle-size.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; // The script is plain JavaScript because CI runs it directly with `node` โ€” // there is no tsx or ts-node in this project, so a .ts entry point could not be @@ -14,28 +14,28 @@ const { formatDelta, renderMarkdownReport, COMMENT_MARKER, -} = await import("../bundle-size.mjs"); +} = await import('../bundle-size.mjs'); let root: string; let staticDir: string; beforeAll(async () => { - root = await mkdtemp(path.join(tmpdir(), "bundle-size-test-")); - staticDir = path.join(root, "static"); - await mkdir(path.join(staticDir, "chunks"), { recursive: true }); - await mkdir(path.join(staticDir, "css"), { recursive: true }); + root = await mkdtemp(path.join(tmpdir(), 'bundle-size-test-')); + staticDir = path.join(root, 'static'); + await mkdir(path.join(staticDir, 'chunks'), { recursive: true }); + await mkdir(path.join(staticDir, 'css'), { recursive: true }); // Repetitive content so gzip compresses meaningfully. await writeFile( - path.join(staticDir, "chunks", "main-abc12345.js"), - "const a=1;".repeat(500), + path.join(staticDir, 'chunks', 'main-abc12345.js'), + 'const a=1;'.repeat(500), ); await writeFile( - path.join(staticDir, "chunks", "framework-deadbeef.js"), - "const b=2;".repeat(1000), + path.join(staticDir, 'chunks', 'framework-deadbeef.js'), + 'const b=2;'.repeat(1000), ); - await writeFile(path.join(staticDir, "css", "styles.css"), "body{margin:0}"); - await writeFile(path.join(staticDir, "manifest.json"), "{}"); + await writeFile(path.join(staticDir, 'css', 'styles.css'), 'body{margin:0}'); + await writeFile(path.join(staticDir, 'manifest.json'), '{}'); }); afterAll(async () => { @@ -44,7 +44,11 @@ afterAll(async () => { const report = ( entries: Array<[string, number]>, -): { files: Array<{ file: string; bytes: number; gzipBytes: number }>; totalBytes: number; totalGzipBytes: number } => { +): { + files: Array<{ file: string; bytes: number; gzipBytes: number }>; + totalBytes: number; + totalGzipBytes: number; +} => { const files = entries.map(([file, gzipBytes]) => ({ file, bytes: gzipBytes * 3, @@ -57,51 +61,57 @@ const report = ( }; }; -describe("measureBundle", () => { - it("measures every JavaScript file", async () => { +describe('measureBundle', () => { + it('measures every JavaScript file', async () => { const result = await measureBundle(staticDir); expect(result.files).toHaveLength(2); }); - it("ignores non-JavaScript assets", async () => { + it('ignores non-JavaScript assets', async () => { // CSS and JSON are not the client JS payload this report is about. const result = await measureBundle(staticDir); const names = result.files.map((f: { file: string }) => f.file); - expect(names.some((n: string) => n.endsWith(".css"))).toBe(false); - expect(names.some((n: string) => n.endsWith(".json"))).toBe(false); + expect(names.some((n: string) => n.endsWith('.css'))).toBe(false); + expect(names.some((n: string) => n.endsWith('.json'))).toBe(false); }); - it("reports paths relative to the measured root, with forward slashes", async () => { + it('reports paths relative to the measured root, with forward slashes', async () => { const result = await measureBundle(staticDir); for (const file of result.files) { - expect(file.file).not.toContain("\\"); - expect(file.file.startsWith("chunks/")).toBe(true); + expect(file.file).not.toContain('\\'); + expect(file.file.startsWith('chunks/')).toBe(true); } }); - it("reports gzip smaller than raw for compressible content", async () => { + it('reports gzip smaller than raw for compressible content', async () => { const result = await measureBundle(staticDir); expect(result.totalGzipBytes).toBeGreaterThan(0); expect(result.totalGzipBytes).toBeLessThan(result.totalBytes); }); - it("totals match the sum of the files", async () => { + it('totals match the sum of the files', async () => { const result = await measureBundle(staticDir); - const raw = result.files.reduce((s: number, f: { bytes: number }) => s + f.bytes, 0); - const gz = result.files.reduce((s: number, f: { gzipBytes: number }) => s + f.gzipBytes, 0); + const raw = result.files.reduce( + (s: number, f: { bytes: number }) => s + f.bytes, + 0, + ); + const gz = result.files.reduce( + (s: number, f: { gzipBytes: number }) => s + f.gzipBytes, + 0, + ); expect(result.totalBytes).toBe(raw); expect(result.totalGzipBytes).toBe(gz); }); - it("returns an empty report for a missing directory rather than throwing", async () => { + it('returns an empty report for a missing directory rather than throwing', async () => { // A base branch that failed to build should mean "no baseline", not a // crashed workflow. - const result = await measureBundle(path.join(root, "does-not-exist")); + const result = await measureBundle(path.join(root, 'does-not-exist')); expect(result.files).toEqual([]); expect(result.totalGzipBytes).toBe(0); }); - it("returns files sorted, so two builds line up", async () => { + it('returns files sorted, so two builds line up', async () => { // Comparing two invocations would pass even without the sort, since readdir // is consistent on a static directory. Comparing against an explicitly // sorted copy actually pins the behaviour. @@ -111,116 +121,146 @@ describe("measureBundle", () => { }); }); -describe("stripContentHash", () => { - it("removes a hex content hash", () => { - expect(stripContentHash("chunks/main-abc12345.js")).toBe("chunks/main.js"); +describe('stripContentHash', () => { + it('removes a hex content hash', () => { + expect(stripContentHash('chunks/main-abc12345.js')).toBe('chunks/main.js'); }); - it("removes a dot-separated hash", () => { - expect(stripContentHash("chunks/page.9f8e7d6c.js")).toBe("chunks/page.js"); + it('removes a dot-separated hash', () => { + expect(stripContentHash('chunks/page.9f8e7d6c.js')).toBe('chunks/page.js'); }); - it("leaves an unhashed name alone", () => { - expect(stripContentHash("chunks/framework.js")).toBe("chunks/framework.js"); - expect(stripContentHash("chunks/polyfills.js")).toBe("chunks/polyfills.js"); + it('leaves an unhashed name alone', () => { + expect(stripContentHash('chunks/framework.js')).toBe('chunks/framework.js'); + expect(stripContentHash('chunks/polyfills.js')).toBe('chunks/polyfills.js'); }); - it("does not mistake a short suffix for a hash", () => { + it('does not mistake a short suffix for a hash', () => { // "page-1" is a name, not a content hash. - expect(stripContentHash("chunks/page-1.js")).toBe("chunks/page-1.js"); + expect(stripContentHash('chunks/page-1.js')).toBe('chunks/page-1.js'); }); - it("normalises a build id directory at the start of a relative path", () => { + it('normalises a build id directory at the start of a relative path', () => { // measureBundle emits paths relative to .next/static, so build ids have no // leading slash. Without this, _buildManifest.js shows as removed-and-added // on every single PR. - expect(stripContentHash("abcdefghij0123456789X/_buildManifest.js")).toBe( - "[buildId]/_buildManifest.js", + expect(stripContentHash('abcdefghij0123456789X/_buildManifest.js')).toBe( + '[buildId]/_buildManifest.js', ); }); - it("normalises a build id directory nested deeper in a path", () => { - expect(stripContentHash("chunks/abcdefghij0123456789X/page.js")).toBe( - "chunks/[buildId]/page.js", + it('normalises a build id directory nested deeper in a path', () => { + expect(stripContentHash('chunks/abcdefghij0123456789X/page.js')).toBe( + 'chunks/[buildId]/page.js', ); }); - it("lets two builds of the same chunk match", () => { - expect(stripContentHash("chunks/main-aaaaaaaa.js")).toBe( - stripContentHash("chunks/main-bbbbbbbb.js"), + it('lets two builds of the same chunk match', () => { + expect(stripContentHash('chunks/main-aaaaaaaa.js')).toBe( + stripContentHash('chunks/main-bbbbbbbb.js'), ); }); }); -describe("diffBundles", () => { - it("reports growth", () => { - const diff = diffBundles(report([["chunks/a-11111111.js", 100]]), report([["chunks/a-22222222.js", 150]])); +describe('diffBundles', () => { + it('reports growth', () => { + const diff = diffBundles( + report([['chunks/a-11111111.js', 100]]), + report([['chunks/a-22222222.js', 150]]), + ); expect(diff.totalGzipDelta).toBe(50); expect(diff.percentChange).toBeCloseTo(50); }); - it("reports shrinkage as a negative delta", () => { - const diff = diffBundles(report([["chunks/a-11111111.js", 200]]), report([["chunks/a-22222222.js", 150]])); + it('reports shrinkage as a negative delta', () => { + const diff = diffBundles( + report([['chunks/a-11111111.js', 200]]), + report([['chunks/a-22222222.js', 150]]), + ); expect(diff.totalGzipDelta).toBe(-50); expect(diff.percentChange).toBeCloseTo(-25); }); - it("matches chunks across builds despite differing hashes", () => { - const diff = diffBundles(report([["chunks/main-aaa11111.js", 100]]), report([["chunks/main-bbb22222.js", 120]])); + it('matches chunks across builds despite differing hashes', () => { + const diff = diffBundles( + report([['chunks/main-aaa11111.js', 100]]), + report([['chunks/main-bbb22222.js', 120]]), + ); expect(diff.changes).toHaveLength(1); - expect(diff.changes[0].status).toBe("changed"); - expect(diff.changes[0].file).toBe("chunks/main.js"); + expect(diff.changes[0].status).toBe('changed'); + expect(diff.changes[0].file).toBe('chunks/main.js'); }); - it("marks a genuinely new chunk as added", () => { - const diff = diffBundles(report([]), report([["chunks/new-11111111.js", 90]])); - expect(diff.changes[0].status).toBe("added"); + it('marks a genuinely new chunk as added', () => { + const diff = diffBundles( + report([]), + report([['chunks/new-11111111.js', 90]]), + ); + expect(diff.changes[0].status).toBe('added'); }); - it("marks a deleted chunk as removed", () => { - const diff = diffBundles(report([["chunks/old-11111111.js", 90]]), report([])); - expect(diff.changes[0].status).toBe("removed"); + it('marks a deleted chunk as removed', () => { + const diff = diffBundles( + report([['chunks/old-11111111.js', 90]]), + report([]), + ); + expect(diff.changes[0].status).toBe('removed'); }); - it("omits unchanged chunks from the change list", () => { + it('omits unchanged chunks from the change list', () => { const diff = diffBundles( - report([["chunks/a-11111111.js", 100], ["chunks/b-11111111.js", 50]]), - report([["chunks/a-22222222.js", 100], ["chunks/b-22222222.js", 80]]), + report([ + ['chunks/a-11111111.js', 100], + ['chunks/b-11111111.js', 50], + ]), + report([ + ['chunks/a-22222222.js', 100], + ['chunks/b-22222222.js', 80], + ]), ); expect(diff.changes).toHaveLength(1); - expect(diff.changes[0].file).toBe("chunks/b.js"); + expect(diff.changes[0].file).toBe('chunks/b.js'); }); - it("orders changes by magnitude regardless of direction", () => { + it('orders changes by magnitude regardless of direction', () => { const diff = diffBundles( - report([["chunks/a-11111111.js", 100], ["chunks/b-11111111.js", 500]]), - report([["chunks/a-22222222.js", 110], ["chunks/b-22222222.js", 100]]), + report([ + ['chunks/a-11111111.js', 100], + ['chunks/b-11111111.js', 500], + ]), + report([ + ['chunks/a-22222222.js', 110], + ['chunks/b-22222222.js', 100], + ]), ); // b shrank by 400, a grew by 10 โ€” the larger movement comes first. - expect(diff.changes[0].file).toBe("chunks/b.js"); + expect(diff.changes[0].file).toBe('chunks/b.js'); }); - it("sums chunks that normalise to the same logical name", () => { + it('sums chunks that normalise to the same logical name', () => { // Two differently-hashed files can collapse to one logical chunk; their // sizes must be added before comparing, not silently overwrite each other. const diff = diffBundles( - report([["chunks/main-aaaaaaaa.js", 100], ["chunks/main-bbbbbbbb.js", 40]]), - report([["chunks/main-cccccccc.js", 200]]), + report([ + ['chunks/main-aaaaaaaa.js', 100], + ['chunks/main-bbbbbbbb.js', 40], + ]), + report([['chunks/main-cccccccc.js', 200]]), ); expect(diff.changes).toHaveLength(1); expect(diff.changes[0]).toMatchObject({ - file: "chunks/main.js", + file: 'chunks/main.js', beforeGzip: 140, afterGzip: 200, deltaGzip: 60, - status: "changed", + status: 'changed', }); }); - it("reports concrete before, after and delta on a changed chunk", () => { + it('reports concrete before, after and delta on a changed chunk', () => { const diff = diffBundles( - report([["chunks/a-11111111.js", 120]]), - report([["chunks/a-22222222.js", 95]]), + report([['chunks/a-11111111.js', 120]]), + report([['chunks/a-22222222.js', 95]]), ); expect(diff.changes[0]).toMatchObject({ beforeGzip: 120, @@ -229,23 +269,26 @@ describe("diffBundles", () => { }); }); - it("carries raw byte totals alongside gzip", () => { + it('carries raw byte totals alongside gzip', () => { const diff = diffBundles( - report([["chunks/a-11111111.js", 100]]), - report([["chunks/a-22222222.js", 150]]), + report([['chunks/a-11111111.js', 100]]), + report([['chunks/a-22222222.js', 150]]), ); expect(diff.totalBytesBefore).toBe(300); expect(diff.totalBytesAfter).toBe(450); expect(diff.totalBytesDelta).toBe(150); }); - it("reports no percentage when there was no baseline", () => { + it('reports no percentage when there was no baseline', () => { // Infinity or 100% would both mislead. - const diff = diffBundles(report([]), report([["chunks/a-11111111.js", 100]])); + const diff = diffBundles( + report([]), + report([['chunks/a-11111111.js', 100]]), + ); expect(diff.percentChange).toBeNull(); }); - it("handles two empty bundles without dividing by zero", () => { + it('handles two empty bundles without dividing by zero', () => { const diff = diffBundles(report([]), report([])); expect(diff.totalGzipDelta).toBe(0); expect(diff.percentChange).toBeNull(); @@ -253,161 +296,187 @@ describe("diffBundles", () => { }); }); -describe("formatBytes / formatDelta", () => { - it("scales units", () => { - expect(formatBytes(512)).toBe("512 B"); - expect(formatBytes(2048)).toBe("2.00 kB"); - expect(formatBytes(5 * 1024 * 1024)).toBe("5.00 MB"); +describe('formatBytes / formatDelta', () => { + it('scales units', () => { + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(2048)).toBe('2.00 kB'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.00 MB'); }); - it("signs a delta so direction is unmistakable", () => { - expect(formatDelta(1024)).toBe("+1.00 kB"); - expect(formatDelta(-1024)).toBe("-1.00 kB"); - expect(formatDelta(0)).toBe("โ€”"); + it('signs a delta so direction is unmistakable', () => { + expect(formatDelta(1024)).toBe('+1.00 kB'); + expect(formatDelta(-1024)).toBe('-1.00 kB'); + expect(formatDelta(0)).toBe('โ€”'); }); - it("scales negative values by magnitude, not sign", () => { - expect(formatBytes(-2048)).toBe("-2.00 kB"); + it('scales negative values by magnitude, not sign', () => { + expect(formatBytes(-2048)).toBe('-2.00 kB'); }); }); -describe("renderMarkdownReport", () => { +describe('renderMarkdownReport', () => { const grew = diffBundles( - report([["chunks/a-11111111.js", 1000]]), - report([["chunks/a-22222222.js", 1500]]), + report([['chunks/a-11111111.js', 1000]]), + report([['chunks/a-22222222.js', 1500]]), ); - it("includes the marker the comment workflow searches for", () => { + it('includes the marker the comment workflow searches for', () => { // Without this the workflow cannot find its previous comment and would // append a new one on every push. expect(renderMarkdownReport(grew)).toContain(COMMENT_MARKER); }); - it("leads with the headline verdict", () => { - expect(renderMarkdownReport(grew)).toContain("Client bundle grew by"); + it('leads with the headline verdict', () => { + expect(renderMarkdownReport(grew)).toContain('Client bundle grew by'); }); - it("says so plainly when the bundle shrinks", () => { + it('says so plainly when the bundle shrinks', () => { const shrank = diffBundles( - report([["chunks/a-11111111.js", 1500]]), - report([["chunks/a-22222222.js", 1000]]), + report([['chunks/a-11111111.js', 1500]]), + report([['chunks/a-22222222.js', 1000]]), ); - expect(renderMarkdownReport(shrank)).toContain("Client bundle shrank by"); + expect(renderMarkdownReport(shrank)).toContain('Client bundle shrank by'); }); - it("reports no change when totals match", () => { + it('reports no change when totals match', () => { const same = diffBundles( - report([["chunks/a-11111111.js", 1000]]), - report([["chunks/a-22222222.js", 1000]]), + report([['chunks/a-11111111.js', 1000]]), + report([['chunks/a-22222222.js', 1000]]), + ); + expect(renderMarkdownReport(same)).toContain( + 'No change to the client bundle.', ); - expect(renderMarkdownReport(same)).toContain("No change to the client bundle."); }); - it("renders a totals table", () => { + it('renders a totals table', () => { const md = renderMarkdownReport(grew); - expect(md).toContain("| **Total (gzipped)** |"); - expect(md).toContain("| --- | ---: | ---: | ---: |"); + expect(md).toContain('| **Total (gzipped)** |'); + expect(md).toContain('| --- | ---: | ---: | ---: |'); }); - it("explains itself when there is no baseline", () => { - const noBase = diffBundles(report([]), report([["chunks/a-11111111.js", 100]])); + it('explains itself when there is no baseline', () => { + const noBase = diffBundles( + report([]), + report([['chunks/a-11111111.js', 100]]), + ); const md = renderMarkdownReport(noBase); - expect(md).toContain("No baseline bundle was available"); - expect(md).not.toContain("NaN"); - expect(md).not.toContain("Infinity"); + expect(md).toContain('No baseline bundle was available'); + expect(md).not.toContain('NaN'); + expect(md).not.toContain('Infinity'); }); - it("collapses a long change list and says how many were hidden", () => { - const many = Array.from({ length: 30 }, (_, i) => [`chunks/c${i}-11111111.js`, 100] as [string, number]); - const after = Array.from({ length: 30 }, (_, i) => [`chunks/c${i}-22222222.js`, 100 + i + 1] as [string, number]); + it('collapses a long change list and says how many were hidden', () => { + const many = Array.from( + { length: 30 }, + (_, i) => [`chunks/c${i}-11111111.js`, 100] as [string, number], + ); + const after = Array.from( + { length: 30 }, + (_, i) => [`chunks/c${i}-22222222.js`, 100 + i + 1] as [string, number], + ); const md = renderMarkdownReport(diffBundles(report(many), report(after)), { maxRows: 5, }); - expect(md).toContain("โ€ฆand 25 more."); + expect(md).toContain('โ€ฆand 25 more.'); }); - it("honours a noise threshold", () => { + it('honours a noise threshold', () => { const md = renderMarkdownReport( - diffBundles(report([["chunks/a-11111111.js", 1000]]), report([["chunks/a-22222222.js", 1005]])), + diffBundles( + report([['chunks/a-11111111.js', 1000]]), + report([['chunks/a-22222222.js', 1005]]), + ), { thresholdBytes: 100 }, ); - expect(md).toContain("No individual chunk changed size."); + expect(md).toContain('No individual chunk changed size.'); }); - it("labels added and removed chunks in the table", () => { + it('labels added and removed chunks in the table', () => { const diff = diffBundles( - report([["chunks/keep-11111111.js", 500], ["chunks/gone-11111111.js", 80]]), - report([["chunks/keep-22222222.js", 500], ["chunks/fresh-33333333.js", 60]]), + report([ + ['chunks/keep-11111111.js', 500], + ['chunks/gone-11111111.js', 80], + ]), + report([ + ['chunks/keep-22222222.js', 500], + ['chunks/fresh-33333333.js', 60], + ]), ); const md = renderMarkdownReport(diff); - expect(md).toContain("_(new)_"); - expect(md).toContain("_(removed)_"); + expect(md).toContain('_(new)_'); + expect(md).toContain('_(removed)_'); }); - it("keeps the added/removed label outside the code span", () => { + it('keeps the added/removed label outside the code span', () => { // Markdown does not interpret underscores inside backticks, so a label // within the code span would render literally as "_(new)_". const diff = diffBundles( - report([["chunks/keep-11111111.js", 500]]), - report([["chunks/keep-22222222.js", 500], ["chunks/fresh-33333333.js", 60]]), + report([['chunks/keep-11111111.js', 500]]), + report([ + ['chunks/keep-22222222.js', 500], + ['chunks/fresh-33333333.js', 60], + ]), ); - expect(renderMarkdownReport(diff)).toContain("` _(new)_"); + expect(renderMarkdownReport(diff)).toContain('` _(new)_'); }); - it("escapes a pipe in a chunk name so the table cannot be broken", () => { + it('escapes a pipe in a chunk name so the table cannot be broken', () => { // Chunk names come from the pull request branch, so they are untrusted. const diff = diffBundles( - report([["chunks/keep-11111111.js", 500]]), - report([["chunks/keep-22222222.js", 500], ["chunks/we|ird-33333333.js", 60]]), + report([['chunks/keep-11111111.js', 500]]), + report([ + ['chunks/keep-22222222.js', 500], + ['chunks/we|ird-33333333.js', 60], + ]), ); - expect(renderMarkdownReport(diff)).toContain("we\\|ird"); + expect(renderMarkdownReport(diff)).toContain('we\\|ird'); }); - it("escapes a backslash before the pipe it precedes", () => { + it('escapes a backslash before the pipe it precedes', () => { // Escaping the pipe first leaves "a\\|b" rendering as a literal backslash // followed by an unescaped pipe, which breaks the cell. The backslash has // to be doubled first. const diff = diffBundles( - report([["chunks/keep-11111111.js", 500]]), + report([['chunks/keep-11111111.js', 500]]), report([ - ["chunks/keep-22222222.js", 500], - ["chunks/a\\|b-33333333.js", 60], + ['chunks/keep-22222222.js', 500], + ['chunks/a\\|b-33333333.js', 60], ]), ); const md = renderMarkdownReport(diff); - expect(md).toContain("a\\\\\\|b"); + expect(md).toContain('a\\\\\\|b'); }); - it("replaces a backtick, which cannot be escaped inside a code span", () => { + it('replaces a backtick, which cannot be escaped inside a code span', () => { const diff = diffBundles( - report([["chunks/keep-11111111.js", 500]]), + report([['chunks/keep-11111111.js', 500]]), report([ - ["chunks/keep-22222222.js", 500], - ["chunks/ti`ck-33333333.js", 60], + ['chunks/keep-22222222.js', 500], + ['chunks/ti`ck-33333333.js', 60], ]), ); const md = renderMarkdownReport(diff); expect(md).toContain("ti'ck"); }); - it("reports the raw total alongside the gzipped one", () => { + it('reports the raw total alongside the gzipped one', () => { const diff = diffBundles( - report([["chunks/a-11111111.js", 100]]), - report([["chunks/a-22222222.js", 150]]), + report([['chunks/a-11111111.js', 100]]), + report([['chunks/a-22222222.js', 150]]), ); - expect(renderMarkdownReport(diff)).toContain("| Total (raw) |"); + expect(renderMarkdownReport(diff)).toContain('| Total (raw) |'); }); - it("never emits NaN or Infinity", () => { + it('never emits NaN or Infinity', () => { for (const diff of [ diffBundles(report([]), report([])), - diffBundles(report([]), report([["chunks/a-11111111.js", 10]])), - diffBundles(report([["chunks/a-11111111.js", 10]]), report([])), + diffBundles(report([]), report([['chunks/a-11111111.js', 10]])), + diffBundles(report([['chunks/a-11111111.js', 10]]), report([])), ]) { const md = renderMarkdownReport(diff); - expect(md).not.toContain("NaN"); - expect(md).not.toContain("Infinity"); - expect(md).not.toContain("undefined"); + expect(md).not.toContain('NaN'); + expect(md).not.toContain('Infinity'); + expect(md).not.toContain('undefined'); } }); }); diff --git a/scripts/__tests__/sql-lint.test.ts b/scripts/__tests__/sql-lint.test.ts index 133ffc3da..7d703d8e3 100644 --- a/scripts/__tests__/sql-lint.test.ts +++ b/scripts/__tests__/sql-lint.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest'; // The script is plain JavaScript because CI runs it directly with `node` โ€” // there is no tsx or ts-node in this project, so a .ts entry point could not be @@ -13,130 +13,140 @@ const { schemaSnapshotUpdated, lintMigrations, renderReport, -} = await import("../sql-lint.mjs"); +} = await import('../sql-lint.mjs'); -describe("parseVersion", () => { - it("reads the 14-digit ordering prefix", () => { - expect(parseVersion("20260726000000_add_achievement_unlocks.sql")).toBe( - "20260726000000", +describe('parseVersion', () => { + it('reads the 14-digit ordering prefix', () => { + expect(parseVersion('20260726000000_add_achievement_unlocks.sql')).toBe( + '20260726000000', ); }); - it("works on a full path", () => { - expect(parseVersion("supabase/migrations/20260520000001_initial.sql")).toBe( - "20260520000001", + it('works on a full path', () => { + expect(parseVersion('supabase/migrations/20260520000001_initial.sql')).toBe( + '20260520000001', ); }); - it("returns null when there is no version prefix", () => { - expect(parseVersion("schema.sql")).toBeNull(); - expect(parseVersion("2026_short.sql")).toBeNull(); + it('returns null when there is no version prefix', () => { + expect(parseVersion('schema.sql')).toBeNull(); + expect(parseVersion('2026_short.sql')).toBeNull(); }); }); -describe("findDuplicateVersions", () => { - it("finds nothing when every version is distinct", () => { +describe('findDuplicateVersions', () => { + it('finds nothing when every version is distinct', () => { expect( - findDuplicateVersions(["20260101000000_a.sql", "20260102000000_b.sql"]), + findDuplicateVersions(['20260101000000_a.sql', '20260102000000_b.sql']), ).toEqual([]); }); - it("reports a collision with both filenames", () => { + it('reports a collision with both filenames', () => { expect( findDuplicateVersions([ - "20260726000000_a.sql", - "20260726000000_b.sql", - "20260727000000_c.sql", + '20260726000000_a.sql', + '20260726000000_b.sql', + '20260727000000_c.sql', ]), ).toEqual([ { - version: "20260726000000", - files: ["20260726000000_a.sql", "20260726000000_b.sql"], + version: '20260726000000', + files: ['20260726000000_a.sql', '20260726000000_b.sql'], }, ]); }); - it("ignores files without a version prefix", () => { - expect(findDuplicateVersions(["schema.sql", "README.md"])).toEqual([]); + it('ignores files without a version prefix', () => { + expect(findDuplicateVersions(['schema.sql', 'README.md'])).toEqual([]); }); }); -describe("stripComments", () => { - it("removes line comments", () => { - expect(stripComments("-- create table ghost (x int);\nselect 1;")).not.toMatch( - /ghost/, - ); +describe('stripComments', () => { + it('removes line comments', () => { + expect( + stripComments('-- create table ghost (x int);\nselect 1;'), + ).not.toMatch(/ghost/); }); - it("removes block comments", () => { - expect(stripComments("/* create table ghost (x int); */ select 1;")).not.toMatch( - /ghost/, - ); + it('removes block comments', () => { + expect( + stripComments('/* create table ghost (x int); */ select 1;'), + ).not.toMatch(/ghost/); }); }); -describe("extractCreatedTables", () => { - it("finds a plain create", () => { - expect(extractCreatedTables("create table profiles (id uuid);")).toEqual([ - "profiles", +describe('extractCreatedTables', () => { + it('finds a plain create', () => { + expect(extractCreatedTables('create table profiles (id uuid);')).toEqual([ + 'profiles', ]); }); - it("handles IF NOT EXISTS and a public. prefix", () => { + it('handles IF NOT EXISTS and a public. prefix', () => { expect( - extractCreatedTables("CREATE TABLE IF NOT EXISTS public.snapshots (id int);"), - ).toEqual(["snapshots"]); + extractCreatedTables( + 'CREATE TABLE IF NOT EXISTS public.snapshots (id int);', + ), + ).toEqual(['snapshots']); }); - it("handles quoted identifiers", () => { - expect(extractCreatedTables('create table "public"."my_table" (id int);')).toEqual( - ["my_table"], - ); + it('handles quoted identifiers', () => { + expect( + extractCreatedTables('create table "public"."my_table" (id int);'), + ).toEqual(['my_table']); }); - it("finds several tables in one migration", () => { + it('finds several tables in one migration', () => { expect( extractCreatedTables( - "create table orgs (id int);\ncreate table org_members (id int);", + 'create table orgs (id int);\ncreate table org_members (id int);', ), - ).toEqual(["orgs", "org_members"]); + ).toEqual(['orgs', 'org_members']); }); - it("does not treat a commented-out create as real", () => { + it('does not treat a commented-out create as real', () => { expect( - extractCreatedTables("-- create table ghost (id int);\ncreate table real (id int);"), - ).toEqual(["real"]); + extractCreatedTables( + '-- create table ghost (id int);\ncreate table real (id int);', + ), + ).toEqual(['real']); }); - it("is case-insensitive and tolerant of extra whitespace", () => { - expect(extractCreatedTables("CrEaTe TABLE\n Weird (id int);")).toEqual([ - "weird", + it('is case-insensitive and tolerant of extra whitespace', () => { + expect(extractCreatedTables('CrEaTe TABLE\n Weird (id int);')).toEqual([ + 'weird', ]); }); }); -describe("extractRlsEnabledTables", () => { - it("recognises the standard statement", () => { +describe('extractRlsEnabledTables', () => { + it('recognises the standard statement', () => { expect( - extractRlsEnabledTables("alter table public.profiles enable row level security;"), - ).toEqual(["profiles"]); + extractRlsEnabledTables( + 'alter table public.profiles enable row level security;', + ), + ).toEqual(['profiles']); }); - it("is case-insensitive and handles no schema prefix", () => { + it('is case-insensitive and handles no schema prefix', () => { expect( - extractRlsEnabledTables("ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;"), - ).toEqual(["profiles"]); + extractRlsEnabledTables( + 'ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;', + ), + ).toEqual(['profiles']); }); - it("does not match a disable statement", () => { + it('does not match a disable statement', () => { expect( - extractRlsEnabledTables("alter table profiles disable row level security;"), + extractRlsEnabledTables( + 'alter table profiles disable row level security;', + ), ).toEqual([]); }); }); -describe("tablesMissingRls", () => { - it("passes when every created table enables RLS", () => { +describe('tablesMissingRls', () => { + it('passes when every created table enables RLS', () => { const sql = ` create table public.widgets (id int); alter table public.widgets enable row level security; @@ -144,22 +154,22 @@ describe("tablesMissingRls", () => { expect(tablesMissingRls(sql)).toEqual([]); }); - it("flags a table created without RLS", () => { - expect(tablesMissingRls("create table public.widgets (id int);")).toEqual([ - "widgets", + it('flags a table created without RLS', () => { + expect(tablesMissingRls('create table public.widgets (id int);')).toEqual([ + 'widgets', ]); }); - it("flags only the table that is missing it", () => { + it('flags only the table that is missing it', () => { const sql = ` create table a (id int); create table b (id int); alter table a enable row level security; `; - expect(tablesMissingRls(sql)).toEqual(["b"]); + expect(tablesMissingRls(sql)).toEqual(['b']); }); - it("passes a table with RLS on and no policies โ€” that is a deliberate pattern here", () => { + it('passes a table with RLS on and no policies โ€” that is a deliberate pattern here', () => { // profile_snapshots documents exactly this: RLS on, zero write policies, // because the only writer is the service-role background sync. const sql = ` @@ -170,147 +180,147 @@ describe("tablesMissingRls", () => { }); }); -describe("schemaSnapshotUpdated", () => { - it("is not required when no migration changed", () => { - expect(schemaSnapshotUpdated(["src/lib/db.ts"])).toEqual({ +describe('schemaSnapshotUpdated', () => { + it('is not required when no migration changed', () => { + expect(schemaSnapshotUpdated(['src/lib/db.ts'])).toEqual({ required: false, satisfied: true, }); }); - it("is satisfied when both a migration and the snapshot changed", () => { + it('is satisfied when both a migration and the snapshot changed', () => { expect( schemaSnapshotUpdated([ - "supabase/migrations/20260101000000_a.sql", - "supabase/schema.sql", + 'supabase/migrations/20260101000000_a.sql', + 'supabase/schema.sql', ]), ).toEqual({ required: true, satisfied: true }); }); - it("is unsatisfied when only a migration changed", () => { + it('is unsatisfied when only a migration changed', () => { expect( - schemaSnapshotUpdated(["supabase/migrations/20260101000000_a.sql"]), + schemaSnapshotUpdated(['supabase/migrations/20260101000000_a.sql']), ).toEqual({ required: true, satisfied: false }); }); - it("normalises Windows path separators", () => { + it('normalises Windows path separators', () => { expect( schemaSnapshotUpdated([ - "supabase\\migrations\\20260101000000_a.sql", - "supabase/schema.sql", + 'supabase\\migrations\\20260101000000_a.sql', + 'supabase/schema.sql', ]), ).toEqual({ required: true, satisfied: true }); }); }); -describe("lintMigrations", () => { +describe('lintMigrations', () => { const clean = { - allMigrations: ["20260101000000_a.sql", "20260102000000_b.sql"], + allMigrations: ['20260101000000_a.sql', '20260102000000_b.sql'], changedFiles: [ - "supabase/migrations/20260102000000_b.sql", - "supabase/schema.sql", + 'supabase/migrations/20260102000000_b.sql', + 'supabase/schema.sql', ], contentsByFile: { - "supabase/migrations/20260102000000_b.sql": - "create table b (id int); alter table b enable row level security;", + 'supabase/migrations/20260102000000_b.sql': + 'create table b (id int); alter table b enable row level security;', }, }; - it("reports nothing for a well-formed migration", () => { + it('reports nothing for a well-formed migration', () => { expect(lintMigrations(clean)).toEqual({ errors: [], warnings: [] }); }); - it("errors when a new table has no RLS", () => { + it('errors when a new table has no RLS', () => { const result = lintMigrations({ ...clean, contentsByFile: { - "supabase/migrations/20260102000000_b.sql": "create table b (id int);", + 'supabase/migrations/20260102000000_b.sql': 'create table b (id int);', }, }); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toContain('table "b"'); - expect(result.errors[0]).toContain("ENABLE ROW LEVEL SECURITY"); + expect(result.errors[0]).toContain('ENABLE ROW LEVEL SECURITY'); }); - it("errors when the schema snapshot was not updated alongside", () => { + it('errors when the schema snapshot was not updated alongside', () => { const result = lintMigrations({ ...clean, - changedFiles: ["supabase/migrations/20260102000000_b.sql"], + changedFiles: ['supabase/migrations/20260102000000_b.sql'], }); expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain("schema.sql"); + expect(result.errors[0]).toContain('schema.sql'); }); - it("errors on a version collision this pull request introduced", () => { + it('errors on a version collision this pull request introduced', () => { const result = lintMigrations({ - allMigrations: ["20260101000000_a.sql", "20260101000000_b.sql"], + allMigrations: ['20260101000000_a.sql', '20260101000000_b.sql'], changedFiles: [ - "supabase/migrations/20260101000000_b.sql", - "supabase/schema.sql", + 'supabase/migrations/20260101000000_b.sql', + 'supabase/schema.sql', ], contentsByFile: { - "supabase/migrations/20260101000000_b.sql": - "create table b (id int); alter table b enable row level security;", + 'supabase/migrations/20260101000000_b.sql': + 'create table b (id int); alter table b enable row level security;', }, }); expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain("20260101000000"); + expect(result.errors[0]).toContain('20260101000000'); expect(result.warnings).toHaveLength(0); }); - it("only warns about a collision the pull request did not touch", () => { + it('only warns about a collision the pull request did not touch', () => { // A contributor editing an unrelated file should not inherit a red check for // history they had no part in. const result = lintMigrations({ - allMigrations: ["20260101000000_a.sql", "20260101000000_b.sql"], - changedFiles: ["src/lib/db.ts"], + allMigrations: ['20260101000000_a.sql', '20260101000000_b.sql'], + changedFiles: ['src/lib/db.ts'], contentsByFile: {}, }); expect(result.errors).toEqual([]); expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain("20260101000000"); + expect(result.warnings[0]).toContain('20260101000000'); }); - it("reports several problems at once rather than stopping at the first", () => { + it('reports several problems at once rather than stopping at the first', () => { const result = lintMigrations({ - allMigrations: ["20260101000000_a.sql"], - changedFiles: ["supabase/migrations/20260101000000_a.sql"], + allMigrations: ['20260101000000_a.sql'], + changedFiles: ['supabase/migrations/20260101000000_a.sql'], contentsByFile: { - "supabase/migrations/20260101000000_a.sql": - "create table a (id int); create table b (id int);", + 'supabase/migrations/20260101000000_a.sql': + 'create table a (id int); create table b (id int);', }, }); expect(result.errors).toHaveLength(3); }); - it("ignores changed files outside the migrations directory", () => { + it('ignores changed files outside the migrations directory', () => { expect( lintMigrations({ - allMigrations: ["20260101000000_a.sql"], - changedFiles: ["README.md", "src/lib/db.ts"], + allMigrations: ['20260101000000_a.sql'], + changedFiles: ['README.md', 'src/lib/db.ts'], contentsByFile: {}, }), ).toEqual({ errors: [], warnings: [] }); }); }); -describe("renderReport", () => { - it("says so plainly when nothing is wrong", () => { +describe('renderReport', () => { + it('says so plainly when nothing is wrong', () => { expect(renderReport({ errors: [], warnings: [] })).toContain( - "No issues found", + 'No issues found', ); }); - it("lists errors with a count", () => { - const out = renderReport({ errors: ["first", "second"], warnings: [] }); - expect(out).toContain("Errors (2)"); - expect(out).toContain("- first"); - expect(out).toContain("- second"); + it('lists errors with a count', () => { + const out = renderReport({ errors: ['first', 'second'], warnings: [] }); + expect(out).toContain('Errors (2)'); + expect(out).toContain('- first'); + expect(out).toContain('- second'); }); - it("marks warnings as pre-existing so they are not mistaken for regressions", () => { - const out = renderReport({ errors: [], warnings: ["old collision"] }); - expect(out).toContain("Warnings (1)"); - expect(out).toContain("Pre-existing"); + it('marks warnings as pre-existing so they are not mistaken for regressions', () => { + const out = renderReport({ errors: [], warnings: ['old collision'] }); + expect(out).toContain('Warnings (1)'); + expect(out).toContain('Pre-existing'); }); }); diff --git a/scripts/bundle-size.mjs b/scripts/bundle-size.mjs index ef8558f48..c0f0f3aa4 100644 --- a/scripts/bundle-size.mjs +++ b/scripts/bundle-size.mjs @@ -1,6 +1,6 @@ -import { readdir, readFile, stat, writeFile } from "node:fs/promises"; -import { gzipSync } from "node:zlib"; -import path from "node:path"; +import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { gzipSync } from 'node:zlib'; +import path from 'node:path'; /** * Bundle size measurement and comparison. @@ -30,16 +30,16 @@ import path from "node:path"; */ export const stripContentHash = (file) => file - .replace(/[-.][a-f0-9]{8,}(?=\.[a-z]+$)/gi, "") - .replace(/[-.][a-z0-9]{16,}(?=\.[a-z]+$)/gi, "") + .replace(/[-.][a-f0-9]{8,}(?=\.[a-z]+$)/gi, '') + .replace(/[-.][a-z0-9]{16,}(?=\.[a-z]+$)/gi, '') // Build-id directories arrive without a leading slash, because paths are // relative to .next/static. Anchoring at the start as well as after a // slash is what makes _buildManifest.js match across builds instead of // showing as removed-and-added on every PR. - .replace(/(^|\/)[a-zA-Z0-9_-]{21}\//g, "$1[buildId]/"); + .replace(/(^|\/)[a-zA-Z0-9_-]{21}\//g, '$1[buildId]/'); /** Recursively lists every `.js` file beneath `root`. */ -const listJsFiles = async (root, prefix = "") => { +const listJsFiles = async (root, prefix = '') => { let entries; try { entries = await readdir(root, { withFileTypes: true }); @@ -53,7 +53,7 @@ const listJsFiles = async (root, prefix = "") => { const abs = path.join(root, entry.name); if (entry.isDirectory()) { found.push(...(await listJsFiles(abs, rel))); - } else if (entry.isFile() && entry.name.endsWith(".js")) { + } else if (entry.isFile() && entry.name.endsWith('.js')) { found.push(rel); } } @@ -122,7 +122,8 @@ export const diffBundles = (before, after) => { changes.push({ file: name, - status: beforeGzip === 0 ? "added" : afterGzip === 0 ? "removed" : "changed", + status: + beforeGzip === 0 ? 'added' : afterGzip === 0 ? 'removed' : 'changed', beforeGzip, afterGzip, deltaGzip: afterGzip - beforeGzip, @@ -161,8 +162,8 @@ export const formatBytes = (bytes) => { /** Signed size, so a reader can tell growth from shrinkage at a glance. */ export const formatDelta = (bytes) => { - if (bytes === 0) return "โ€”"; - const sign = bytes > 0 ? "+" : ""; + if (bytes === 0) return 'โ€”'; + const sign = bytes > 0 ? '+' : ''; return `${sign}${formatBytes(bytes)}`; }; @@ -178,14 +179,14 @@ const escapeTableCell = (value) => // Backslashes first: escaping the pipe before the backslash leaves // "a\\|b" rendering as a literal backslash followed by an *unescaped* // pipe, which breaks the cell the escaping was meant to protect. - .replace(/\\/g, "\\\\") - .replace(/\|/g, "\\|") + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') // A backtick cannot be escaped inside a code span โ€” it can only be // replaced โ€” so this substitutes rather than escapes. .replace(/`/g, "'"); /** Marker used to find and replace this workflow's previous comment. */ -export const COMMENT_MARKER = ""; +export const COMMENT_MARKER = ''; /** * Renders the diff as the Markdown comment body. @@ -197,38 +198,38 @@ export const renderMarkdownReport = (diff, options = {}) => { const maxRows = options.maxRows ?? 15; const threshold = options.thresholdBytes ?? 0; - const lines = [COMMENT_MARKER, "## Bundle size report", ""]; + const lines = [COMMENT_MARKER, '## Bundle size report', '']; if (diff.totalGzipBefore === 0) { lines.push( - "No baseline bundle was available for the base branch, so only the current total is shown.", - "", + 'No baseline bundle was available for the base branch, so only the current total is shown.', + '', `**Total (gzipped):** ${formatBytes(diff.totalGzipAfter)}`, - "", + '', ); - return lines.join("\n"); + return lines.join('\n'); } const percent = diff.percentChange === null - ? "โ€”" - : `${diff.percentChange >= 0 ? "+" : ""}${diff.percentChange.toFixed(2)}%`; + ? 'โ€”' + : `${diff.percentChange >= 0 ? '+' : ''}${diff.percentChange.toFixed(2)}%`; const verdict = diff.totalGzipDelta === 0 - ? "No change to the client bundle." + ? 'No change to the client bundle.' : diff.totalGzipDelta > 0 ? `Client bundle grew by **${formatBytes(diff.totalGzipDelta)}** gzipped.` : `Client bundle shrank by **${formatBytes(Math.abs(diff.totalGzipDelta))}** gzipped.`; lines.push( verdict, - "", - "| | Base | This PR | Change |", - "| --- | ---: | ---: | ---: |", + '', + '| | Base | This PR | Change |', + '| --- | ---: | ---: | ---: |', `| **Total (gzipped)** | ${formatBytes(diff.totalGzipBefore)} | ${formatBytes(diff.totalGzipAfter)} | ${formatDelta(diff.totalGzipDelta)} (${percent}) |`, `| Total (raw) | ${formatBytes(diff.totalBytesBefore)} | ${formatBytes(diff.totalBytesAfter)} | ${formatDelta(diff.totalBytesDelta)} |`, - "", + '', ); const notable = diff.changes.filter( @@ -236,30 +237,30 @@ export const renderMarkdownReport = (diff, options = {}) => { ); if (notable.length === 0) { - lines.push("No individual chunk changed size.", ""); - return lines.join("\n"); + lines.push('No individual chunk changed size.', ''); + return lines.join('\n'); } const shown = notable.slice(0, maxRows); const hidden = notable.length - shown.length; lines.push( - "
", + '
', `Per-chunk changes (${notable.length})`, - "", - "| Chunk | Base | This PR | Change |", - "| --- | ---: | ---: | ---: |", + '', + '| Chunk | Base | This PR | Change |', + '| --- | ---: | ---: | ---: |', ); for (const change of shown) { // The suffix sits outside the code span: Markdown does not interpret // underscores inside backticks, so "_(new)_" would render literally. const suffix = - change.status === "added" - ? " _(new)_" - : change.status === "removed" - ? " _(removed)_" - : ""; + change.status === 'added' + ? ' _(new)_' + : change.status === 'removed' + ? ' _(removed)_' + : ''; lines.push( `| \`${escapeTableCell(change.file)}\`${suffix} | ${formatBytes(change.beforeGzip)} | ` + `${formatBytes(change.afterGzip)} | ${formatDelta(change.deltaGzip)} |`, @@ -267,15 +268,15 @@ export const renderMarkdownReport = (diff, options = {}) => { } if (hidden > 0) { - lines.push("", `_โ€ฆand ${hidden} more._`); + lines.push('', `_โ€ฆand ${hidden} more._`); } - lines.push("", "
", ""); + lines.push('', '
', ''); lines.push( - "Measured from `.next/static`, gzipped. Content hashes are stripped so chunks match across builds.", + 'Measured from `.next/static`, gzipped. Content hashes are stripped so chunks match across builds.', ); - return lines.join("\n"); + return lines.join('\n'); }; // --------------------------------------------------------------------------- @@ -292,8 +293,8 @@ export const renderMarkdownReport = (diff, options = {}) => { // text it was handed. // --------------------------------------------------------------------------- -import { fileURLToPath } from "node:url"; -import process from "node:process"; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; const isDirectRun = process.argv[1] && @@ -301,7 +302,7 @@ const isDirectRun = const readJson = async (file) => { try { - return JSON.parse(await readFile(file, "utf-8")); + return JSON.parse(await readFile(file, 'utf-8')); } catch { // A missing or unreadable baseline is normal โ€” the base branch may have // failed to build. Treat it as "no bundle" rather than crashing. @@ -312,13 +313,13 @@ const readJson = async (file) => { const main = async () => { const [command, ...args] = process.argv.slice(2); - if (command === "measure") { + if (command === 'measure') { const [staticDir, outFile] = args; if (!staticDir || !outFile) { - throw new Error("usage: bundle-size.mjs measure "); + throw new Error('usage: bundle-size.mjs measure '); } const report = await measureBundle(staticDir); - await writeFile(outFile, JSON.stringify(report, null, 2), "utf-8"); + await writeFile(outFile, JSON.stringify(report, null, 2), 'utf-8'); console.log( `[bundle-size] ${report.files.length} JS files, ` + `${formatBytes(report.totalGzipBytes)} gzipped -> ${outFile}`, @@ -326,22 +327,25 @@ const main = async () => { return; } - if (command === "report") { + if (command === 'report') { const [baseFile, headFile, outFile] = args; if (!baseFile || !headFile || !outFile) { throw new Error( - "usage: bundle-size.mjs report ", + 'usage: bundle-size.mjs report ', ); } - const diff = diffBundles(await readJson(baseFile), await readJson(headFile)); - await writeFile(outFile, renderMarkdownReport(diff), "utf-8"); + const diff = diffBundles( + await readJson(baseFile), + await readJson(headFile), + ); + await writeFile(outFile, renderMarkdownReport(diff), 'utf-8'); console.log( `[bundle-size] delta ${formatDelta(diff.totalGzipDelta)} gzipped -> ${outFile}`, ); return; } - throw new Error(`Unknown command: ${command ?? "(none)"}`); + throw new Error(`Unknown command: ${command ?? '(none)'}`); }; if (isDirectRun) { diff --git a/scripts/sql-lint.mjs b/scripts/sql-lint.mjs index e7287b9da..bc5ab6187 100644 --- a/scripts/sql-lint.mjs +++ b/scripts/sql-lint.mjs @@ -14,12 +14,12 @@ // and teach contributors to skip it. Syntax is validated separately by // `sqlfluff parse`, which passes on all 20 today. -import { readFile, readdir } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -export const MIGRATIONS_DIR = "supabase/migrations"; -export const SCHEMA_FILE = "supabase/schema.sql"; +export const MIGRATIONS_DIR = 'supabase/migrations'; +export const SCHEMA_FILE = 'supabase/schema.sql'; /** * Remove SQL comments before pattern matching. @@ -28,7 +28,7 @@ export const SCHEMA_FILE = "supabase/schema.sql"; * read as a real table definition and demand an RLS policy that should not exist. */ export const stripComments = (sql) => - sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " "); + sql.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' '); /** * Pull the ordering prefix out of a migration filename. @@ -98,9 +98,9 @@ export const tablesMissingRls = (sql) => { * so that `schema.sql` stays a truthful picture of the database. */ export const schemaSnapshotUpdated = (changedFiles) => { - const normalised = changedFiles.map((file) => file.replace(/\\/g, "/")); + const normalised = changedFiles.map((file) => file.replace(/\\/g, '/')); const touchedMigration = normalised.some( - (file) => file.startsWith(`${MIGRATIONS_DIR}/`) && file.endsWith(".sql"), + (file) => file.startsWith(`${MIGRATIONS_DIR}/`) && file.endsWith('.sql'), ); if (!touchedMigration) return { required: false, satisfied: true }; @@ -126,9 +126,9 @@ export const lintMigrations = ({ const warnings = []; const changedMigrations = changedFiles - .map((file) => file.replace(/\\/g, "/")) + .map((file) => file.replace(/\\/g, '/')) .filter( - (file) => file.startsWith(`${MIGRATIONS_DIR}/`) && file.endsWith(".sql"), + (file) => file.startsWith(`${MIGRATIONS_DIR}/`) && file.endsWith('.sql'), ); const changedBasenames = new Set( changedMigrations.map((file) => path.basename(file)), @@ -138,7 +138,7 @@ export const lintMigrations = ({ const involvesThisPr = duplicate.files.some((file) => changedBasenames.has(file), ); - const message = `Migration version ${duplicate.version} is used by ${duplicate.files.length} files: ${duplicate.files.join(", ")}. Supabase orders migrations by this prefix, so their apply order is undefined.`; + const message = `Migration version ${duplicate.version} is used by ${duplicate.files.length} files: ${duplicate.files.join(', ')}. Supabase orders migrations by this prefix, so their apply order is undefined.`; (involvesThisPr ? errors : warnings).push(message); } @@ -166,29 +166,29 @@ export const lintMigrations = ({ /** Render findings as a GitHub step summary. */ export const renderReport = ({ errors, warnings }) => { - const lines = ["## Migration checks", ""]; + const lines = ['## Migration checks', '']; if (errors.length === 0 && warnings.length === 0) { - lines.push("No issues found."); - return lines.join("\n"); + lines.push('No issues found.'); + return lines.join('\n'); } if (errors.length > 0) { - lines.push(`### Errors (${errors.length})`, ""); + lines.push(`### Errors (${errors.length})`, ''); for (const error of errors) lines.push(`- ${error}`); - lines.push(""); + lines.push(''); } if (warnings.length > 0) { - lines.push(`### Warnings (${warnings.length})`, ""); + lines.push(`### Warnings (${warnings.length})`, ''); lines.push( - "_Pre-existing, and not caused by this pull request. Reported so they stay visible._", - "", + '_Pre-existing, and not caused by this pull request. Reported so they stay visible._', + '', ); for (const warning of warnings) lines.push(`- ${warning}`); } - return lines.join("\n").trimEnd(); + return lines.join('\n').trimEnd(); }; const isDirectRun = @@ -199,23 +199,29 @@ if (isDirectRun) { const changedFiles = process.argv.slice(2).filter(Boolean); const entries = await readdir(MIGRATIONS_DIR).catch(() => []); - const allMigrations = entries.filter((entry) => entry.endsWith(".sql")).sort(); + const allMigrations = entries + .filter((entry) => entry.endsWith('.sql')) + .sort(); const contentsByFile = {}; for (const file of changedFiles) { - const normalised = file.replace(/\\/g, "/"); + const normalised = file.replace(/\\/g, '/'); if ( !normalised.startsWith(`${MIGRATIONS_DIR}/`) || - !normalised.endsWith(".sql") + !normalised.endsWith('.sql') ) { continue; } - contentsByFile[normalised] = await readFile(normalised, "utf8").catch( + contentsByFile[normalised] = await readFile(normalised, 'utf8').catch( () => undefined, ); } - const result = lintMigrations({ allMigrations, changedFiles, contentsByFile }); + const result = lintMigrations({ + allMigrations, + changedFiles, + contentsByFile, + }); const report = renderReport(result); console.log(report); diff --git a/src/app/[username]/error.tsx b/src/app/[username]/error.tsx index 445aaacbc..6817c65e3 100644 --- a/src/app/[username]/error.tsx +++ b/src/app/[username]/error.tsx @@ -1,9 +1,15 @@ -"use client"; +'use client'; -import { useEffect } from "react"; -import Link from "next/link"; +import { useEffect } from 'react'; +import Link from 'next/link'; -export default function ProfileError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { +export default function ProfileError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { useEffect(() => { console.error(error); }, [error]); @@ -12,7 +18,8 @@ export default function ProfileError({ error, reset }: { error: Error & { digest

Something went wrong!

- {error.message || "We encountered an error while loading this profile. The user might not exist or the API rate limit was reached."} + {error.message || + 'We encountered an error while loading this profile. The user might not exist or the API rate limit was reached.'}