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/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 a3ee1293f..650734717 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 }} @@ -433,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 }} 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/.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/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", diff --git a/app/test/OAuthDiscord.test.tsx b/app/test/OAuthDiscord.test.tsx new file mode 100644 index 000000000..ce6a7286c --- /dev/null +++ b/app/test/OAuthDiscord.test.tsx @@ -0,0 +1,310 @@ +/// +/** + * 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 { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; +import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; +import { renderWithProviders } from '../src/test/test-utils'; + +// --------------------------------------------------------------------------- +// 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..36686529a --- /dev/null +++ b/app/test/OAuthGitHub.test.tsx @@ -0,0 +1,310 @@ +/// +/** + * 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 { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; +import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; +import { renderWithProviders } from '../src/test/test-utils'; + +// --------------------------------------------------------------------------- +// 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..0e974199c --- /dev/null +++ b/app/test/OAuthLoginSection.test.tsx @@ -0,0 +1,393 @@ +/// +/** + * 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 { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +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 +// 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..fcd3c8c20 --- /dev/null +++ b/app/test/OAuthTwitter.test.tsx @@ -0,0 +1,310 @@ +/// +/** + * 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 { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; +import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; +import { renderWithProviders } from '../src/test/test-utils'; + +// --------------------------------------------------------------------------- +// 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' + ); + }); +}); diff --git a/app/test/tsconfig.unit.json b/app/test/tsconfig.unit.json new file mode 100644 index 000000000..455b8b212 --- /dev/null +++ b/app/test/tsconfig.unit.json @@ -0,0 +1,5 @@ +{ + "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/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" 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"