Merge remote-tracking branch 'origin/main'

This commit is contained in:
cyrus
2026-03-30 21:12:43 +05:30
18 changed files with 1495 additions and 327 deletions
+65 -12
View File
@@ -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
-138
View File
@@ -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
+27 -13
View File
@@ -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 }}
+15 -6
View File
@@ -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: |
+1
View File
@@ -51,3 +51,4 @@ coverage/
tauri.key
tauri.key.pub
/target/
src-tauri/target/
+43
View File
@@ -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'],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openhuman-app",
"version": "0.49.19",
"version": "0.49.20",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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",
+310
View File
@@ -0,0 +1,310 @@
/// <reference types="@testing-library/jest-dom/vitest" />
/**
* 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<Record<string, unknown>>();
return { ...actual, isTauri: mockIsTauri };
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const discordConfig = oauthProviderConfigs.find(p => p.id === 'discord')!;
const renderDiscordButton = (props: Partial<ComponentProps<typeof OAuthProviderButton>> = {}) =>
renderWithProviders(<OAuthProviderButton provider={discordConfig} {...props} />);
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<string, unknown>).location;
(window as unknown as Record<string, unknown>).location = { href: '' };
});
afterEach(() => {
(window as unknown as Record<string, unknown>).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<string>(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<string>(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<string, unknown>).location;
(window as unknown as Record<string, unknown>).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<string, unknown>).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'
);
});
});
+310
View File
@@ -0,0 +1,310 @@
/// <reference types="@testing-library/jest-dom/vitest" />
/**
* 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<Record<string, unknown>>();
return { ...actual, isTauri: mockIsTauri };
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const githubConfig = oauthProviderConfigs.find(p => p.id === 'github')!;
const renderGitHubButton = (props: Partial<ComponentProps<typeof OAuthProviderButton>> = {}) =>
renderWithProviders(<OAuthProviderButton provider={githubConfig} {...props} />);
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<string, unknown>).location;
(window as unknown as Record<string, unknown>).location = { href: '' };
});
afterEach(() => {
(window as unknown as Record<string, unknown>).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<string>(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<string>(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<string, unknown>).location;
(window as unknown as Record<string, unknown>).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<string, unknown>).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'
);
});
});
+393
View File
@@ -0,0 +1,393 @@
/// <reference types="@testing-library/jest-dom/vitest" />
/**
* 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<Record<string, unknown>>();
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<ComponentProps<typeof OAuthLoginSection>> = {}) =>
renderWithProviders(<OAuthLoginSection {...props} />);
const renderGoogleButton = (props: Partial<ComponentProps<typeof OAuthProviderButton>> = {}) =>
renderWithProviders(<OAuthProviderButton provider={googleConfig} {...props} />);
// act() with an async callback returns Promise<void>, 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<string, unknown>).location;
(window as unknown as Record<string, unknown>).location = { href: '' };
});
afterEach(() => {
(window as unknown as Record<string, unknown>).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<string>(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<string>(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<string, unknown>).location;
(window as unknown as Record<string, unknown>).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<string, unknown>).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<string, unknown>).location;
(window as unknown as Record<string, unknown>).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<string, unknown>).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');
});
});
+310
View File
@@ -0,0 +1,310 @@
/// <reference types="@testing-library/jest-dom/vitest" />
/**
* 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<Record<string, unknown>>();
return { ...actual, isTauri: mockIsTauri };
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const twitterConfig = oauthProviderConfigs.find(p => p.id === 'twitter')!;
const renderTwitterButton = (props: Partial<ComponentProps<typeof OAuthProviderButton>> = {}) =>
renderWithProviders(<OAuthProviderButton provider={twitterConfig} {...props} />);
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<string, unknown>).location;
(window as unknown as Record<string, unknown>).location = { href: '' };
});
afterEach(() => {
(window as unknown as Record<string, unknown>).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<string>(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<string>(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<string, unknown>).location;
(window as unknown as Record<string, unknown>).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<string, unknown>).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'
);
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"compilerOptions": { "types": ["vitest/globals", "@testing-library/jest-dom", "node"] },
"include": ["../src", "./*.test.ts", "./*.test.tsx"]
}
+1 -1
View File
@@ -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: {
+1 -1
View File
@@ -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" }]
}
+5 -1
View File
@@ -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"
+6 -152
View File
@@ -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"