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"