Files
openhuman/app/test/OAuthDiscord.test.tsx
T
d3a4ea3688 fix(oauth): stabilize first-launch OAuth CI readiness
## Summary

- Restores the first-launch desktop OAuth flow from #2247 while unblocking the failing CI surfaces.
- Keeps the runtime readiness gate before launching OAuth in Tauri so first-launch sign-in waits for core/auth readiness.
- Stabilizes legacy OAuth provider tests by mocking the new readiness preflight in Tauri-mode unit tests.
- Makes the E2E-only `window.__simulateDeepLink` helper fire-and-forget, matching production `onOpenUrl` behavior so WebDriver does not block on auth readiness.
- Keeps auth readiness focused on core-mode selection plus core RPC reachability, so first-login callbacks are not blocked by CoreStateProvider bootstrap.
- Adds regression coverage for the non-blocking deep-link helper path.

## Problem

- #2247 fixes first-launch OAuth readiness, but its PR branch is not writable from this maintainer account, and CI is currently red.
- Frontend unit and coverage jobs fail because legacy provider tests exercise Tauri mode without mocking the new readiness preflight.
- Linux E2E times out because `browser.execute(async () => await window.__simulateDeepLink(...))` waits on the full auth callback path, including readiness waits.
- The PR checklist also had the diff coverage item unchecked.

## Solution

- Mock `prepareOAuthLoginLaunch()` in the legacy Google/GitHub/Discord/Twitter OAuth tests, preserving their existing URL/openUrl assertions while isolating the readiness preflight.
- Change `__simulateDeepLink` to schedule `handleDeepLinkUrls()` without awaiting it, which matches the real desktop deep-link listener's fire-and-forget handler.
- Treat CoreStateProvider bootstrap as observational logging rather than a hard auth-readiness gate; core mode plus a successful core RPC ping are the required login preconditions.
- Dismiss the runtime picker before E2E auth deep-link simulation so raw mega-flow auth callbacks also commit a core mode before readiness checks.
- Treat the E2E default local core mode as an auth-readiness core mode when no explicit localStorage marker exists.
- Add a desktop deep-link listener unit test proving the E2E helper resolves immediately while the auth readiness path continues asynchronously.
- This PR supersedes #2247 because the original contributor fork rejected direct pushes from `YOMXXX` despite `maintainerCanModify=true`.

## Submission Checklist

> If a section does not apply to this change, mark the item as `N/A` with a one-line reason. Do not delete items.

- [x] Tests added or updated (happy path + at least one failure / edge case) per [Testing Strategy](../gitbooks/developing/testing-strategy.md#failure-path-requirement)
- [x] **Diff coverage ≥ 80%** — changed lines (Vitest + cargo-llvm-cov merged via `diff-cover`) meet the gate enforced by [`.github/workflows/coverage.yml`](../.github/workflows/coverage.yml). Run `pnpm test:coverage` and `pnpm test:rust` locally; PRs below 80% on changed lines will not merge.
- [x] Coverage matrix updated — N/A: behavior/test stabilization for existing OAuth sign-in flow; no feature row added/removed/renamed.
- [x] All affected feature IDs from the matrix are listed in the PR description under `## Related` — N/A: no matrix feature IDs changed.
- [x] No new external network dependencies introduced (mock backend used per [Testing Strategy](../gitbooks/developing/testing-strategy.md#mock-policy))
- [x] Manual smoke checklist updated if this touches release-cut surfaces ([`docs/RELEASE-MANUAL-SMOKE.md`](../docs/RELEASE-MANUAL-SMOKE.md)) — N/A: no release smoke procedure changed.
- [x] Linked issue closed via `Closes #NNN` in the `## Related` section

## Impact

- Desktop OAuth first-launch sign-in is more reliable because OAuth launch still waits for readiness before opening the external browser.
- E2E-only deep-link simulation no longer blocks WebDriver script execution on long auth readiness waits.
- No new runtime dependency, migration, storage, or security surface.
- Web OAuth behavior is unchanged.

## Related

- Closes: Closes #1689
- Follow-up PR(s)/TODOs: Supersedes #2247 because the original head branch is not writable from this fork.

---

## AI Authored PR Metadata (required for Codex/Linear PRs)

> Keep this section for AI-authored PRs. For human-only PRs, mark each field `N/A`.

### Linear Issue
- Key: N/A
- URL: N/A

### Commit & Branch
- Branch: `fix/2247-oauth-ci-readiness`
- Commit SHA: `3367058b145a37566dcd377198b2881a977ce3cd`

### Validation Run
- [x] `pnpm --filter openhuman-app format:check` (via pre-push hook)
- [x] `pnpm typecheck`
- [x] Focused tests:
  - `pnpm debug unit test/OAuthTwitter.test.tsx`
  - `pnpm debug unit src/utils/__tests__/desktopDeepLinkListener.test.ts`
  - `pnpm debug unit test/OAuthGitHub.test.tsx test/OAuthDiscord.test.tsx test/OAuthLoginSection.test.tsx`
  - `pnpm debug unit src/components/oauth/__tests__/OAuthProviderButton.test.tsx src/components/oauth/__tests__/oauthAuthReadiness.test.ts`
  - `pnpm debug unit src/components/oauth/__tests__/oauthAuthReadiness.test.ts src/utils/__tests__/desktopDeepLinkListener.test.ts src/components/oauth/__tests__/OAuthProviderButton.test.tsx test/OAuthTwitter.test.tsx`
  - `pnpm debug unit src/utils/__tests__/configPersistence.test.ts src/components/oauth/__tests__/oauthAuthReadiness.test.ts src/utils/__tests__/desktopDeepLinkListener.test.ts`
  - `pnpm debug unit`
  - `pnpm test:coverage`
  - `pnpm --dir app exec eslint src/utils/desktopDeepLinkListener.ts src/utils/__tests__/desktopDeepLinkListener.test.ts test/OAuthTwitter.test.tsx test/OAuthGitHub.test.tsx test/OAuthDiscord.test.tsx test/OAuthLoginSection.test.tsx --ext .ts,.tsx --max-warnings=0`
- [x] Rust fmt/check (if changed): `cargo fmt --manifest-path ../Cargo.toml --all --check`, `cargo fmt --manifest-path src-tauri/Cargo.toml --all --check`, and `GGML_NATIVE=OFF pnpm rust:check` via pre-push hook
- [x] Tauri fmt/check (if changed): `cargo fmt --manifest-path app/src-tauri/Cargo.toml --all --check` and `GGML_NATIVE=OFF pnpm rust:check` via pre-push hook

### Validation Blocked
- `command:` N/A
- `error:` N/A
- `impact:` N/A. Local macOS/Tahoe `rust:check` requires the documented `GGML_NATIVE=OFF` workaround for `whisper-rs-sys`/`-mcpu=native`; validation passed with that env var.

### Behavior Changes
- Intended behavior change: first-launch Tauri OAuth launch waits for runtime readiness, and E2E deep-link simulation no longer blocks the WebDriver script on asynchronous auth completion.
- User-visible effect: desktop first-launch sign-in should complete reliably instead of racing core/auth initialization.

### Parity Contract
- Legacy behavior preserved: web OAuth redirects and Tauri `openUrl()` URL construction remain unchanged.
- Guard/fallback/dispatch parity checks: existing provider tests still cover Google/GitHub/Discord/Twitter web and Tauri branches; deep-link listener test covers readiness failure and async helper behavior.

### Duplicate / Superseded PR Handling
- Duplicate PR(s): #2247
- Canonical PR: this PR
- Resolution (closed/superseded/updated): #2247 could not be updated directly because `git push git@github.com:CodeGhost21/openhuman.git HEAD:cursor/a02-1689-signin-failed-first-time` was rejected with `Permission denied to YOMXXX`.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * OAuth sign-in adds a readiness gate that checks local runtime availability, surfaces clear user-facing messages when sign-in can’t proceed, and runs a short preflight on supported desktop builds before launching provider flows.
  * Deep-link sign-ins coordinate lifecycle steps for more reliable handling across environments; E2E helpers now treat auth deep links to bypass boot checks when appropriate.
  * Config lookup supports an E2E default core-mode fallback.

* **Tests**
  * Expanded tests for OAuth readiness, deep-link lifecycle, launch preparation, desktop flows, and config fallbacks.

<!-- review_stack_entry_start -->

[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/tinyhumansai/openhuman/pull/2267?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Ghost Scripter <ghostscripter@zerolend.xyz>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: 李冠辰 <liguanchen@xiaomi.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
2026-05-20 15:05:26 -07:00

343 lines
12 KiB
TypeScript

/// <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,
mockPrepareOAuthLoginLaunch,
mockCheckBackendHealthy,
} = vi.hoisted(() => ({
mockGetBackendUrl: vi.fn(),
mockOpenUrl: vi.fn(),
mockIsTauri: vi.fn(),
mockPrepareOAuthLoginLaunch: vi.fn(),
// Default to a healthy backend so the pre-flight in OAuthProviderButton
// (added for issue #1985) doesn't short-circuit the OAuth flow these
// tests exercise.
mockCheckBackendHealthy: vi.fn().mockImplementation(async () => {
// Mirror the real checkBackendHealthy contract: never throw — convert a
// getBackendUrl() rejection into the resolve-failure result so tests that
// exercise that error path see the production banner instead of an
// unhandled rejection in the click handler.
try {
const backendUrl = await mockGetBackendUrl();
return { healthy: true, status: 200, latencyMs: 5, backendUrl };
} catch {
return { healthy: false, reason: 'resolve-failure', latencyMs: 0 };
}
}),
}));
vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl }));
vi.mock('../src/services/backendHealth', () => ({ checkBackendHealthy: mockCheckBackendHealthy }));
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 };
});
vi.mock('../src/utils/oauthAppVersionGate', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, prepareOAuthLoginLaunch: mockPrepareOAuthLoginLaunch };
});
beforeEach(() => {
mockPrepareOAuthLoginLaunch.mockReset();
mockPrepareOAuthLoginLaunch.mockResolvedValue(undefined);
});
// ---------------------------------------------------------------------------
// 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'
);
});
});