From 8751d36f14e06b75d3e50d1588c384c10bb93b9d Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 30 Mar 2026 19:36:57 +0530 Subject: [PATCH 1/6] Enhance TypeScript testing configuration - Updated ESLint configuration to include unit test files with TypeScript and JSX support. - Modified tsconfig.json to include test files for better type checking. - Added a new tsconfig.unit.json for unit tests, specifying necessary types and includes. - Adjusted vitest.config.ts to include the new test file patterns for comprehensive testing coverage. --- .gitignore | 1 + app/eslint.config.js | 43 ++++++++++ app/test/tsconfig.unit.json | 7 ++ app/test/vitest.config.ts | 2 +- app/tsconfig.json | 2 +- yarn.lock | 158 ++---------------------------------- 6 files changed, 59 insertions(+), 154 deletions(-) create mode 100644 app/test/tsconfig.unit.json diff --git a/.gitignore b/.gitignore index c465bb0da..e4beb1271 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ coverage/ tauri.key tauri.key.pub /target/ +src-tauri/target/ diff --git a/app/eslint.config.js b/app/eslint.config.js index aa37bca0e..f4b626d2a 100644 --- a/app/eslint.config.js +++ b/app/eslint.config.js @@ -210,6 +210,49 @@ export default [ }, }, + // Unit test files in test/ — TypeScript + JSX, parsed with main tsconfig + { + files: ['test/*.test.ts', 'test/*.test.tsx'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { jsx: true }, + project: './test/tsconfig.unit.json', + tsconfigRootDir: __dirname, + }, + globals: { + describe: 'readonly', + it: 'readonly', + test: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + vi: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + react: reactPlugin, + 'react-hooks': reactHooksPlugin, + }, + settings: { react: { version: 'detect' } }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + ], + 'no-undef': 'off', + }, + }, + // E2E test files (Appium/WebDriverIO) — use tsconfig.e2e.json for parsing { files: ['test/e2e/**/*.ts', 'test/wdio.conf.ts'], diff --git a/app/test/tsconfig.unit.json b/app/test/tsconfig.unit.json new file mode 100644 index 000000000..326ae5d3e --- /dev/null +++ b/app/test/tsconfig.unit.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals", "@testing-library/jest-dom", "node"] + }, + "include": ["../src", "./*.test.ts", "./*.test.tsx"] +} diff --git a/app/test/vitest.config.ts b/app/test/vitest.config.ts index 8b6b2edea..e6990e192 100644 --- a/app/test/vitest.config.ts +++ b/app/test/vitest.config.ts @@ -37,7 +37,7 @@ export default defineConfig({ mockReset: false, restoreMocks: false, setupFiles: ["src/test/setup.ts"], - include: ["src/**/*.test.{ts,tsx}"], + include: ["src/**/*.test.{ts,tsx}", "test/*.test.{ts,tsx}"], hookTimeout: 30000, testTimeout: 30000, coverage: { diff --git a/app/tsconfig.json b/app/tsconfig.json index 6f8e497bf..eaeb750c1 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -24,7 +24,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"], + "include": ["src", "test/*.test.ts", "test/*.test.tsx"], "exclude": ["skills"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/yarn.lock b/yarn.lock index e5fb241ab..7a4addc05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -696,7 +696,7 @@ "@inquirer/type" "^3.0.10" yoctocolors-cjs "^2.1.3" -"@inquirer/confirm@^5.0.0", "@inquirer/confirm@^5.1.21": +"@inquirer/confirm@^5.1.21": version "5.1.21" resolved "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz" integrity sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ== @@ -916,18 +916,6 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@mswjs/interceptors@^0.41.2": - version "0.41.2" - resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.2.tgz" - integrity sha512-7G0Uf0yK3f2bjElBLGHIQzgRgMESczOMyYVasq1XK8P5HaXtlW4eQhz9MBL+TQILZLaruq+ClGId+hH0w4jvWw== - dependencies: - "@open-draft/deferred-promise" "^2.2.0" - "@open-draft/logger" "^0.3.0" - "@open-draft/until" "^2.0.0" - is-node-process "^1.2.0" - outvariant "^1.4.3" - strict-event-emitter "^0.5.1" - "@noble/curves@2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-2.0.1.tgz#64ba8bd5e8564a02942655602515646df1cdb3ad" @@ -966,24 +954,6 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@open-draft/deferred-promise@^2.2.0": - version "2.2.0" - resolved "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz" - integrity sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA== - -"@open-draft/logger@^0.3.0": - version "0.3.0" - resolved "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz" - integrity sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ== - dependencies: - is-node-process "^1.2.0" - outvariant "^1.4.0" - -"@open-draft/until@^2.0.0": - version "2.1.0" - resolved "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz" - integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== - "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" @@ -1294,16 +1264,11 @@ dependencies: postcss-selector-parser "6.0.10" -"@tauri-apps/api@2.10.1", "@tauri-apps/api@^2.10.0": +"@tauri-apps/api@2.10.1", "@tauri-apps/api@^2.10.0", "@tauri-apps/api@^2.8.0": version "2.10.1" resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.10.1.tgz#57c1bae6114ec33d977eb2b50dfefc25fa84fc93" integrity sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw== -"@tauri-apps/api@^2.8.0": - version "2.9.1" - resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz" - integrity sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw== - "@tauri-apps/cli-darwin-arm64@2.10.0": version "2.10.0" resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz#015cd11bf138286cf98625c2341c53a84e0fdb9c" @@ -1637,11 +1602,6 @@ resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.4.tgz#1933e5ff153a23c7664487833198d685c22e791e" integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA== -"@types/statuses@^2.0.6": - version "2.0.6" - resolved "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz" - integrity sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA== - "@types/three@^0.183.1": version "0.183.1" resolved "https://registry.yarnpkg.com/@types/three/-/three-0.183.1.tgz#d812d028b38ad68843725e3e7bd3268607cef150" @@ -2910,7 +2870,7 @@ convert-source-map@^2.0.0: resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== -cookie@^1.0.1, cookie@^1.0.2: +cookie@^1.0.1: version "1.1.1" resolved "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz" integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== @@ -4290,11 +4250,6 @@ grapheme-splitter@^1.0.4: resolved "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz" integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== -graphql@^16.12.0: - version "16.12.0" - resolved "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz" - integrity sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ== - has-bigints@^1.0.2: version "1.1.0" resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz" @@ -4397,11 +4352,6 @@ he@^1.2.0: resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -headers-polyfill@^4.0.2: - version "4.0.3" - resolved "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz" - integrity sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ== - hermes-estree@0.25.1: version "0.25.1" resolved "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz" @@ -4767,11 +4717,6 @@ is-negative-zero@^2.0.3: resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz" integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== -is-node-process@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz" - integrity sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw== - is-number-object@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz" @@ -5774,30 +5719,6 @@ ms@^2.1.1, ms@^2.1.3: resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -msw@^2.12.10: - version "2.12.10" - resolved "https://registry.npmjs.org/msw/-/msw-2.12.10.tgz" - integrity sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw== - dependencies: - "@inquirer/confirm" "^5.0.0" - "@mswjs/interceptors" "^0.41.2" - "@open-draft/deferred-promise" "^2.2.0" - "@types/statuses" "^2.0.6" - cookie "^1.0.2" - graphql "^16.12.0" - headers-polyfill "^4.0.2" - is-node-process "^1.2.0" - outvariant "^1.4.3" - path-to-regexp "^6.3.0" - picocolors "^1.1.1" - rettime "^0.10.1" - statuses "^2.0.2" - strict-event-emitter "^0.5.1" - tough-cookie "^6.0.0" - type-fest "^5.2.0" - until-async "^3.0.2" - yargs "^17.7.2" - mute-stream@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz" @@ -6011,11 +5932,6 @@ os-browserify@^0.3.0: resolved "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz" integrity sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A== -outvariant@^1.4.0, outvariant@^1.4.3: - version "1.4.3" - resolved "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz" - integrity sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA== - own-keys@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz" @@ -6211,11 +6127,6 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" -path-to-regexp@^6.3.0: - version "6.3.0" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz" - integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== - pathe@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz" @@ -6779,11 +6690,6 @@ ret@~0.5.0: resolved "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz" integrity sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw== -rettime@^0.10.1: - version "0.10.1" - resolved "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz" - integrity sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw== - reusify@^1.0.4: version "1.1.0" resolved "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz" @@ -7171,11 +7077,6 @@ stackback@0.0.2: resolved "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz" integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== -statuses@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz" - integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== - std-env@^3.10.0: version "3.10.0" resolved "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz" @@ -7221,21 +7122,7 @@ streamx@^2.15.0, streamx@^2.21.0: fast-fifo "^1.3.2" text-decoder "^1.1.0" -strict-event-emitter@^0.5.1: - version "0.5.1" - resolved "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz" - integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ== - -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7334,14 +7221,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7433,11 +7313,6 @@ symbol-tree@^3.2.4: resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== -tagged-tag@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz" - integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== - tailwindcss@^3.4.19: version "3.4.19" resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz" @@ -7666,13 +7541,6 @@ type-fest@^4.2.0, type-fest@^4.31.0, type-fest@^4.41.0: resolved "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz" integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== -type-fest@^5.2.0: - version "5.4.4" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz" - integrity sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw== - dependencies: - tagged-tag "^1.0.0" - typed-array-buffer@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz" @@ -7809,11 +7677,6 @@ unist-util-visit@^5.0.0: unist-util-is "^6.0.0" unist-util-visit-parents "^6.0.0" -until-async@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz" - integrity sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw== - update-browserslist-db@^1.2.0: version "1.2.3" resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz" @@ -8132,7 +7995,7 @@ workerpool@^6.5.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -8150,15 +8013,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" From b2edcb7b621d1c3202a10c70fdad8a18d4b4dcb9 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 30 Mar 2026 19:38:12 +0530 Subject: [PATCH 2/6] Add OAuth provider tests for Discord, GitHub, Google, and Twitter - Implemented comprehensive unit tests for OAuthProviderButton across multiple providers: Discord, GitHub, Google, and Twitter. - Covered rendering, OAuth flow in both web and Tauri environments, loading states, and error handling. - Ensured consistent styling and behavior for each provider's button, including label, icon, and disabled states. - Enhanced test coverage for backend URL retrieval and interaction with the OAuth flow. --- app/test/OAuthDiscord.test.tsx | 303 +++++++++++++++++++++ app/test/OAuthGitHub.test.tsx | 303 +++++++++++++++++++++ app/test/OAuthLoginSection.test.tsx | 396 ++++++++++++++++++++++++++++ app/test/OAuthTwitter.test.tsx | 303 +++++++++++++++++++++ 4 files changed, 1305 insertions(+) create mode 100644 app/test/OAuthDiscord.test.tsx create mode 100644 app/test/OAuthGitHub.test.tsx create mode 100644 app/test/OAuthLoginSection.test.tsx create mode 100644 app/test/OAuthTwitter.test.tsx diff --git a/app/test/OAuthDiscord.test.tsx b/app/test/OAuthDiscord.test.tsx new file mode 100644 index 000000000..1eb53d48a --- /dev/null +++ b/app/test/OAuthDiscord.test.tsx @@ -0,0 +1,303 @@ +/// +/** + * Tests for Discord OAuth login via OAuthProviderButton. + * + * Coverage areas: + * - Discord button rendering (label, icon, indigo styling) + * - OAuth flow in both Tauri (desktop) and web environments + * - Loading / disabled state management + * - Error handling when backend URL lookup fails + * - dev-mode URL construction (?responseType=json) + */ +import type { ComponentProps } from 'react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../src/test/test-utils'; +import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; +import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ + mockGetBackendUrl: vi.fn(), + mockOpenUrl: vi.fn(), + mockIsTauri: vi.fn(), +})); + +vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); +vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); +vi.mock('../src/utils/tauriCommands', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, isTauri: mockIsTauri }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const discordConfig = oauthProviderConfigs.find(p => p.id === 'discord')!; + +const renderDiscordButton = (props: Partial> = {}) => + renderWithProviders(); + +const clickButton = (btn: HTMLElement) => act(async () => { fireEvent.click(btn); }); + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Discord) — rendering', () => { + it('shows the Discord label', () => { + renderDiscordButton(); + expect(screen.getByText('Discord')).toBeInTheDocument(); + }); + + it('is enabled by default', () => { + renderDiscordButton(); + expect(screen.getByRole('button', { name: /discord/i })).toBeEnabled(); + }); + + it('is disabled when disabled prop is true', () => { + renderDiscordButton({ disabled: true }); + expect(screen.getByRole('button', { name: /discord/i })).toBeDisabled(); + }); + + it('renders the Discord SVG icon', () => { + const { container } = renderDiscordButton(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('has indigo background styling', () => { + renderDiscordButton(); + expect(screen.getByRole('button', { name: /discord/i })).toHaveClass('bg-indigo-600'); + }); + + it('has white text', () => { + const { container } = renderDiscordButton(); + const label = container.querySelector('span'); + expect(label).toHaveClass('text-white'); + }); +}); + +// --------------------------------------------------------------------------- +// Web OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Discord) — web OAuth flow', () => { + const originalLocation = window.location; + + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); + mockIsTauri.mockReturnValue(false); + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + }); + + afterEach(() => { + (window as unknown as Record).location = originalLocation; + }); + + it('redirects to /auth/discord/login?responseType=json on click', async () => { + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => { + expect((window.location as unknown as { href: string }).href).toBe( + 'http://localhost:5005/auth/discord/login?responseType=json' + ); + }); + }); + + it('does not call openUrl in web mode', async () => { + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('calls getBackendUrl exactly once per click', async () => { + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Tauri OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Discord) — Tauri OAuth flow', () => { + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + }); + + it('calls openUrl with /auth/discord/login?responseType=json', async () => { + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => { + expect(mockOpenUrl).toHaveBeenCalledWith( + 'https://api.example.com/auth/discord/login?responseType=json' + ); + }); + }); + + it('does not set window.location.href in Tauri mode', async () => { + const originalHref = window.location.href; + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(window.location.href).toBe(originalHref); + }); + + it('remains in loading state after openUrl resolves (awaits deep-link callback)', async () => { + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(screen.getByText('Connecting...')).toBeInTheDocument(); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Loading state +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Discord) — loading state', () => { + it('shows spinner and "Connecting..." while getBackendUrl is pending', async () => { + let resolve!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockIsTauri.mockReturnValue(false); + + renderDiscordButton(); + const button = screen.getByRole('button', { name: /discord/i }); + await clickButton(button); + + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(button).toBeDisabled(); + + await act(async () => { resolve('http://localhost:5005'); }); + }); + + it('ignores a second click while already loading', async () => { + let resolve!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockIsTauri.mockReturnValue(false); + + renderDiscordButton(); + const button = screen.getByRole('button', { name: /discord/i }); + + await clickButton(button); + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + + fireEvent.click(button); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + + await act(async () => { resolve('http://localhost:5005'); }); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Discord) — error handling', () => { + beforeEach(() => { + mockIsTauri.mockReturnValue(false); + }); + + it('returns to enabled state after getBackendUrl throws', async () => { + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderDiscordButton(); + const button = screen.getByRole('button', { name: /discord/i }); + await clickButton(button); + + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.getByText('Discord')).toBeInTheDocument(); + }); + + it('does not redirect on getBackendUrl error (web mode)', async () => { + const originalLocation = window.location; + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /discord/i })).toBeEnabled() + ); + expect((window.location as unknown as { href: string }).href).toBe(''); + + (window as unknown as Record).location = originalLocation; + }); + + it('does not call openUrl on getBackendUrl error (Tauri mode)', async () => { + mockIsTauri.mockReturnValue(true); + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /discord/i })).toBeEnabled() + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('is a no-op when disabled and clicked', async () => { + renderDiscordButton({ disabled: true }); + await clickButton(screen.getByRole('button', { name: /discord/i })); + expect(mockGetBackendUrl).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// URL construction +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Discord) — URL construction', () => { + it('uses /auth/discord/login path (not another provider)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + expect(mockOpenUrl.mock.calls[0][0]).toContain('/auth/discord/login'); + }); + + it('appends ?responseType=json in dev mode (Tauri)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + expect(mockOpenUrl.mock.calls[0][0]).toBe( + 'https://api.example.com/auth/discord/login?responseType=json' + ); + }); +}); diff --git a/app/test/OAuthGitHub.test.tsx b/app/test/OAuthGitHub.test.tsx new file mode 100644 index 000000000..be7851842 --- /dev/null +++ b/app/test/OAuthGitHub.test.tsx @@ -0,0 +1,303 @@ +/// +/** + * Tests for GitHub OAuth login via OAuthProviderButton. + * + * Coverage areas: + * - GitHub button rendering (label, icon, dark styling) + * - OAuth flow in both Tauri (desktop) and web environments + * - Loading / disabled state management + * - Error handling when backend URL lookup fails + * - dev-mode URL construction (?responseType=json) + */ +import type { ComponentProps } from 'react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../src/test/test-utils'; +import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; +import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ + mockGetBackendUrl: vi.fn(), + mockOpenUrl: vi.fn(), + mockIsTauri: vi.fn(), +})); + +vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); +vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); +vi.mock('../src/utils/tauriCommands', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, isTauri: mockIsTauri }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const githubConfig = oauthProviderConfigs.find(p => p.id === 'github')!; + +const renderGitHubButton = (props: Partial> = {}) => + renderWithProviders(); + +const clickButton = (btn: HTMLElement) => act(async () => { fireEvent.click(btn); }); + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (GitHub) — rendering', () => { + it('shows the GitHub label', () => { + renderGitHubButton(); + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + it('is enabled by default', () => { + renderGitHubButton(); + expect(screen.getByRole('button', { name: /github/i })).toBeEnabled(); + }); + + it('is disabled when disabled prop is true', () => { + renderGitHubButton({ disabled: true }); + expect(screen.getByRole('button', { name: /github/i })).toBeDisabled(); + }); + + it('renders the GitHub SVG icon', () => { + const { container } = renderGitHubButton(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('has dark background styling', () => { + renderGitHubButton(); + expect(screen.getByRole('button', { name: /github/i })).toHaveClass('bg-gray-900'); + }); + + it('has white text', () => { + const { container } = renderGitHubButton(); + const label = container.querySelector('span'); + expect(label).toHaveClass('text-white'); + }); +}); + +// --------------------------------------------------------------------------- +// Web OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (GitHub) — web OAuth flow', () => { + const originalLocation = window.location; + + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); + mockIsTauri.mockReturnValue(false); + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + }); + + afterEach(() => { + (window as unknown as Record).location = originalLocation; + }); + + it('redirects to /auth/github/login?responseType=json on click', async () => { + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => { + expect((window.location as unknown as { href: string }).href).toBe( + 'http://localhost:5005/auth/github/login?responseType=json' + ); + }); + }); + + it('does not call openUrl in web mode', async () => { + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('calls getBackendUrl exactly once per click', async () => { + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Tauri OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (GitHub) — Tauri OAuth flow', () => { + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + }); + + it('calls openUrl with /auth/github/login?responseType=json', async () => { + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => { + expect(mockOpenUrl).toHaveBeenCalledWith( + 'https://api.example.com/auth/github/login?responseType=json' + ); + }); + }); + + it('does not set window.location.href in Tauri mode', async () => { + const originalHref = window.location.href; + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(window.location.href).toBe(originalHref); + }); + + it('remains in loading state after openUrl resolves (awaits deep-link callback)', async () => { + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(screen.getByText('Connecting...')).toBeInTheDocument(); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Loading state +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (GitHub) — loading state', () => { + it('shows spinner and "Connecting..." while getBackendUrl is pending', async () => { + let resolve!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockIsTauri.mockReturnValue(false); + + renderGitHubButton(); + const button = screen.getByRole('button', { name: /github/i }); + await clickButton(button); + + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(button).toBeDisabled(); + + await act(async () => { resolve('http://localhost:5005'); }); + }); + + it('ignores a second click while already loading', async () => { + let resolve!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockIsTauri.mockReturnValue(false); + + renderGitHubButton(); + const button = screen.getByRole('button', { name: /github/i }); + + await clickButton(button); + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + + fireEvent.click(button); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + + await act(async () => { resolve('http://localhost:5005'); }); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (GitHub) — error handling', () => { + beforeEach(() => { + mockIsTauri.mockReturnValue(false); + }); + + it('returns to enabled state after getBackendUrl throws', async () => { + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderGitHubButton(); + const button = screen.getByRole('button', { name: /github/i }); + await clickButton(button); + + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + it('does not redirect on getBackendUrl error (web mode)', async () => { + const originalLocation = window.location; + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /github/i })).toBeEnabled() + ); + expect((window.location as unknown as { href: string }).href).toBe(''); + + (window as unknown as Record).location = originalLocation; + }); + + it('does not call openUrl on getBackendUrl error (Tauri mode)', async () => { + mockIsTauri.mockReturnValue(true); + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /github/i })).toBeEnabled() + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('is a no-op when disabled and clicked', async () => { + renderGitHubButton({ disabled: true }); + await clickButton(screen.getByRole('button', { name: /github/i })); + expect(mockGetBackendUrl).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// URL construction — /auth/github/login path +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (GitHub) — URL construction', () => { + it('uses /auth/github/login path (not another provider)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + expect(mockOpenUrl.mock.calls[0][0]).toContain('/auth/github/login'); + }); + + it('appends ?responseType=json in dev mode (Tauri)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + expect(mockOpenUrl.mock.calls[0][0]).toBe( + 'https://api.example.com/auth/github/login?responseType=json' + ); + }); +}); diff --git a/app/test/OAuthLoginSection.test.tsx b/app/test/OAuthLoginSection.test.tsx new file mode 100644 index 000000000..0f7e5b53b --- /dev/null +++ b/app/test/OAuthLoginSection.test.tsx @@ -0,0 +1,396 @@ +/// +/** + * Tests for Google OAuth login via OAuthLoginSection and OAuthProviderButton. + * + * Coverage areas: + * - Section renders all providers including Google + * - Google button initiates OAuth in both Tauri (desktop) and web environments + * - Loading / disabled state management during login + * - Error handling when the backend URL lookup fails + * - dev-mode URL construction (responseType=json query param) + */ +import type { ComponentProps } from 'react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../src/test/test-utils'; +import OAuthLoginSection from '../src/components/oauth/OAuthLoginSection'; +import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; +import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; + +// --------------------------------------------------------------------------- +// Module mocks +// vi.hoisted() ensures mock functions are available inside vi.mock() factories +// (which are hoisted to the top of the file by Vitest). +// --------------------------------------------------------------------------- + +const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ + mockGetBackendUrl: vi.fn(), + mockOpenUrl: vi.fn(), + mockIsTauri: vi.fn(), +})); + +vi.mock('../src/services/backendUrl', () => ({ + getBackendUrl: mockGetBackendUrl, +})); + +vi.mock('../src/utils/openUrl', () => ({ + openUrl: mockOpenUrl, +})); + +vi.mock('../src/utils/tauriCommands', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, isTauri: mockIsTauri }; +}); + +// IS_DEV is set to `true` by the global setup mock of '../utils/config' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const googleConfig = oauthProviderConfigs.find(p => p.id === 'google')!; + +const renderSection = (props: Partial> = {}) => + renderWithProviders(); + +const renderGoogleButton = (props: Partial> = {}) => + renderWithProviders(); + +// act() with an async callback returns Promise, making await valid. +const clickButton = (btn: HTMLElement) => act(async () => { fireEvent.click(btn); }); + +// --------------------------------------------------------------------------- +// OAuthLoginSection — rendering +// --------------------------------------------------------------------------- + +describe('OAuthLoginSection', () => { + it('renders the "Continue with" heading', () => { + renderSection(); + expect(screen.getByText('Continue with')).toBeInTheDocument(); + }); + + it('renders a button for every configured OAuth provider', () => { + renderSection(); + for (const provider of oauthProviderConfigs) { + expect( + screen.getByRole('button', { name: new RegExp(provider.name, 'i') }) + ).toBeInTheDocument(); + } + }); + + it('renders a Google login button', () => { + renderSection(); + expect(screen.getByRole('button', { name: /google/i })).toBeInTheDocument(); + }); + + it('renders buttons in a 2-column grid', () => { + const { container } = renderSection(); + const grid = container.querySelector('.grid.grid-cols-2'); + expect(grid).toBeInTheDocument(); + expect(grid!.children).toHaveLength(oauthProviderConfigs.length); + }); + + it('applies extra className to the wrapper div', () => { + const { container } = renderSection({ className: 'mt-8' }); + expect(container.firstChild).toHaveClass('mt-8'); + }); + + it('forwards disabled prop to every provider button', () => { + renderSection({ disabled: true }); + for (const btn of screen.getAllByRole('button')) { + expect(btn).toBeDisabled(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Google button — initial render +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Google) — rendering', () => { + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); + mockIsTauri.mockReturnValue(false); + }); + + it('shows the Google label', () => { + renderGoogleButton(); + expect(screen.getByText('Google')).toBeInTheDocument(); + }); + + it('is enabled by default', () => { + renderGoogleButton(); + expect(screen.getByRole('button', { name: /google/i })).toBeEnabled(); + }); + + it('is disabled when disabled prop is true', () => { + renderGoogleButton({ disabled: true }); + expect(screen.getByRole('button', { name: /google/i })).toBeDisabled(); + }); + + it('renders the Google SVG icon', () => { + const { container } = renderGoogleButton(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Google button — web environment OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Google) — web OAuth flow', () => { + const originalLocation = window.location; + + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); + mockIsTauri.mockReturnValue(false); + + // Replace window.location so we can assert href changes + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + }); + + afterEach(() => { + (window as unknown as Record).location = originalLocation; + }); + + it('redirects to backend Google OAuth URL on click (web, IS_DEV=true)', async () => { + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => { + expect((window.location as unknown as { href: string }).href).toBe( + 'http://localhost:5005/auth/google/login?responseType=json' + ); + }); + }); + + it('does not call openUrl (Tauri) in web mode', async () => { + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('calls getBackendUrl exactly once per click', async () => { + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Google button — Tauri (desktop) OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Google) — Tauri OAuth flow', () => { + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + }); + + it('calls openUrl with the Google OAuth URL (Tauri, IS_DEV=true)', async () => { + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => { + expect(mockOpenUrl).toHaveBeenCalledWith( + 'https://api.example.com/auth/google/login?responseType=json' + ); + }); + }); + + it('does not set window.location.href in Tauri mode', async () => { + const originalHref = window.location.href; + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(window.location.href).toBe(originalHref); + }); +}); + +// --------------------------------------------------------------------------- +// Google button — loading state +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Google) — loading state', () => { + it('shows spinner and "Connecting..." text while login is in progress', async () => { + let resolveBackendUrl!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue( + new Promise(res => { resolveBackendUrl = res; }) + ); + mockIsTauri.mockReturnValue(false); + + renderGoogleButton(); + const button = screen.getByRole('button', { name: /google/i }); + + await clickButton(button); + + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(button).toBeDisabled(); + + // Settle the promise so React doesn't warn about state updates after unmount + await act(async () => { resolveBackendUrl('http://localhost:5005'); }); + }); + + it('does not respond to a second click while already loading', async () => { + let resolveBackendUrl!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue( + new Promise(res => { resolveBackendUrl = res; }) + ); + mockIsTauri.mockReturnValue(false); + + renderGoogleButton(); + const button = screen.getByRole('button', { name: /google/i }); + + await clickButton(button); + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + + // Second click while loading — getBackendUrl must still be called only once + fireEvent.click(button); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + + await act(async () => { resolveBackendUrl('http://localhost:5005'); }); + }); + + it('remains in loading state after successful Tauri openUrl (awaits deep-link callback)', async () => { + // By design: the app calls openUrl() to open the system browser and then waits + // for the deep-link callback. setIsLoading(false) is only called on error, so + // the button intentionally stays in "Connecting..." state. + mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(screen.getByText('Connecting...')).toBeInTheDocument(); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Google button — error handling +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Google) — error handling', () => { + beforeEach(() => { + mockIsTauri.mockReturnValue(false); + }); + + it('returns to enabled state after getBackendUrl throws', async () => { + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderGoogleButton(); + const button = screen.getByRole('button', { name: /google/i }); + await clickButton(button); + + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.getByText('Google')).toBeInTheDocument(); + }); + + it('does not redirect when getBackendUrl throws (web mode)', async () => { + const originalLocation = window.location; + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /google/i })).toBeEnabled() + ); + expect((window.location as unknown as { href: string }).href).toBe(''); + + (window as unknown as Record).location = originalLocation; + }); + + it('does not call openUrl when getBackendUrl throws in Tauri mode', async () => { + mockIsTauri.mockReturnValue(true); + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /google/i })).toBeEnabled() + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('is a no-op when the button is disabled and clicked', async () => { + renderGoogleButton({ disabled: true }); + await clickButton(screen.getByRole('button', { name: /google/i })); + expect(mockGetBackendUrl).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// URL construction — dev mode query params (IS_DEV=true via global setup mock) +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Google) — dev mode URL params', () => { + // The global setup.ts mocks IS_DEV=true, so these assertions run in that context. + + it('appends ?responseType=json to the Google OAuth URL in dev mode (Tauri)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + const calledUrl: string = mockOpenUrl.mock.calls[0][0]; + expect(calledUrl).toContain('?responseType=json'); + expect(calledUrl).toBe('https://api.example.com/auth/google/login?responseType=json'); + }); + + it('appends ?responseType=json to the Google OAuth URL in dev mode (web)', async () => { + const originalLocation = window.location; + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(false); + + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect((window.location as unknown as { href: string }).href).toBe( + 'https://api.example.com/auth/google/login?responseType=json' + ); + + (window as unknown as Record).location = originalLocation; + }); + + it('uses the /auth/google/login path (not another provider)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + const calledUrl: string = mockOpenUrl.mock.calls[0][0]; + expect(calledUrl).toContain('/auth/google/login'); + }); +}); diff --git a/app/test/OAuthTwitter.test.tsx b/app/test/OAuthTwitter.test.tsx new file mode 100644 index 000000000..649d85d10 --- /dev/null +++ b/app/test/OAuthTwitter.test.tsx @@ -0,0 +1,303 @@ +/// +/** + * Tests for Twitter/X OAuth login via OAuthProviderButton. + * + * Coverage areas: + * - Twitter button rendering (label, icon, black/dark styling) + * - OAuth flow in both Tauri (desktop) and web environments + * - Loading / disabled state management + * - Error handling when backend URL lookup fails + * - dev-mode URL construction (?responseType=json) + */ +import type { ComponentProps } from 'react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../src/test/test-utils'; +import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; +import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ + mockGetBackendUrl: vi.fn(), + mockOpenUrl: vi.fn(), + mockIsTauri: vi.fn(), +})); + +vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); +vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); +vi.mock('../src/utils/tauriCommands', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, isTauri: mockIsTauri }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const twitterConfig = oauthProviderConfigs.find(p => p.id === 'twitter')!; + +const renderTwitterButton = (props: Partial> = {}) => + renderWithProviders(); + +const clickButton = (btn: HTMLElement) => act(async () => { fireEvent.click(btn); }); + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Twitter) — rendering', () => { + it('shows the Twitter label', () => { + renderTwitterButton(); + expect(screen.getByText('Twitter')).toBeInTheDocument(); + }); + + it('is enabled by default', () => { + renderTwitterButton(); + expect(screen.getByRole('button', { name: /twitter/i })).toBeEnabled(); + }); + + it('is disabled when disabled prop is true', () => { + renderTwitterButton({ disabled: true }); + expect(screen.getByRole('button', { name: /twitter/i })).toBeDisabled(); + }); + + it('renders the Twitter SVG icon', () => { + const { container } = renderTwitterButton(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('has black background styling', () => { + renderTwitterButton(); + expect(screen.getByRole('button', { name: /twitter/i })).toHaveClass('bg-black'); + }); + + it('has white text', () => { + const { container } = renderTwitterButton(); + const label = container.querySelector('span'); + expect(label).toHaveClass('text-white'); + }); +}); + +// --------------------------------------------------------------------------- +// Web OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Twitter) — web OAuth flow', () => { + const originalLocation = window.location; + + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); + mockIsTauri.mockReturnValue(false); + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + }); + + afterEach(() => { + (window as unknown as Record).location = originalLocation; + }); + + it('redirects to /auth/twitter/login?responseType=json on click', async () => { + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => { + expect((window.location as unknown as { href: string }).href).toBe( + 'http://localhost:5005/auth/twitter/login?responseType=json' + ); + }); + }); + + it('does not call openUrl in web mode', async () => { + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('calls getBackendUrl exactly once per click', async () => { + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Tauri OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Twitter) — Tauri OAuth flow', () => { + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + }); + + it('calls openUrl with /auth/twitter/login?responseType=json', async () => { + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => { + expect(mockOpenUrl).toHaveBeenCalledWith( + 'https://api.example.com/auth/twitter/login?responseType=json' + ); + }); + }); + + it('does not set window.location.href in Tauri mode', async () => { + const originalHref = window.location.href; + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(window.location.href).toBe(originalHref); + }); + + it('remains in loading state after openUrl resolves (awaits deep-link callback)', async () => { + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(screen.getByText('Connecting...')).toBeInTheDocument(); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Loading state +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Twitter) — loading state', () => { + it('shows spinner and "Connecting..." while getBackendUrl is pending', async () => { + let resolve!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockIsTauri.mockReturnValue(false); + + renderTwitterButton(); + const button = screen.getByRole('button', { name: /twitter/i }); + await clickButton(button); + + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(button).toBeDisabled(); + + await act(async () => { resolve('http://localhost:5005'); }); + }); + + it('ignores a second click while already loading', async () => { + let resolve!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockIsTauri.mockReturnValue(false); + + renderTwitterButton(); + const button = screen.getByRole('button', { name: /twitter/i }); + + await clickButton(button); + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + + fireEvent.click(button); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + + await act(async () => { resolve('http://localhost:5005'); }); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Twitter) — error handling', () => { + beforeEach(() => { + mockIsTauri.mockReturnValue(false); + }); + + it('returns to enabled state after getBackendUrl throws', async () => { + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderTwitterButton(); + const button = screen.getByRole('button', { name: /twitter/i }); + await clickButton(button); + + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.getByText('Twitter')).toBeInTheDocument(); + }); + + it('does not redirect on getBackendUrl error (web mode)', async () => { + const originalLocation = window.location; + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /twitter/i })).toBeEnabled() + ); + expect((window.location as unknown as { href: string }).href).toBe(''); + + (window as unknown as Record).location = originalLocation; + }); + + it('does not call openUrl on getBackendUrl error (Tauri mode)', async () => { + mockIsTauri.mockReturnValue(true); + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /twitter/i })).toBeEnabled() + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('is a no-op when disabled and clicked', async () => { + renderTwitterButton({ disabled: true }); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + expect(mockGetBackendUrl).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// URL construction +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Twitter) — URL construction', () => { + it('uses /auth/twitter/login path (not another provider)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + expect(mockOpenUrl.mock.calls[0][0]).toContain('/auth/twitter/login'); + }); + + it('appends ?responseType=json in dev mode (Tauri)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + expect(mockOpenUrl.mock.calls[0][0]).toBe( + 'https://api.example.com/auth/twitter/login?responseType=json' + ); + }); +}); From 877a96dd373d7deb1e9baf718c19260653936312 Mon Sep 17 00:00:00 2001 From: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:52:20 +0530 Subject: [PATCH 3/6] Enhance CI workflows: multi-platform build, Rust quality gates, Windows release (#75) --- .github/workflows/build.yml | 77 +++++++++++++++++++++++++++++------ .github/workflows/release.yml | 28 ++++++++++--- .github/workflows/test.yml | 21 +++++++--- scripts/load-dotenv.sh | 6 ++- 4 files changed, 108 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 519deb759..e30ec993d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,13 +9,65 @@ permissions: pull-requests: read concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref || github.ref }} cancel-in-progress: true jobs: - test-build: - name: Build Tauri App + rust-quality: + name: Rust Quality (fmt + clippy) runs-on: ubuntu-22.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install Rust (rust-toolchain.toml) + uses: dtolnay/rust-toolchain@1.93.0 + with: + components: rustfmt, clippy + + - name: Install Tauri build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Cargo.lock fingerprint (deps only) + id: cargo-lock-fingerprint + shell: bash + run: | + echo "hash=$(tail -n +8 Cargo.lock | openssl dgst -sha256 | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Cache Cargo registry and git sources + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-registry-${{ steps.cargo-lock-fingerprint.outputs.hash }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Check formatting (cargo fmt) + run: cargo fmt --all -- --check + + - name: Run clippy (core crate) + run: cargo clippy -p openhuman + + build: + name: Build Tauri App (${{ matrix.settings.label }}) + needs: rust-quality + runs-on: ${{ matrix.settings.platform }} + strategy: + fail-fast: false + matrix: + settings: + - platform: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + label: Linux x86_64 + - platform: macos-latest + target: aarch64-apple-darwin + label: macOS ARM64 steps: - name: Checkout code uses: actions/checkout@v4 @@ -29,12 +81,13 @@ jobs: node-version: 24.x cache: "yarn" - - name: Install Rust stable + - name: Install Rust (rust-toolchain.toml) uses: dtolnay/rust-toolchain@1.93.0 with: - targets: x86_64-unknown-linux-gnu + targets: ${{ matrix.settings.target }} - - name: Install Tauri dependencies + - name: Install Tauri dependencies (Linux) + if: matrix.settings.platform == 'ubuntu-22.04' run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf @@ -70,19 +123,19 @@ jobs: run: yarn install --frozen-lockfile - name: Build sidecar core binary - run: cargo build --manifest-path Cargo.toml --release --target x86_64-unknown-linux-gnu --bin openhuman + run: cargo build --manifest-path Cargo.toml --release --target ${{ matrix.settings.target }} --bin openhuman - name: Stage sidecar for Tauri bundler + shell: bash run: | mkdir -p app/src-tauri/binaries - # Release artifacts for the root package land in repo root target/ - cp target/x86_64-unknown-linux-gnu/release/openhuman app/src-tauri/binaries/openhuman-x86_64-unknown-linux-gnu - chmod +x app/src-tauri/binaries/openhuman-x86_64-unknown-linux-gnu + cp target/${{ matrix.settings.target }}/release/openhuman app/src-tauri/binaries/openhuman-${{ matrix.settings.target }} + chmod +x app/src-tauri/binaries/openhuman-${{ matrix.settings.target }} - name: Build Tauri app working-directory: app run: | - TAURI_CONFIG_OVERRIDE='{"bundle":{"createUpdaterArtifacts":false}}' - yarn tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles none -- --target x86_64-unknown-linux-gnu + TAURI_CONFIG_OVERRIDE='{"bundle":{"createUpdaterArtifacts":"never"},"plugins":{"updater":{"active":false}}}' + yarn tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles none --target ${{ matrix.settings.target }} -- -- --bin OpenHuman env: NODE_ENV: production diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3ee1293f..1a1e1d1a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,6 +221,10 @@ jobs: args: --target x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu artifact_suffix: ubuntu + - platform: windows-latest + args: --target x86_64-pc-windows-msvc + target: x86_64-pc-windows-msvc + artifact_suffix: windows env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: @@ -376,10 +380,16 @@ jobs: shell: bash run: | mkdir -p app/src-tauri/binaries - SOURCE="$CORE_TARGET_DIR/$CORE_BIN_NAME" - DEST="app/src-tauri/binaries/$SIDECAR_BASE-$MATRIX_TARGET" + EXE_SUFFIX="" + if [[ "$MATRIX_TARGET" == *"windows"* ]]; then + EXE_SUFFIX=".exe" + fi + SOURCE="$CORE_TARGET_DIR/${CORE_BIN_NAME}${EXE_SUFFIX}" + DEST="app/src-tauri/binaries/${SIDECAR_BASE}-${MATRIX_TARGET}${EXE_SUFFIX}" cp "$SOURCE" "$DEST" - chmod +x "$DEST" + if [[ "$MATRIX_TARGET" != *"windows"* ]]; then + chmod +x "$DEST" + fi env: MATRIX_TARGET: ${{ matrix.settings.target }} CORE_TARGET_DIR: ${{ steps.core-paths.outputs.core_target_dir }} @@ -389,7 +399,11 @@ jobs: - name: Verify staged sidecar for bundler (all platforms) shell: bash run: | - SIDE_CAR_PATH="app/src-tauri/binaries/$SIDECAR_BASE-$MATRIX_TARGET" + EXE_SUFFIX="" + if [[ "$MATRIX_TARGET" == *"windows"* ]]; then + EXE_SUFFIX=".exe" + fi + SIDE_CAR_PATH="app/src-tauri/binaries/${SIDECAR_BASE}-${MATRIX_TARGET}${EXE_SUFFIX}" echo "Checking staged sidecar: $SIDE_CAR_PATH" if [ ! -f "$SIDE_CAR_PATH" ]; then echo "Missing staged sidecar binary: $SIDE_CAR_PATH" @@ -409,8 +423,12 @@ jobs: shell: bash run: | BASE_DIR="$CORE_TARGET_DIR" + EXE_SUFFIX="" + if [[ "$MATRIX_TARGET" == *"windows"* ]]; then + EXE_SUFFIX=".exe" + fi echo "base_dir=$BASE_DIR" >> "$GITHUB_OUTPUT" - echo "cli_path=$BASE_DIR/$CORE_BIN_NAME" >> "$GITHUB_OUTPUT" + echo "cli_path=$BASE_DIR/${CORE_BIN_NAME}${EXE_SUFFIX}" >> "$GITHUB_OUTPUT" env: MATRIX_TARGET: ${{ matrix.settings.target }} CORE_TARGET_DIR: ${{ steps.core-paths.outputs.core_target_dir }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c68c96601..7a95de7e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,8 +13,8 @@ concurrency: cancel-in-progress: true jobs: - test: - name: Run Unit Tests + unit-tests: + name: Frontend Unit Tests runs-on: ubuntu-latest steps: - name: Checkout code @@ -56,7 +56,7 @@ jobs: retention-days: 7 rust-tests: - name: Run Rust Tests + name: Rust Tests + Quality runs-on: ubuntu-22.04 steps: - name: Checkout code @@ -67,6 +67,7 @@ jobs: - name: Install Rust (rust-toolchain.toml) uses: dtolnay/rust-toolchain@1.93.0 with: + components: rustfmt, clippy targets: x86_64-unknown-linux-gnu - name: Install Tauri build dependencies @@ -91,10 +92,16 @@ jobs: restore-keys: | ${{ runner.os }}-rust-test-cargo- - - name: Test rust-core (openhuman) + - name: Check formatting (cargo fmt) + run: cargo fmt --all -- --check + + - name: Run clippy (core crate) + run: cargo clippy -p openhuman + + - name: Test core crate (openhuman) run: cargo test -p openhuman - - name: Test src-tauri (OpenHuman) + - name: Test Tauri shell (OpenHuman) run: cargo test --manifest-path app/src-tauri/Cargo.toml e2e-macos: @@ -121,7 +128,9 @@ jobs: run: yarn install --frozen-lockfile - name: Ensure .env exists for E2E build - run: touch app/.env + run: | + touch .env + touch app/.env - name: Install Appium and mac2 driver run: | diff --git a/scripts/load-dotenv.sh b/scripts/load-dotenv.sh index d0649cb46..54412dcaa 100755 --- a/scripts/load-dotenv.sh +++ b/scripts/load-dotenv.sh @@ -38,7 +38,11 @@ while IFS= read -r line || [[ -n "$line" ]]; do fi done < "$RESOLVED" -joined=$(printf '%s\n' "${exports[@]}") +if [[ ${#exports[@]} -eq 0 ]]; then + joined="" +else + joined=$(printf '%s\n' "${exports[@]}") +fi if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then echo "$joined" From 513562e814355bb1d8c7b7d84dcb6bc9106473bd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 30 Mar 2026 07:47:19 -0700 Subject: [PATCH 4/6] Remove macOS ARM64 build workflow from GitHub Actions. This change eliminates the dedicated workflow file, streamlining CI configurations. Adjustments made to the release workflow to ensure compatibility with the updated build process. --- .github/workflows/macos-arm64-build.yml | 138 ------------------------ .github/workflows/release.yml | 12 +-- 2 files changed, 4 insertions(+), 146 deletions(-) delete mode 100644 .github/workflows/macos-arm64-build.yml diff --git a/.github/workflows/macos-arm64-build.yml b/.github/workflows/macos-arm64-build.yml deleted file mode 100644 index 6282aa895..000000000 --- a/.github/workflows/macos-arm64-build.yml +++ /dev/null @@ -1,138 +0,0 @@ -name: macOS ARM64 Build - -on: - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: macos-arm64-build-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-signed-macos-arm64: - name: Build signed macOS ARM64 bundle - runs-on: macos-latest - environment: Production - env: - TARGET: aarch64-apple-darwin - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 1 - submodules: true - - - name: Set Xcode version - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: latest-stable - - - name: Setup Node.js 24.x - uses: actions/setup-node@v4 - with: - node-version: 24.x - cache: yarn - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.93.0 - with: - targets: aarch64-apple-darwin - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Validate signing prerequisites - shell: bash - env: - UPDATER_PUBLIC_KEY: ${{ secrets.UPDATER_PUBLIC_KEY || vars.UPDATER_PUBLIC_KEY }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }} - APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - for var in UPDATER_PUBLIC_KEY TAURI_SIGNING_PRIVATE_KEY APPLE_CERTIFICATE_BASE64 APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do - if [ -z "${!var}" ]; then - echo "Missing required secret/variable: $var" - exit 1 - fi - done - - - name: Define Tauri configuration overrides - id: config-overrides - uses: actions/github-script@v7 - env: - BASE_URL: ${{ vars.BASE_URL }} - UPDATER_PUBLIC_KEY: ${{ secrets.UPDATER_PUBLIC_KEY || vars.UPDATER_PUBLIC_KEY }} - UPDATER_ENDPOINT: ${{ vars.UPDATER_ENDPOINT }} - UPDATER_REPO: tinyhumansai/openhuman - WITH_UPDATER: "true" - with: - script: | - const workspacePath = process.env.GITHUB_WORKSPACE.replace(/\\/g, '/'); - const prefix = workspacePath.startsWith('/') ? 'file://' : 'file:///'; - const moduleUrl = `${prefix}${workspacePath}/scripts/prepareTauriConfig.js`; - const { default: prepareTauriConfig } = await import(moduleUrl); - const config = prepareTauriConfig(); - core.setOutput('json', JSON.stringify(config)); - - - name: Build frontend - run: yarn workspace openhuman-app build - env: - NODE_ENV: production - VITE_BACKEND_URL: ${{ vars.VITE_BACKEND_URL }} - VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} - VITE_DEBUG: ${{ vars.VITE_DEBUG }} - - - name: Build sidecar core binary - run: cargo build --manifest-path Cargo.toml --release --target "$TARGET" --bin openhuman - - - name: Stage sidecar for Tauri bundler - shell: bash - run: | - mkdir -p app/src-tauri/binaries - cp "target/$TARGET/release/openhuman" "app/src-tauri/binaries/openhuman-$TARGET" - chmod +x "app/src-tauri/binaries/openhuman-$TARGET" - - - name: Build signed app and dmg - working-directory: app - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }} - MACOSX_DEPLOYMENT_TARGET: "10.15" - run: | - yarn tauri build -c '${{ steps.config-overrides.outputs.json }}' --target "$TARGET" --bundles app,dmg -- --bin OpenHuman - - - name: Verify macOS app bundle sidecar layout - shell: bash - run: | - APP_PATH="target/$TARGET/release/bundle/macos/OpenHuman.app" - echo "Inspecting bundle at: $APP_PATH" - ls -la "$APP_PATH/Contents/MacOS" - ls -la "$APP_PATH/Contents/Resources" | grep openhuman || true - if [ -f "$APP_PATH/Contents/MacOS/openhuman" ]; then - echo "Unexpected standalone core binary found in MacOS dir" - exit 1 - fi - if ! ls "$APP_PATH/Contents/Resources"/openhuman-* >/dev/null 2>&1; then - echo "Sidecar core binary missing from app resources" - exit 1 - fi - - - name: Upload signed macOS artifacts - uses: actions/upload-artifact@v4 - with: - name: macos-arm64-signed-bundles - path: | - target/${{ env.TARGET }}/release/bundle/macos/*.app - target/${{ env.TARGET }}/release/bundle/dmg/*.dmg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a1e1d1a9..650734717 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -451,14 +451,10 @@ jobs: WITH_UPDATER: "true" with: projectPath: app - # Tools discovery now uses a JS mock registry (no Rust discovery - # binary target), so there is no extra helper executable for Tauri - # to copy/sign inside release app bundles. - # Restrict Cargo bin selection for the Tauri bundle build so only - # the desktop UI app binary is packaged. - # Yarn v1 strips one "--" layer when invoking scripts, hence the - # double-separator when forwarding flags to Cargo. - args: -c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }} -- -- --bin OpenHuman + # OpenHuman is the only declared Tauri bin, so no extra Cargo bin + # forwarding is required here. Keep args minimal so --target is + # interpreted consistently by tauri-action artifact discovery. + args: -c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }} includeDebug: false includeRelease: true releaseId: ${{ needs.create-release.outputs.release_id }} From 3b9fd2e446c7839f38d3109bc7d88fdc55139343 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 30 Mar 2026 07:47:53 -0700 Subject: [PATCH 5/6] Refactor OAuth provider tests for consistency and readability - Updated test files for Discord, GitHub, Google, and Twitter to improve code consistency by standardizing async function formatting and import statements. - Enhanced readability by restructuring promise resolutions and waitFor assertions. - Ensured uniformity in the handling of button click events across all OAuth provider tests. --- app/test/OAuthDiscord.test.tsx | 47 +++++++++++++----------- app/test/OAuthGitHub.test.tsx | 47 +++++++++++++----------- app/test/OAuthLoginSection.test.tsx | 55 ++++++++++++++--------------- app/test/OAuthTwitter.test.tsx | 47 +++++++++++++----------- app/test/tsconfig.unit.json | 4 +-- 5 files changed, 108 insertions(+), 92 deletions(-) diff --git a/app/test/OAuthDiscord.test.tsx b/app/test/OAuthDiscord.test.tsx index 1eb53d48a..ce6a7286c 100644 --- a/app/test/OAuthDiscord.test.tsx +++ b/app/test/OAuthDiscord.test.tsx @@ -9,13 +9,13 @@ * - Error handling when backend URL lookup fails * - dev-mode URL construction (?responseType=json) */ -import type { ComponentProps } from 'react'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import type { ComponentProps } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { renderWithProviders } from '../src/test/test-utils'; import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; +import { renderWithProviders } from '../src/test/test-utils'; // --------------------------------------------------------------------------- // Module mocks @@ -29,7 +29,7 @@ const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); -vi.mock('../src/utils/tauriCommands', async (importOriginal) => { +vi.mock('../src/utils/tauriCommands', async importOriginal => { const actual = await importOriginal>(); return { ...actual, isTauri: mockIsTauri }; }); @@ -43,7 +43,10 @@ const discordConfig = oauthProviderConfigs.find(p => p.id === 'discord')!; const renderDiscordButton = (props: Partial> = {}) => renderWithProviders(); -const clickButton = (btn: HTMLElement) => act(async () => { fireEvent.click(btn); }); +const clickButton = (btn: HTMLElement) => + act(async () => { + fireEvent.click(btn); + }); // --------------------------------------------------------------------------- // Rendering @@ -115,9 +118,7 @@ describe('OAuthProviderButton (Discord) — web OAuth flow', () => { renderDiscordButton(); await clickButton(screen.getByRole('button', { name: /discord/i })); - await waitFor(() => - expect((window.location as unknown as { href: string }).href).not.toBe('') - ); + await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe('')); expect(mockOpenUrl).not.toHaveBeenCalled(); }); @@ -125,9 +126,7 @@ describe('OAuthProviderButton (Discord) — web OAuth flow', () => { renderDiscordButton(); await clickButton(screen.getByRole('button', { name: /discord/i })); - await waitFor(() => - expect((window.location as unknown as { href: string }).href).not.toBe('') - ); + await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe('')); expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); }); }); @@ -180,7 +179,11 @@ describe('OAuthProviderButton (Discord) — Tauri OAuth flow', () => { describe('OAuthProviderButton (Discord) — loading state', () => { it('shows spinner and "Connecting..." while getBackendUrl is pending', async () => { let resolve!: (_v: string) => void; - mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockGetBackendUrl.mockReturnValue( + new Promise(res => { + resolve = res; + }) + ); mockIsTauri.mockReturnValue(false); renderDiscordButton(); @@ -191,12 +194,18 @@ describe('OAuthProviderButton (Discord) — loading state', () => { expect(document.querySelector('.animate-spin')).toBeInTheDocument(); expect(button).toBeDisabled(); - await act(async () => { resolve('http://localhost:5005'); }); + await act(async () => { + resolve('http://localhost:5005'); + }); }); it('ignores a second click while already loading', async () => { let resolve!: (_v: string) => void; - mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockGetBackendUrl.mockReturnValue( + new Promise(res => { + resolve = res; + }) + ); mockIsTauri.mockReturnValue(false); renderDiscordButton(); @@ -208,7 +217,9 @@ describe('OAuthProviderButton (Discord) — loading state', () => { fireEvent.click(button); expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); - await act(async () => { resolve('http://localhost:5005'); }); + await act(async () => { + resolve('http://localhost:5005'); + }); }); }); @@ -242,9 +253,7 @@ describe('OAuthProviderButton (Discord) — error handling', () => { renderDiscordButton(); await clickButton(screen.getByRole('button', { name: /discord/i })); - await waitFor(() => - expect(screen.getByRole('button', { name: /discord/i })).toBeEnabled() - ); + await waitFor(() => expect(screen.getByRole('button', { name: /discord/i })).toBeEnabled()); expect((window.location as unknown as { href: string }).href).toBe(''); (window as unknown as Record).location = originalLocation; @@ -257,9 +266,7 @@ describe('OAuthProviderButton (Discord) — error handling', () => { renderDiscordButton(); await clickButton(screen.getByRole('button', { name: /discord/i })); - await waitFor(() => - expect(screen.getByRole('button', { name: /discord/i })).toBeEnabled() - ); + await waitFor(() => expect(screen.getByRole('button', { name: /discord/i })).toBeEnabled()); expect(mockOpenUrl).not.toHaveBeenCalled(); }); diff --git a/app/test/OAuthGitHub.test.tsx b/app/test/OAuthGitHub.test.tsx index be7851842..36686529a 100644 --- a/app/test/OAuthGitHub.test.tsx +++ b/app/test/OAuthGitHub.test.tsx @@ -9,13 +9,13 @@ * - Error handling when backend URL lookup fails * - dev-mode URL construction (?responseType=json) */ -import type { ComponentProps } from 'react'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import type { ComponentProps } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { renderWithProviders } from '../src/test/test-utils'; import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; +import { renderWithProviders } from '../src/test/test-utils'; // --------------------------------------------------------------------------- // Module mocks @@ -29,7 +29,7 @@ const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); -vi.mock('../src/utils/tauriCommands', async (importOriginal) => { +vi.mock('../src/utils/tauriCommands', async importOriginal => { const actual = await importOriginal>(); return { ...actual, isTauri: mockIsTauri }; }); @@ -43,7 +43,10 @@ const githubConfig = oauthProviderConfigs.find(p => p.id === 'github')!; const renderGitHubButton = (props: Partial> = {}) => renderWithProviders(); -const clickButton = (btn: HTMLElement) => act(async () => { fireEvent.click(btn); }); +const clickButton = (btn: HTMLElement) => + act(async () => { + fireEvent.click(btn); + }); // --------------------------------------------------------------------------- // Rendering @@ -115,9 +118,7 @@ describe('OAuthProviderButton (GitHub) — web OAuth flow', () => { renderGitHubButton(); await clickButton(screen.getByRole('button', { name: /github/i })); - await waitFor(() => - expect((window.location as unknown as { href: string }).href).not.toBe('') - ); + await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe('')); expect(mockOpenUrl).not.toHaveBeenCalled(); }); @@ -125,9 +126,7 @@ describe('OAuthProviderButton (GitHub) — web OAuth flow', () => { renderGitHubButton(); await clickButton(screen.getByRole('button', { name: /github/i })); - await waitFor(() => - expect((window.location as unknown as { href: string }).href).not.toBe('') - ); + await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe('')); expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); }); }); @@ -180,7 +179,11 @@ describe('OAuthProviderButton (GitHub) — Tauri OAuth flow', () => { describe('OAuthProviderButton (GitHub) — loading state', () => { it('shows spinner and "Connecting..." while getBackendUrl is pending', async () => { let resolve!: (_v: string) => void; - mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockGetBackendUrl.mockReturnValue( + new Promise(res => { + resolve = res; + }) + ); mockIsTauri.mockReturnValue(false); renderGitHubButton(); @@ -191,12 +194,18 @@ describe('OAuthProviderButton (GitHub) — loading state', () => { expect(document.querySelector('.animate-spin')).toBeInTheDocument(); expect(button).toBeDisabled(); - await act(async () => { resolve('http://localhost:5005'); }); + await act(async () => { + resolve('http://localhost:5005'); + }); }); it('ignores a second click while already loading', async () => { let resolve!: (_v: string) => void; - mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockGetBackendUrl.mockReturnValue( + new Promise(res => { + resolve = res; + }) + ); mockIsTauri.mockReturnValue(false); renderGitHubButton(); @@ -208,7 +217,9 @@ describe('OAuthProviderButton (GitHub) — loading state', () => { fireEvent.click(button); expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); - await act(async () => { resolve('http://localhost:5005'); }); + await act(async () => { + resolve('http://localhost:5005'); + }); }); }); @@ -242,9 +253,7 @@ describe('OAuthProviderButton (GitHub) — error handling', () => { renderGitHubButton(); await clickButton(screen.getByRole('button', { name: /github/i })); - await waitFor(() => - expect(screen.getByRole('button', { name: /github/i })).toBeEnabled() - ); + await waitFor(() => expect(screen.getByRole('button', { name: /github/i })).toBeEnabled()); expect((window.location as unknown as { href: string }).href).toBe(''); (window as unknown as Record).location = originalLocation; @@ -257,9 +266,7 @@ describe('OAuthProviderButton (GitHub) — error handling', () => { renderGitHubButton(); await clickButton(screen.getByRole('button', { name: /github/i })); - await waitFor(() => - expect(screen.getByRole('button', { name: /github/i })).toBeEnabled() - ); + await waitFor(() => expect(screen.getByRole('button', { name: /github/i })).toBeEnabled()); expect(mockOpenUrl).not.toHaveBeenCalled(); }); diff --git a/app/test/OAuthLoginSection.test.tsx b/app/test/OAuthLoginSection.test.tsx index 0f7e5b53b..0e974199c 100644 --- a/app/test/OAuthLoginSection.test.tsx +++ b/app/test/OAuthLoginSection.test.tsx @@ -9,14 +9,14 @@ * - Error handling when the backend URL lookup fails * - dev-mode URL construction (responseType=json query param) */ -import type { ComponentProps } from 'react'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import type { ComponentProps } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { renderWithProviders } from '../src/test/test-utils'; import OAuthLoginSection from '../src/components/oauth/OAuthLoginSection'; import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; +import { renderWithProviders } from '../src/test/test-utils'; // --------------------------------------------------------------------------- // Module mocks @@ -30,15 +30,11 @@ const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ mockIsTauri: vi.fn(), })); -vi.mock('../src/services/backendUrl', () => ({ - getBackendUrl: mockGetBackendUrl, -})); +vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); -vi.mock('../src/utils/openUrl', () => ({ - openUrl: mockOpenUrl, -})); +vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); -vi.mock('../src/utils/tauriCommands', async (importOriginal) => { +vi.mock('../src/utils/tauriCommands', async importOriginal => { const actual = await importOriginal>(); return { ...actual, isTauri: mockIsTauri }; }); @@ -58,7 +54,10 @@ const renderGoogleButton = (props: Partial); // act() with an async callback returns Promise, making await valid. -const clickButton = (btn: HTMLElement) => act(async () => { fireEvent.click(btn); }); +const clickButton = (btn: HTMLElement) => + act(async () => { + fireEvent.click(btn); + }); // --------------------------------------------------------------------------- // OAuthLoginSection — rendering @@ -170,9 +169,7 @@ describe('OAuthProviderButton (Google) — web OAuth flow', () => { renderGoogleButton(); await clickButton(screen.getByRole('button', { name: /google/i })); - await waitFor(() => - expect((window.location as unknown as { href: string }).href).not.toBe('') - ); + await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe('')); expect(mockOpenUrl).not.toHaveBeenCalled(); }); @@ -180,9 +177,7 @@ describe('OAuthProviderButton (Google) — web OAuth flow', () => { renderGoogleButton(); await clickButton(screen.getByRole('button', { name: /google/i })); - await waitFor(() => - expect((window.location as unknown as { href: string }).href).not.toBe('') - ); + await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe('')); expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); }); }); @@ -227,7 +222,9 @@ describe('OAuthProviderButton (Google) — loading state', () => { it('shows spinner and "Connecting..." text while login is in progress', async () => { let resolveBackendUrl!: (_v: string) => void; mockGetBackendUrl.mockReturnValue( - new Promise(res => { resolveBackendUrl = res; }) + new Promise(res => { + resolveBackendUrl = res; + }) ); mockIsTauri.mockReturnValue(false); @@ -241,13 +238,17 @@ describe('OAuthProviderButton (Google) — loading state', () => { expect(button).toBeDisabled(); // Settle the promise so React doesn't warn about state updates after unmount - await act(async () => { resolveBackendUrl('http://localhost:5005'); }); + await act(async () => { + resolveBackendUrl('http://localhost:5005'); + }); }); it('does not respond to a second click while already loading', async () => { let resolveBackendUrl!: (_v: string) => void; mockGetBackendUrl.mockReturnValue( - new Promise(res => { resolveBackendUrl = res; }) + new Promise(res => { + resolveBackendUrl = res; + }) ); mockIsTauri.mockReturnValue(false); @@ -261,7 +262,9 @@ describe('OAuthProviderButton (Google) — loading state', () => { fireEvent.click(button); expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); - await act(async () => { resolveBackendUrl('http://localhost:5005'); }); + await act(async () => { + resolveBackendUrl('http://localhost:5005'); + }); }); it('remains in loading state after successful Tauri openUrl (awaits deep-link callback)', async () => { @@ -311,9 +314,7 @@ describe('OAuthProviderButton (Google) — error handling', () => { renderGoogleButton(); await clickButton(screen.getByRole('button', { name: /google/i })); - await waitFor(() => - expect(screen.getByRole('button', { name: /google/i })).toBeEnabled() - ); + await waitFor(() => expect(screen.getByRole('button', { name: /google/i })).toBeEnabled()); expect((window.location as unknown as { href: string }).href).toBe(''); (window as unknown as Record).location = originalLocation; @@ -326,9 +327,7 @@ describe('OAuthProviderButton (Google) — error handling', () => { renderGoogleButton(); await clickButton(screen.getByRole('button', { name: /google/i })); - await waitFor(() => - expect(screen.getByRole('button', { name: /google/i })).toBeEnabled() - ); + await waitFor(() => expect(screen.getByRole('button', { name: /google/i })).toBeEnabled()); expect(mockOpenUrl).not.toHaveBeenCalled(); }); @@ -371,9 +370,7 @@ describe('OAuthProviderButton (Google) — dev mode URL params', () => { renderGoogleButton(); await clickButton(screen.getByRole('button', { name: /google/i })); - await waitFor(() => - expect((window.location as unknown as { href: string }).href).not.toBe('') - ); + await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe('')); expect((window.location as unknown as { href: string }).href).toBe( 'https://api.example.com/auth/google/login?responseType=json' ); diff --git a/app/test/OAuthTwitter.test.tsx b/app/test/OAuthTwitter.test.tsx index 649d85d10..fcd3c8c20 100644 --- a/app/test/OAuthTwitter.test.tsx +++ b/app/test/OAuthTwitter.test.tsx @@ -9,13 +9,13 @@ * - Error handling when backend URL lookup fails * - dev-mode URL construction (?responseType=json) */ -import type { ComponentProps } from 'react'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import type { ComponentProps } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { renderWithProviders } from '../src/test/test-utils'; import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; +import { renderWithProviders } from '../src/test/test-utils'; // --------------------------------------------------------------------------- // Module mocks @@ -29,7 +29,7 @@ const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); -vi.mock('../src/utils/tauriCommands', async (importOriginal) => { +vi.mock('../src/utils/tauriCommands', async importOriginal => { const actual = await importOriginal>(); return { ...actual, isTauri: mockIsTauri }; }); @@ -43,7 +43,10 @@ const twitterConfig = oauthProviderConfigs.find(p => p.id === 'twitter')!; const renderTwitterButton = (props: Partial> = {}) => renderWithProviders(); -const clickButton = (btn: HTMLElement) => act(async () => { fireEvent.click(btn); }); +const clickButton = (btn: HTMLElement) => + act(async () => { + fireEvent.click(btn); + }); // --------------------------------------------------------------------------- // Rendering @@ -115,9 +118,7 @@ describe('OAuthProviderButton (Twitter) — web OAuth flow', () => { renderTwitterButton(); await clickButton(screen.getByRole('button', { name: /twitter/i })); - await waitFor(() => - expect((window.location as unknown as { href: string }).href).not.toBe('') - ); + await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe('')); expect(mockOpenUrl).not.toHaveBeenCalled(); }); @@ -125,9 +126,7 @@ describe('OAuthProviderButton (Twitter) — web OAuth flow', () => { renderTwitterButton(); await clickButton(screen.getByRole('button', { name: /twitter/i })); - await waitFor(() => - expect((window.location as unknown as { href: string }).href).not.toBe('') - ); + await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe('')); expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); }); }); @@ -180,7 +179,11 @@ describe('OAuthProviderButton (Twitter) — Tauri OAuth flow', () => { describe('OAuthProviderButton (Twitter) — loading state', () => { it('shows spinner and "Connecting..." while getBackendUrl is pending', async () => { let resolve!: (_v: string) => void; - mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockGetBackendUrl.mockReturnValue( + new Promise(res => { + resolve = res; + }) + ); mockIsTauri.mockReturnValue(false); renderTwitterButton(); @@ -191,12 +194,18 @@ describe('OAuthProviderButton (Twitter) — loading state', () => { expect(document.querySelector('.animate-spin')).toBeInTheDocument(); expect(button).toBeDisabled(); - await act(async () => { resolve('http://localhost:5005'); }); + await act(async () => { + resolve('http://localhost:5005'); + }); }); it('ignores a second click while already loading', async () => { let resolve!: (_v: string) => void; - mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockGetBackendUrl.mockReturnValue( + new Promise(res => { + resolve = res; + }) + ); mockIsTauri.mockReturnValue(false); renderTwitterButton(); @@ -208,7 +217,9 @@ describe('OAuthProviderButton (Twitter) — loading state', () => { fireEvent.click(button); expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); - await act(async () => { resolve('http://localhost:5005'); }); + await act(async () => { + resolve('http://localhost:5005'); + }); }); }); @@ -242,9 +253,7 @@ describe('OAuthProviderButton (Twitter) — error handling', () => { renderTwitterButton(); await clickButton(screen.getByRole('button', { name: /twitter/i })); - await waitFor(() => - expect(screen.getByRole('button', { name: /twitter/i })).toBeEnabled() - ); + await waitFor(() => expect(screen.getByRole('button', { name: /twitter/i })).toBeEnabled()); expect((window.location as unknown as { href: string }).href).toBe(''); (window as unknown as Record).location = originalLocation; @@ -257,9 +266,7 @@ describe('OAuthProviderButton (Twitter) — error handling', () => { renderTwitterButton(); await clickButton(screen.getByRole('button', { name: /twitter/i })); - await waitFor(() => - expect(screen.getByRole('button', { name: /twitter/i })).toBeEnabled() - ); + await waitFor(() => expect(screen.getByRole('button', { name: /twitter/i })).toBeEnabled()); expect(mockOpenUrl).not.toHaveBeenCalled(); }); diff --git a/app/test/tsconfig.unit.json b/app/test/tsconfig.unit.json index 326ae5d3e..455b8b212 100644 --- a/app/test/tsconfig.unit.json +++ b/app/test/tsconfig.unit.json @@ -1,7 +1,5 @@ { "extends": "../tsconfig.json", - "compilerOptions": { - "types": ["vitest/globals", "@testing-library/jest-dom", "node"] - }, + "compilerOptions": { "types": ["vitest/globals", "@testing-library/jest-dom", "node"] }, "include": ["../src", "./*.test.ts", "./*.test.tsx"] } From 680bd8cdadc603ae0c9f6c1a3be467d787bf0422 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 14:50:41 +0000 Subject: [PATCH 6/6] chore(release): v0.49.20 --- app/package.json | 2 +- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/package.json b/app/package.json index 45127fb12..703510727 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "openhuman-app", - "version": "0.49.19", + "version": "0.49.20", "type": "module", "scripts": { "dev": "vite", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index b27392378..1c61af301 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenHuman" -version = "0.49.19" +version = "0.49.20" description = "OpenHuman - AI-powered Super Assistant" authors = ["OpenHuman"] edition = "2021" diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index b16d5e40e..d4d34efd1 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenHuman", - "version": "0.49.19", + "version": "0.49.20", "identifier": "com.openhuman.app", "build": { "beforeDevCommand": "npm run core:stage && npm run dev",