diff --git a/.claude/commands/merge-to-main.md b/.claude/commands/merge-to-main.md index 2cde4ece..5545cc54 100644 --- a/.claude/commands/merge-to-main.md +++ b/.claude/commands/merge-to-main.md @@ -62,7 +62,21 @@ Show the user: 2. The proposed commit message 3. Ask for confirmation or modifications -### Step 5: Execute Merge +### Step 5: Run the CI Gate Locally (mandatory) + +Run the exact gate that `.github/workflows/ci.yml` enforces, on the code about to merge: + +```sh +pnpm lint && pnpm check && pnpm test:ci && ADAPTER=node pnpm build +``` + +- Any failure is a hard stop: fix it (or ask the user) before merging. Never merge red. +- This is the fast local mirror of CI; the authoritative gate re-runs on GitHub + (mirror repo `BrettM86/coves-frontend`) when main is pushed. Before deploying, + verify that run is green for the deployed SHA: + `gh run list -R BrettM86/coves-frontend --workflow ci.yml --commit `. + +### Step 6: Execute Merge Based on the current state, offer appropriate options: @@ -86,7 +100,7 @@ Based on the current state, offer appropriate options: **If on main with uncommitted changes:** 1. Commit directly with the comprehensive message -### Step 6: Cleanup (Optional) +### Step 7: Cleanup (Optional) After successful merge, offer to: - Delete the feature branch locally: `git branch -d ` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f987e7e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + # Manual trigger for a ref that missed push CI + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + # Never cancel main runs — deploys gate on a completed run for the deployed SHA + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +# Least privilege — don't inherit the repo default token scope +permissions: + contents: read + +jobs: + ci: + name: Lint, typecheck, test, build + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + # Version comes from the "packageManager" field in package.json + # (keep the Dockerfile's pinned pnpm in sync when bumping it). + # Third-party action — pinned to a commit SHA, not the mutable v4 tag. + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - uses: actions/setup-node@v4 + with: + # Match the Dockerfile's node:22-alpine so CI validates what ships + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm check + + - name: Test + run: pnpm test:ci + + - name: Build (node adapter, same as the Docker image) + run: ADAPTER=node pnpm build diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index e3ca677d..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Lint -on: - - pull_request -jobs: - lint: - runs-on: ubuntu-latest - name: Lint - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: install - run: npm install - - name: deps - run: npm ci - - name: lint - run: npm run lint diff --git a/.gitignore b/.gitignore index abbe051e..37fd6ccc 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ network-*.txt *-snapshot.md # Claude agent memory (local review-agent scratch state) .claude/agent-memory/ +coverage/ diff --git a/eslint.config.js b/eslint.config.js index b82ae6f4..66b60e73 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,6 +41,17 @@ export default ts.config( 'no-console': 'off', '@typescript-eslint/no-explicit-any': 'off', 'svelte/no-navigation-without-resolve': 'off', + // _-prefix marks intentionally-unused bindings (exhaustiveness checks, + // stubbed params, discarded destructures) + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + }, + ], }, }, { diff --git a/package.json b/package.json index 8dfebff9..65a0f815 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "@eslint/compat": "^1.3.2", "@eslint/js": "^9.36.0", "@floating-ui/dom": "^1.7.5", - "@playwright/test": "^1.58.1", "@svelte-put/qr": "^2.1.0", "@sveltejs/adapter-auto": "^3.3.1", "@sveltejs/adapter-node": "^5.4.0", @@ -26,7 +25,6 @@ "eslint": "^9.36.0", "eslint-plugin-svelte": "^3.12.3", "globals": "^17.3.0", - "jsdom": "^22.1.0", "lemmy-js-client": "0.19.11-donation-dialog.1", "linkify-it": "^5.0.0", "marked": "^16.4.2", @@ -61,7 +59,9 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch --diagnostic-sources 'js,svelte' --threshold error", "format": "prettier --write .", "format:specific": "prettier --write", - "lint": "eslint ." + "lint": "eslint . --max-warnings 0", + "test:ci": "vitest run", + "test:coverage": "vitest run --coverage" }, "type": "module" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a78ebdf2..e77bf42f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: '@floating-ui/dom': specifier: ^1.7.5 version: 1.7.5 - '@playwright/test': - specifier: ^1.58.1 - version: 1.58.1 '@svelte-put/qr': specifier: ^2.1.0 version: 2.1.1(svelte@5.49.1) @@ -74,9 +71,6 @@ importers: globals: specifier: ^17.3.0 version: 17.3.0 - jsdom: - specifier: ^22.1.0 - version: 22.1.0 lemmy-js-client: specifier: 0.19.11-donation-dialog.1 version: 0.19.11-donation-dialog.1 @@ -574,11 +568,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@playwright/test@1.58.1': - resolution: {integrity: sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==} - engines: {node: '>=18'} - hasBin: true - '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1363,11 +1352,6 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1753,16 +1737,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - playwright-core@1.58.1: - resolution: {integrity: sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.58.1: - resolution: {integrity: sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==} - engines: {node: '>=18'} - hasBin: true - postcss-load-config@3.1.4: resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} engines: {node: '>= 10'} @@ -2483,10 +2457,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@playwright/test@1.58.1': - dependencies: - playwright: 1.58.1 - '@polka/url@1.0.0-next.29': {} '@rollup/plugin-commonjs@28.0.9(rollup@4.57.1)': @@ -2749,7 +2719,8 @@ snapshots: tailwindcss: 4.1.18 vite: 5.4.21(@types/node@25.2.0)(lightningcss@1.30.2) - '@tootallnate/once@2.0.0': {} + '@tootallnate/once@2.0.0': + optional: true '@types/chai@5.2.3': dependencies: @@ -2924,7 +2895,8 @@ snapshots: dependencies: svelte: 5.49.1 - abab@2.0.6: {} + abab@2.0.6: + optional: true acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -2937,6 +2909,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true ajv@6.12.6: dependencies: @@ -2963,7 +2936,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - asynckit@0.4.0: {} + asynckit@0.4.0: + optional: true axobject-query@4.1.0: {} @@ -2982,6 +2956,7 @@ snapshots: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 + optional: true callsites@3.1.0: {} @@ -3007,6 +2982,7 @@ snapshots: combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 + optional: true commondir@1.0.1: {} @@ -3025,24 +3001,28 @@ snapshots: cssstyle@3.0.0: dependencies: rrweb-cssom: 0.6.0 + optional: true data-urls@4.0.0: dependencies: abab: 2.0.6 whatwg-mimetype: 3.0.0 whatwg-url: 12.0.1 + optional: true debug@4.4.3: dependencies: ms: 2.1.3 - decimal.js@10.6.0: {} + decimal.js@10.6.0: + optional: true deep-is@0.1.4: {} deepmerge@4.3.1: {} - delayed-stream@1.0.0: {} + delayed-stream@1.0.0: + optional: true detect-libc@2.1.2: {} @@ -3051,29 +3031,35 @@ snapshots: domexception@4.0.0: dependencies: webidl-conversions: 7.0.0 + optional: true dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + optional: true enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 - entities@6.0.1: {} + entities@6.0.1: + optional: true - es-define-property@1.0.1: {} + es-define-property@1.0.1: + optional: true - es-errors@1.3.0: {} + es-errors@1.3.0: + optional: true es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 + optional: true es-set-tostringtag@2.1.0: dependencies: @@ -3081,6 +3067,7 @@ snapshots: get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 hasown: 2.0.2 + optional: true esbuild@0.21.5: optionalDependencies: @@ -3272,8 +3259,6 @@ snapshots: es-set-tostringtag: 2.1.0 hasown: 2.0.2 mime-types: 2.1.35 - - fsevents@2.3.2: optional: true fsevents@2.3.3: @@ -3293,11 +3278,13 @@ snapshots: has-symbols: 1.1.0 hasown: 2.0.2 math-intrinsics: 1.1.0 + optional: true get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + optional: true glob-parent@6.0.2: dependencies: @@ -3309,17 +3296,20 @@ snapshots: globals@17.3.0: {} - gopd@1.2.0: {} + gopd@1.2.0: + optional: true graceful-fs@4.2.11: {} has-flag@4.0.0: {} - has-symbols@1.1.0: {} + has-symbols@1.1.0: + optional: true has-tostringtag@1.0.2: dependencies: has-symbols: 1.1.0 + optional: true hasown@2.0.2: dependencies: @@ -3330,6 +3320,7 @@ snapshots: html-encoding-sniffer@3.0.0: dependencies: whatwg-encoding: 2.0.0 + optional: true html-escaper@2.0.2: {} @@ -3340,6 +3331,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true https-proxy-agent@5.0.1: dependencies: @@ -3347,10 +3339,12 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 + optional: true ignore@5.3.2: {} @@ -3377,7 +3371,8 @@ snapshots: is-module@1.0.0: {} - is-potential-custom-element-name@1.0.1: {} + is-potential-custom-element-name@1.0.1: + optional: true is-reference@1.2.1: dependencies: @@ -3439,6 +3434,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true json-buffer@3.0.1: {} @@ -3546,13 +3542,16 @@ snapshots: marked@16.4.2: {} - math-intrinsics@1.1.0: {} + math-intrinsics@1.1.0: + optional: true - mime-db@1.52.0: {} + mime-db@1.52.0: + optional: true mime-types@2.1.35: dependencies: mime-db: 1.52.0 + optional: true minimatch@3.1.2: dependencies: @@ -3574,7 +3573,8 @@ snapshots: nprogress@0.2.0: {} - nwsapi@2.2.23: {} + nwsapi@2.2.23: + optional: true obug@2.1.1: {} @@ -3608,6 +3608,7 @@ snapshots: parse5@7.3.0: dependencies: entities: 6.0.1 + optional: true path-exists@4.0.0: {} @@ -3621,14 +3622,6 @@ snapshots: picomatch@4.0.3: {} - playwright-core@1.58.1: {} - - playwright@1.58.1: - dependencies: - playwright-core: 1.58.1 - optionalDependencies: - fsevents: 2.3.2 - postcss-load-config@3.1.4(postcss@8.5.6): dependencies: lilconfig: 2.1.0 @@ -3667,14 +3660,17 @@ snapshots: psl@1.15.0: dependencies: punycode: 2.3.1 + optional: true punycode@2.3.1: {} - querystringify@2.2.0: {} + querystringify@2.2.0: + optional: true readdirp@4.1.2: {} - requires-port@1.0.0: {} + requires-port@1.0.0: + optional: true resolve-from@4.0.0: {} @@ -3715,13 +3711,15 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 - rrweb-cssom@0.6.0: {} + rrweb-cssom@0.6.0: + optional: true sade@1.8.1: dependencies: mri: 1.2.0 - safer-buffer@2.1.2: {} + safer-buffer@2.1.2: + optional: true sanitize-markdown@2.6.7: dependencies: @@ -3730,6 +3728,7 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 + optional: true semver@7.7.3: {} @@ -3816,7 +3815,8 @@ snapshots: '@sveltekit-i18n/parser-default': 1.1.1 svelte: 5.49.1 - symbol-tree@3.2.4: {} + symbol-tree@3.2.4: + optional: true tailwindcss@4.1.18: {} @@ -3847,10 +3847,12 @@ snapshots: punycode: 2.3.1 universalify: 0.2.0 url-parse: 1.5.10 + optional: true tr46@4.1.1: dependencies: punycode: 2.3.1 + optional: true trap-focus-svelte@1.1.0: {} @@ -3883,7 +3885,8 @@ snapshots: undici-types@7.16.0: {} - universalify@0.2.0: {} + universalify@0.2.0: + optional: true uri-js@4.4.1: dependencies: @@ -3893,6 +3896,7 @@ snapshots: dependencies: querystringify: 2.2.0 requires-port: 1.0.0 + optional: true util-deprecate@1.0.2: {} @@ -3965,19 +3969,24 @@ snapshots: w3c-xmlserializer@4.0.0: dependencies: xml-name-validator: 4.0.0 + optional: true - webidl-conversions@7.0.0: {} + webidl-conversions@7.0.0: + optional: true whatwg-encoding@2.0.0: dependencies: iconv-lite: 0.6.3 + optional: true - whatwg-mimetype@3.0.0: {} + whatwg-mimetype@3.0.0: + optional: true whatwg-url@12.0.1: dependencies: tr46: 4.1.1 webidl-conversions: 7.0.0 + optional: true which@2.0.2: dependencies: @@ -3990,11 +3999,14 @@ snapshots: word-wrap@1.2.5: {} - ws@8.19.0: {} + ws@8.19.0: + optional: true - xml-name-validator@4.0.0: {} + xml-name-validator@4.0.0: + optional: true - xmlchars@2.2.0: {} + xmlchars@2.2.0: + optional: true yaml@1.10.2: {} diff --git a/src/app.d.ts b/src/app.d.ts index 28640a1f..5ceca7fb 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -106,7 +106,6 @@ declare global { } export {} -// eslint-disable-next-line declare const __VERSION__: string declare module 'markdown-it-sub' diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts index ac38bf90..4d0a5a7a 100644 --- a/src/hooks.server.test.ts +++ b/src/hooks.server.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { Cookies, Redirect, RequestEvent } from '@sveltejs/kit' +import type { RequestEvent } from '@sveltejs/kit' +import { + createMockCookies, + createMockEvent, + isRedirect, +} from '$lib/test-utils/request-event' // Variable to control the mocked instance URL let mockPublicInternalInstance: string | undefined = 'http://localhost:4000' @@ -37,65 +42,6 @@ const { handle, handleError } = await import('./hooks.server') const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) -// Helper to create mock cookies -function createMockCookies( - initialCookies: Record = {}, -): Cookies { - const store = new Map(Object.entries(initialCookies)) - return { - get: vi.fn((name: string) => store.get(name)), - getAll: vi.fn(() => - Array.from(store.entries()).map(([name, value]) => ({ name, value })), - ), - set: vi.fn((name: string, value: string) => { - store.set(name, value) - }), - delete: vi.fn((name: string) => { - store.delete(name) - }), - serialize: vi.fn(), - } as unknown as Cookies -} - -/** - * Creates a mock request event for testing. - */ -function createMockEvent(options: { - cookies?: Cookies - locals?: App.Locals - url?: string -}): RequestEvent { - const url = new URL(options.url ?? 'http://localhost:5173/') - const defaultLocals: App.Locals = { auth: { authenticated: false } } - return { - request: new Request(url), - cookies: options.cookies ?? createMockCookies(), - url, - locals: options.locals ?? defaultLocals, - params: {}, - platform: undefined, - route: { id: '/' }, - getClientAddress: () => '127.0.0.1', - fetch: vi.fn(), - isDataRequest: false, - isSubRequest: false, - setHeaders: vi.fn(), - } as unknown as RequestEvent -} - -/** - * Checks if a thrown value is a SvelteKit Redirect. - * SvelteKit's `redirect()` throws an object with `status` and `location` properties. - */ -function isRedirect(err: unknown): err is Redirect { - return ( - typeof err === 'object' && - err !== null && - 'status' in err && - 'location' in err - ) -} - /** * Creates a mock resolve function that returns a Response */ diff --git a/src/lib/app/auth.svelte.ts b/src/lib/app/auth.svelte.ts index 18552fa5..f604f529 100644 --- a/src/lib/app/auth.svelte.ts +++ b/src/lib/app/auth.svelte.ts @@ -409,7 +409,6 @@ class Profile { /** * @deprecated Legacy Lemmy compatibility - will be replaced with Coves roles */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars isMod(_community?: unknown): boolean { if (!this.#warnedIsMod) { console.warn( diff --git a/src/lib/feature/comment/comments.svelte.ts b/src/lib/feature/comment/comments.svelte.ts index 8c385d5f..296e230b 100644 --- a/src/lib/feature/comment/comments.svelte.ts +++ b/src/lib/feature/comment/comments.svelte.ts @@ -214,6 +214,7 @@ export function createOptimisticCommentView( parentRef: StrongRef, author: { did: string; handle: string; avatar?: string }, ): NormalizedCommentView { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- one-shot timestamp, never held as reactive state const now = new Date().toISOString() return { uri: output.uri, diff --git a/src/lib/feature/community/CommunityCard.svelte b/src/lib/feature/community/CommunityCard.svelte index 1a9db836..94d8fdee 100644 --- a/src/lib/feature/community/CommunityCard.svelte +++ b/src/lib/feature/community/CommunityCard.svelte @@ -67,7 +67,6 @@ * Purge a community by DID. * Not yet available in the Coves API. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars export async function purgeCommunity(_did: DID): Promise { toast({ content: 'Purging communities is not yet available', diff --git a/src/lib/feature/legacy/item.svelte.ts b/src/lib/feature/legacy/item.svelte.ts index d71cbbc6..32b818b6 100644 --- a/src/lib/feature/legacy/item.svelte.ts +++ b/src/lib/feature/legacy/item.svelte.ts @@ -144,7 +144,6 @@ export const resumables = new ResumableStore() /** * @deprecated Legacy Lemmy code - needs Coves API replacement */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars export function addFavorite(_community: CommunityView, _add = true): void { toast({ content: 'Favorites management is not yet available', diff --git a/src/lib/feature/user/PictrsImage.svelte b/src/lib/feature/user/PictrsImage.svelte index 8fd0e878..19ac2cd6 100644 --- a/src/lib/feature/user/PictrsImage.svelte +++ b/src/lib/feature/user/PictrsImage.svelte @@ -34,10 +34,9 @@ interface Props { image: PictrsLocalImage user?: AuthorView - ondelete?: (res: boolean) => void } - let { image, user, ondelete }: Props = $props() + let { image, user }: Props = $props()
diff --git a/src/lib/feature/user/index.ts b/src/lib/feature/user/index.ts index d9120fbe..78cf4f7a 100644 --- a/src/lib/feature/user/index.ts +++ b/src/lib/feature/user/index.ts @@ -27,7 +27,6 @@ let _warnedAddSubscription = false * @deprecated Legacy Lemmy code - needs Coves API replacement * This is a no-op stub to maintain compilation */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars export function addSubscription(_community: unknown, _subscribe = true): void { if (!_warnedAddSubscription) { console.warn( @@ -44,9 +43,7 @@ let _warnedHasFavorite = false * Always returns false as favorites are not yet implemented */ export function hasFavorite( - // eslint-disable-next-line @typescript-eslint/no-unused-vars _profile: unknown, - // eslint-disable-next-line @typescript-eslint/no-unused-vars _id: number, ): boolean { if (!_warnedHasFavorite) { diff --git a/src/lib/test-utils/request-event.test.ts b/src/lib/test-utils/request-event.test.ts new file mode 100644 index 00000000..1132d53c --- /dev/null +++ b/src/lib/test-utils/request-event.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest' +import { redirect } from '@sveltejs/kit' +import { createMockCookies, createMockEvent, isRedirect } from './request-event' + +describe('createMockCookies', () => { + it('exposes initial cookies through get and getAll', () => { + const cookies = createMockCookies({ session: 'abc', theme: 'dark' }) + + expect(cookies.get('session')).toBe('abc') + expect(cookies.getAll()).toEqual([ + { name: 'session', value: 'abc' }, + { name: 'theme', value: 'dark' }, + ]) + }) + + it('round-trips set and delete', () => { + const cookies = createMockCookies() + + cookies.set('a', '1', { path: '/' }) + expect(cookies.get('a')).toBe('1') + + cookies.delete('a', { path: '/' }) + expect(cookies.get('a')).toBeUndefined() + }) + + it('records calls for assertion', () => { + const cookies = createMockCookies() + + cookies.set('a', '1', { path: '/' }) + + expect(cookies.set).toHaveBeenCalledWith('a', '1', { path: '/' }) + }) + + it('serialize returns a name=value string, not undefined', () => { + const cookies = createMockCookies() + + expect(cookies.serialize('a', '1', { path: '/' })).toBe('a=1') + }) +}) + +describe('createMockEvent', () => { + it('defaults to an unauthenticated GET to http://localhost:5173/ with no Content-Type', () => { + const event = createMockEvent() + + expect(event.request.method).toBe('GET') + expect(event.url.href).toBe('http://localhost:5173/') + expect(event.locals.auth.authenticated).toBe(false) + expect(event.params).toEqual({}) + expect(event.request.headers.get('content-type')).toBeNull() + }) + + it('JSON-serializes body, defaults method to POST, and sets Content-Type', async () => { + const event = createMockEvent({ body: { handle: 'user.example.com' } }) + + expect(event.request.method).toBe('POST') + expect(event.request.headers.get('content-type')).toBe('application/json') + expect(await event.request.json()).toEqual({ handle: 'user.example.com' }) + }) + + it.each([ + [0, '0'], + [false, 'false'], + [null, 'null'], + ['', '""'], + ])('preserves falsy body %j instead of dropping it', async (body, wire) => { + const event = createMockEvent({ body }) + + expect(await event.request.text()).toBe(wire) + }) + + it('respects an explicit method alongside a body', () => { + const event = createMockEvent({ method: 'PUT', body: { a: 1 } }) + + expect(event.request.method).toBe('PUT') + }) + + it('merges caller headers over defaults', () => { + const event = createMockEvent({ + body: { a: 1 }, + headers: { 'Content-Type': 'text/plain', Origin: 'https://evil.com' }, + }) + + expect(event.request.headers.get('content-type')).toBe('text/plain') + expect(event.request.headers.get('origin')).toBe('https://evil.com') + }) + + it('accepts a URL instance and preserves it', () => { + const url = new URL('https://kelp.example.com/api/auth/callback?state=x') + const event = createMockEvent({ url }) + + expect(event.url).toBe(url) + expect(event.url.searchParams.get('state')).toBe('x') + }) + + it('defaults route.id to the pathname and honors routeId', () => { + expect(createMockEvent({ url: 'http://localhost:5173/c/x' }).route.id).toBe( + '/c/x', + ) + expect(createMockEvent({ routeId: '/c/[handle=handle]' }).route.id).toBe( + '/c/[handle=handle]', + ) + }) + + it('rejects loudly when event.fetch is used without being mocked', async () => { + const event = createMockEvent() + + await expect(event.fetch('http://localhost/api')).rejects.toThrow( + /event\.fetch called but not mocked/, + ) + }) +}) + +describe('isRedirect', () => { + it('recognizes a thrown SvelteKit redirect', () => { + let caught: unknown + try { + redirect(302, '/login') + } catch (error) { + caught = error + } + + expect(isRedirect(caught)).toBe(true) + }) + + it('rejects redirect-shaped plain objects (instanceof semantics)', () => { + expect(isRedirect({ status: 302, location: '/login' })).toBe(false) + }) + + it('rejects ordinary errors', () => { + expect(isRedirect(new Error('boom'))).toBe(false) + }) +}) diff --git a/src/lib/test-utils/request-event.ts b/src/lib/test-utils/request-event.ts new file mode 100644 index 00000000..5456c53f --- /dev/null +++ b/src/lib/test-utils/request-event.ts @@ -0,0 +1,129 @@ +/** + * Shared mocks for testing SvelteKit server code (hooks, +server endpoints, + * load functions). Import these instead of hand-rolling per-file + * RequestEvent/Cookies mocks so all server tests exercise the same shapes. + * + * Test-only module: never import from production code. + */ +import type { Cookies, RequestEvent } from '@sveltejs/kit' +import { vi } from 'vitest' + +/** + * SvelteKit's first-party redirect guard: `redirect()` throws an instance of + * kit's `Redirect` class, and this checks for it. Re-exported so server tests + * have one canonical import site alongside the mocks. + */ +export { isRedirect } from '@sveltejs/kit' + +/** + * A RequestEvent with relaxed params/route typing so one mock event can be + * passed to any route's handler without per-route type plumbing. + */ +export type MockRequestEvent = RequestEvent, any> + +/** + * In-memory Cookies mock backed by a Map. All methods are vi.fn()s, so tests + * can assert on calls (e.g. `expect(cookies.set).toHaveBeenCalledWith(...)`). + */ +export function createMockCookies( + initialCookies: Record = {}, +): Cookies { + const store = new Map(Object.entries(initialCookies)) + return { + get: vi.fn((name: string) => store.get(name)), + getAll: vi.fn(() => + Array.from(store.entries()).map(([name, value]) => ({ name, value })), + ), + set: vi.fn((name: string, value: string) => { + store.set(name, value) + }), + delete: vi.fn((name: string) => { + store.delete(name) + }), + serialize: vi.fn((name: string, value: string) => `${name}=${value}`), + } +} + +export interface MockEventOptions { + /** Defaults to POST when `body` is set, GET otherwise */ + method?: string + /** + * JSON-serialized into the request body (with Content-Type: + * application/json). Any JSON value works, including falsy ones — + * presence is decided by `!== undefined`, not truthiness. + */ + body?: unknown + headers?: Record + /** Defaults to http://localhost:5173/ */ + url?: string | URL + /** Defaults to fresh createMockCookies() */ + cookies?: Cookies + /** Defaults to unauthenticated */ + locals?: App.Locals + params?: Record + /** + * Defaults to url.pathname — note that is NOT a real SvelteKit route id + * shape (those look like '/c/[handle]'); pass one explicitly if code under + * test matches on route.id. + */ + routeId?: string +} + +// The real Span type lives in @opentelemetry/api, which isn't a dependency; +// nothing under test traces, so an inert stand-in is enough. +const noopSpan = { + end: () => {}, + setAttribute: () => noopSpan, + setAttributes: () => noopSpan, + recordException: () => {}, + setStatus: () => noopSpan, +} as unknown as MockRequestEvent['tracing']['root'] + +/** + * Creates a complete mock RequestEvent. The literal below is compiler-checked + * against SvelteKit's RequestEvent, so kit adding/renaming fields fails + * `pnpm check` here — one obvious fix site — instead of N tests failing at + * runtime on a missing field. + */ +export function createMockEvent( + options: MockEventOptions = {}, +): MockRequestEvent { + const url = + options.url instanceof URL + ? options.url + : new URL(options.url ?? 'http://localhost:5173/') + const hasBody = options.body !== undefined + const defaultLocals: App.Locals = { auth: { authenticated: false } } + const event: MockRequestEvent = { + request: new Request(url, { + method: options.method ?? (hasBody ? 'POST' : 'GET'), + body: hasBody ? JSON.stringify(options.body) : undefined, + headers: { + ...(hasBody && { 'Content-Type': 'application/json' }), + ...options.headers, + }, + }), + cookies: options.cookies ?? createMockCookies(), + url, + locals: options.locals ?? defaultLocals, + params: options.params ?? {}, + platform: undefined, + route: { id: options.routeId ?? url.pathname }, + getClientAddress: () => '127.0.0.1', + // Loud default: resolving undefined here would let handlers crash inside + // catch-and-redirect blocks while tests assert the redirect and pass. + fetch: vi.fn(() => + Promise.reject( + new Error( + 'createMockEvent: event.fetch called but not mocked — pass a fetch via Object.assign(event, { fetch: ... })', + ), + ), + ), + isDataRequest: false, + isSubRequest: false, + isRemoteRequest: false, + setHeaders: vi.fn(), + tracing: { enabled: false, root: noopSpan, current: noopSpan }, + } + return event +} diff --git a/src/lib/ui/navbar/Navbar.svelte b/src/lib/ui/navbar/Navbar.svelte index 91d92881..85bf1f70 100644 --- a/src/lib/ui/navbar/Navbar.svelte +++ b/src/lib/ui/navbar/Navbar.svelte @@ -5,7 +5,7 @@ import { Bars3, GlobeAlt, Icon, PencilSquare } from 'svelte-hero-icons/dist' import type { ClassValue } from 'svelte/elements' import Avatar from '../generic/Avatar.svelte' - import CommandsWrapper, { chords } from './commands/CommandsHost.svelte' + import CommandsWrapper from './commands/CommandsHost.svelte' import NavButton from './NavButton.svelte' interface Props { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 85b996e3..4ec4aef8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -33,9 +33,7 @@
- {#snippet children()} - - {/snippet} + {#snippet extended()}
, any> - -/** - * Helper to check if an error is a SvelteKit Redirect - */ -function isRedirect(error: unknown): error is Redirect { - return ( - typeof error === 'object' && - error !== null && - 'status' in error && - 'location' in error && - (error as Redirect).status >= 300 && - (error as Redirect).status < 400 - ) -} +import { + createMockCookies, + createMockEvent, + isRedirect, +} from '$lib/test-utils/request-event' // Mock environment variables (needed by login endpoint) vi.mock('$env/dynamic/private', () => ({ env: {}, })) -// Helper to create mock cookies -function createMockCookies( - initialCookies: Record = {}, -): Cookies { - const store = new Map(Object.entries(initialCookies)) - return { - get: vi.fn((name: string) => store.get(name)), - getAll: vi.fn(() => - Array.from(store.entries()).map(([name, value]) => ({ name, value })), - ), - set: vi.fn((name: string, value: string) => { - store.set(name, value) - }), - delete: vi.fn((name: string) => { - store.delete(name) - }), - serialize: vi.fn(), - } as unknown as Cookies -} - -/** - * Creates a mock request event for testing. - */ -function createMockEvent(options: { - method?: string - body?: unknown - cookies?: Cookies - url?: string - locals?: App.Locals - headers?: Record -}): AnyRequestEvent { - const url = new URL(options.url ?? 'http://localhost:5173/api/auth/test') - // Default to unauthenticated state - const defaultLocals: App.Locals = { auth: { authenticated: false } } - return { - request: new Request(url, { - method: options.method ?? 'GET', - body: options.body ? JSON.stringify(options.body) : undefined, - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - }), - cookies: options.cookies ?? createMockCookies(), - url, - locals: options.locals ?? defaultLocals, - params: {}, - platform: undefined, - route: { id: '/api/auth/test' }, - getClientAddress: () => '127.0.0.1', - fetch: vi.fn(), - isDataRequest: false, - isSubRequest: false, - setHeaders: vi.fn(), - } as unknown as AnyRequestEvent -} - /** * Helper to create authenticated App.Locals with the new shape. */ diff --git a/src/routes/api/auth/callback/callback.test.ts b/src/routes/api/auth/callback/callback.test.ts index 0b6e8baf..940dc472 100644 --- a/src/routes/api/auth/callback/callback.test.ts +++ b/src/routes/api/auth/callback/callback.test.ts @@ -1,4 +1,8 @@ import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest' +import { + createMockCookies, + createMockEvent, +} from '$lib/test-utils/request-event' // Mock CSRF validation - control per test let mockValidateOAuthState: Mock @@ -7,20 +11,6 @@ vi.mock('$lib/server/csrf', () => ({ validateOAuthState: (...args: unknown[]) => mockValidateOAuthState(...args), })) -// Helper to create mock cookies -function createMockCookies(initialCookies: Record = {}) { - const store = new Map(Object.entries(initialCookies)) - return { - get: vi.fn((name: string) => store.get(name)), - set: vi.fn((name: string, value: string) => { - store.set(name, value) - }), - delete: vi.fn((name: string) => { - store.delete(name) - }), - } -} - // Helper to create mock URL with optional state parameter function createMockUrl(state?: string): URL { const base = 'https://kelp.example.com/api/auth/callback' @@ -43,10 +33,9 @@ describe('GET /api/auth/callback', () => { const cookies = createMockCookies({}) try { - await GET({ - cookies: cookies as never, - url: createMockUrl('some-state'), - } as never) + await GET( + createMockEvent({ cookies, url: createMockUrl('some-state') }), + ) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -65,10 +54,9 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl('some-state'), - } as never) + await GET( + createMockEvent({ cookies, url: createMockUrl('some-state') }), + ) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -91,10 +79,9 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl('some-state'), - } as never) + await GET( + createMockEvent({ cookies, url: createMockUrl('some-state') }), + ) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -115,10 +102,9 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl('some-state'), - } as never) + await GET( + createMockEvent({ cookies, url: createMockUrl('some-state') }), + ) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -139,10 +125,9 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl('some-state'), - } as never) + await GET( + createMockEvent({ cookies, url: createMockUrl('some-state') }), + ) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -163,10 +148,9 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl('some-state'), - } as never) + await GET( + createMockEvent({ cookies, url: createMockUrl('some-state') }), + ) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -190,10 +174,9 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl('some-state'), - } as never) + await GET( + createMockEvent({ cookies, url: createMockUrl('some-state') }), + ) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -219,10 +202,8 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl(), // No state parameter - } as never) + // No state parameter in the URL + await GET(createMockEvent({ cookies, url: createMockUrl() })) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -242,10 +223,9 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl('url-state-value'), - } as never) + await GET( + createMockEvent({ cookies, url: createMockUrl('url-state-value') }), + ) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -265,10 +245,9 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl('url-state-value'), - } as never) + await GET( + createMockEvent({ cookies, url: createMockUrl('url-state-value') }), + ) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -292,10 +271,12 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl('different-state-bbbb'), - } as never) + await GET( + createMockEvent({ + cookies, + url: createMockUrl('different-state-bbbb'), + }), + ) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -325,10 +306,7 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl(testState), - } as never) + await GET(createMockEvent({ cookies, url: createMockUrl(testState) })) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -351,10 +329,7 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl(testState), - } as never) + await GET(createMockEvent({ cookies, url: createMockUrl(testState) })) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -379,10 +354,7 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl(testState), - } as never) + await GET(createMockEvent({ cookies, url: createMockUrl(testState) })) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -405,10 +377,7 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl(testState), - } as never) + await GET(createMockEvent({ cookies, url: createMockUrl(testState) })) expect.fail('Expected redirect to be thrown') } catch (error: unknown) { const redirect = error as { status: number; location: string } @@ -433,10 +402,7 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl(testState), - } as never) + await GET(createMockEvent({ cookies, url: createMockUrl(testState) })) } catch { // Expected redirect } @@ -459,10 +425,9 @@ describe('GET /api/auth/callback', () => { }) try { - await GET({ - cookies: cookies as never, - url: createMockUrl('different-state'), - } as never) + await GET( + createMockEvent({ cookies, url: createMockUrl('different-state') }), + ) } catch { // Expected redirect } diff --git a/src/routes/profile/media/+page.svelte b/src/routes/profile/media/+page.svelte index 53591155..bea76022 100644 --- a/src/routes/profile/media/+page.svelte +++ b/src/routes/profile/media/+page.svelte @@ -14,19 +14,7 @@
{#each data.images.value as image (image.local_image.pictrs_delete_token)}
- { - data.images.value = data.images.value.toSpliced( - data.images.value.findIndex( - (i) => - i.local_image.pictrs_delete_token == - image.local_image.pictrs_delete_token, - ), - 1, - ) - }} - /> +
{/each}
diff --git a/vitest.config.ts b/vitest.config.ts index 4af1a11d..4ee9788b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,5 @@ export default defineConfig({ test: { include: ['src/**/*.test.ts'], environment: 'node', - globals: true, }, })