diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 949f6b22..7e39c183 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,31 @@ name: CI on: pull_request: + types: [opened, synchronize, reopened, closed] branches: [main] +permissions: + contents: write + pull-requests: write + jobs: + lint: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - run: npm run lint + test: + if: github.event.action != 'closed' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,8 +38,48 @@ jobs: - run: npm ci - - run: npm test + - run: npx vitest run --coverage - - run: npm run build + preview: + if: always() && (github.event.action == 'closed' || (needs.lint.result == 'success' && needs.test.result == 'success')) + needs: [lint, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - if: github.event.action != 'closed' + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - if: github.event.action != 'closed' + run: npm ci + + - if: github.event.action != 'closed' + name: Build env: - NEXT_PUBLIC_BASE_PATH: /leetcode-patterns + NEXT_PUBLIC_BASE_PATH: /leetcode-patterns/pr-preview/pr-${{ github.event.number }} + run: npx next build + + - if: github.event.action != 'closed' + name: Remove service worker from preview + run: rm -f out/sw.js + + - name: Ensure .nojekyll exists at gh-pages root + run: | + git fetch origin gh-pages:gh-pages || true + if git rev-parse --verify gh-pages >/dev/null 2>&1; then + if ! git show gh-pages:.nojekyll >/dev/null 2>&1; then + git checkout gh-pages + touch .nojekyll + git add .nojekyll + git -c user.name="github-actions[bot]" -c user.email="github-actions[bot]@users.noreply.github.com" commit -m "Add .nojekyll for Next.js _next/ assets" + git push origin gh-pages + git checkout - + fi + fi + + - uses: rossjrw/pr-preview-action@v1 + with: + source-dir: out diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 41b69cea..b12f5b5c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,16 +12,10 @@ on: workflow_dispatch: permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false + contents: write jobs: - build: + deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -36,18 +30,11 @@ jobs: - name: Build env: NEXT_PUBLIC_BASE_PATH: /leetcode-patterns - run: npx next build + run: npm run build - - uses: actions/upload-pages-artifact@v3 + - uses: JamesIves/github-pages-deploy-action@v4 with: - path: out - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@v4 + folder: out + branch: gh-pages + clean-exclude: pr-preview + force: false diff --git a/package-lock.json b/package-lock.json index dadf233b..ee8a8be5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^5.1.4", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^9", "eslint-config-next": "16.1.6", "happy-dom": "^20.8.3", @@ -384,6 +385,15 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3286,6 +3296,36 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -3643,6 +3683,23 @@ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -5321,6 +5378,12 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5825,6 +5888,42 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -6333,6 +6432,44 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index 8241a414..0b81c201 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^5.1.4", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^9", "eslint-config-next": "16.1.6", "happy-dom": "^20.8.3", diff --git a/src/components/panels/AboutPanel.tsx b/src/components/panels/AboutPanel.tsx index d2bd9b4e..33d5bfa9 100644 --- a/src/components/panels/AboutPanel.tsx +++ b/src/components/panels/AboutPanel.tsx @@ -71,7 +71,7 @@ export default function AboutPanel() {

I believe everyone deserves - access to high-quality interview prep - regardless of their financial situation. It's why I chose to make this website{" "} + access to high-quality interview prep - regardless of their financial situation. It's why I chose to make this website{" "} free and open source.

diff --git a/src/components/questions/FilterToolbar.tsx b/src/components/questions/FilterToolbar.tsx index ae31170c..9b8e57ea 100644 --- a/src/components/questions/FilterToolbar.tsx +++ b/src/components/questions/FilterToolbar.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect, useRef, useCallback } from "react"; +import { useState, useMemo, useEffect, useRef } from "react"; import { type Table } from "@tanstack/react-table"; import { Question } from "@/types/question"; import { RotateCcw, Shuffle, Download, Upload, Trash2, StarOff, Dices, ListOrdered } from "lucide-react"; @@ -63,6 +63,7 @@ export default function FilterToolbar({ }: FilterToolbarProps) { const difficultyFilter = useMemo( () => (table.getColumn("difficulty")?.getFilterValue() as string[]) ?? [], + // eslint-disable-next-line react-hooks/exhaustive-deps [table, columnFilters] ); const [difficultyDropdownOpen, setDifficultyDropdownOpen] = useState(false); @@ -70,6 +71,7 @@ export default function FilterToolbar({ const patternFilter = useMemo( () => (table.getColumn("pattern")?.getFilterValue() as string[]) ?? [], + // eslint-disable-next-line react-hooks/exhaustive-deps [table, columnFilters] ); const [patternDropdownOpen, setPatternDropdownOpen] = useState(false); @@ -92,6 +94,7 @@ export default function FilterToolbar({ const companyFilter = useMemo( () => (table.getColumn("companies")?.getFilterValue() as string[]) ?? [], + // eslint-disable-next-line react-hooks/exhaustive-deps [table, columnFilters] ); const [companyDropdownOpen, setCompanyDropdownOpen] = useState(false); diff --git a/src/components/questions/QuestionsTable.tsx b/src/components/questions/QuestionsTable.tsx index 1a6da33a..e6114eea 100644 --- a/src/components/questions/QuestionsTable.tsx +++ b/src/components/questions/QuestionsTable.tsx @@ -457,7 +457,6 @@ export default function QuestionsTable({ data, updatedDate }: { data: Question[] return vis; }, [isMobile, columns]); - // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ data: filteredData, columns, @@ -784,6 +783,7 @@ export default function QuestionsTable({ data, updatedDate }: { data: Question[] const tableBodyRef = useRef(null); const [scrollMargin, setScrollMargin] = useState(0); + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (tableBodyRef.current) { setScrollMargin(tableBodyRef.current.offsetTop); diff --git a/src/components/roadmaps/RoadmapView.tsx b/src/components/roadmaps/RoadmapView.tsx index a2203e3e..53c44753 100644 --- a/src/components/roadmaps/RoadmapView.tsx +++ b/src/components/roadmaps/RoadmapView.tsx @@ -64,7 +64,7 @@ export default function RoadmapView({ roadmap, questions }: Props) { const [completed, setCompleted] = useState>(new Set()); const [starred, setStarred] = useState>(new Set()); const [notes, setNotes] = useState>({}); - const [solvedDates, setSolvedDates] = useState>({}); + const [, setSolvedDates] = useState>({}); const [collapsedPhases, setCollapsedPhases] = useState>( new Set() ); diff --git a/src/lib/sw.test.ts b/src/lib/sw.test.ts index 4cdd4ee3..162e9a4b 100644 --- a/src/lib/sw.test.ts +++ b/src/lib/sw.test.ts @@ -55,9 +55,10 @@ interface FetchEvent { function loadSW(mockSelf: Record) { const swSource = readFileSync(join(__dirname, "../../public/sw.js"), "utf-8"); - const listeners = new Map(); + type EventHandler = (...args: unknown[]) => void; + const listeners = new Map(); - mockSelf.addEventListener = (event: string, handler: Function) => { + mockSelf.addEventListener = (event: string, handler: EventHandler) => { if (!listeners.has(event)) listeners.set(event, []); listeners.get(event)!.push(handler); }; @@ -136,7 +137,7 @@ describe("Service Worker", () => { const keys = await mockCaches.keys(); expect(keys).not.toContain("lc-patterns-v1"); expect(keys).toContain("lc-patterns-v2"); - expect((mockSelf.clients as { claim: Function }).claim).toHaveBeenCalled(); + expect((mockSelf.clients as { claim: () => void }).claim).toHaveBeenCalled(); }); }); diff --git a/vitest.config.mts b/vitest.config.mts index f8f951e5..15ef7616 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -8,6 +8,12 @@ export default defineConfig({ environment: "happy-dom", setupFiles: ["./src/test/setup.ts"], globals: true, + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.{ts,tsx}"], + exclude: ["src/**/*.test.{ts,tsx}", "src/test/**"], + }, }, resolve: { alias: {