mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
## 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 --> [](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>
343 lines
12 KiB
TypeScript
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'
|
|
);
|
|
});
|
|
});
|