mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 21:44:38 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
+65
-12
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -51,3 +51,4 @@ coverage/
|
||||
tauri.key
|
||||
tauri.key.pub
|
||||
/target/
|
||||
src-tauri/target/
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhuman-app",
|
||||
"version": "0.49.19",
|
||||
"version": "0.49.20",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": { "types": ["vitest/globals", "@testing-library/jest-dom", "node"] },
|
||||
"include": ["../src", "./*.test.ts", "./*.test.tsx"]
|
||||
}
|
||||
@@ -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
@@ -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" }]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user