mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
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 --> [](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>
This commit is contained in:
co-authored by
Ghost Scripter
Cursor
Steven Enamakel
parent
92682d06e5
commit
d3a4ea3688
@@ -1 +1 @@
|
||||
c90c8a330056286e7c0d05439ae3d4527fa4fafe
|
||||
c90c8a330056286e7c0d05439ae3d4527fa4fafe
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import debug from 'debug';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { checkBackendHealthy } from '../../services/backendHealth';
|
||||
import { getDeepLinkAuthState } from '../../store/deepLinkAuthState';
|
||||
import {
|
||||
beginDeepLinkAuthProcessing,
|
||||
completeDeepLinkAuthProcessing,
|
||||
getDeepLinkAuthState,
|
||||
} from '../../store/deepLinkAuthState';
|
||||
import type { OAuthProviderConfig } from '../../types/oauth';
|
||||
import { IS_DEV } from '../../utils/config';
|
||||
import { prepareOAuthLoginLaunch } from '../../utils/oauthAppVersionGate';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
import { isTauri } from '../../utils/tauriCommands';
|
||||
|
||||
@@ -29,7 +35,36 @@ const OAUTH_PREFLIGHT_TIMEOUT_MS = 4_000;
|
||||
const BACKEND_UNAVAILABLE_MESSAGE =
|
||||
'OpenHuman cloud sign-in is temporarily unavailable. Please try again in a few minutes.';
|
||||
|
||||
const getOAuthStartupFailureMessage = (provider: OAuthProviderConfig): string => {
|
||||
const log = debug('oauth:button');
|
||||
const warnLog = debug('oauth:button:warn');
|
||||
const errorLog = debug('oauth:button:error');
|
||||
|
||||
const SAFE_READINESS_STARTUP_MESSAGE_PREFIXES = [
|
||||
'Finish choosing how OpenHuman runs',
|
||||
'OpenHuman could not reach its local runtime',
|
||||
];
|
||||
|
||||
const getSafeReadinessStartupMessage = (error: unknown): string | null => {
|
||||
if (!(error instanceof Error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = error.message.trim();
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SAFE_READINESS_STARTUP_MESSAGE_PREFIXES.some(prefix => message.startsWith(prefix))
|
||||
? message
|
||||
: null;
|
||||
};
|
||||
|
||||
const getOAuthStartupFailureMessage = (provider: OAuthProviderConfig, error?: unknown): string => {
|
||||
const readinessMessage = getSafeReadinessStartupMessage(error);
|
||||
if (readinessMessage) {
|
||||
return readinessMessage;
|
||||
}
|
||||
|
||||
if (provider.id === 'twitter') {
|
||||
return 'Twitter/X sign-in could not start. Check that the Twitter OAuth app callback URL, client ID/secret, and requested scopes match the OpenHuman backend, then try again.';
|
||||
}
|
||||
@@ -84,14 +119,14 @@ const OAuthProviderButton = ({
|
||||
void checkBackendHealthy()
|
||||
.then(result => {
|
||||
if (!result.healthy) {
|
||||
console.warn(`[oauth-button][${provider.id}] ${label} probe → backend unhealthy`, {
|
||||
warnLog('[%s] %s probe -> backend unhealthy %o', provider.id, label, {
|
||||
reason: result.reason,
|
||||
latencyMs: result.latencyMs,
|
||||
status: 'status' in result ? result.status : undefined,
|
||||
});
|
||||
setStartupError(BACKEND_UNAVAILABLE_MESSAGE);
|
||||
} else {
|
||||
console.debug(`[oauth-button][${provider.id}] ${label} probe → backend healthy`, {
|
||||
log('[%s] %s probe -> backend healthy %o', provider.id, label, {
|
||||
status: result.status,
|
||||
latencyMs: result.latencyMs,
|
||||
});
|
||||
@@ -100,7 +135,7 @@ const OAuthProviderButton = ({
|
||||
.catch(err => {
|
||||
// checkBackendHealthy already swallows network/abort errors and
|
||||
// turns them into a result; reaching this branch is unexpected.
|
||||
console.debug(`[oauth-button][${provider.id}] ${label} probe threw`, err);
|
||||
log('[%s] %s probe threw %o', provider.id, label, err);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -109,7 +144,7 @@ const OAuthProviderButton = ({
|
||||
// and resetting first would briefly re-enable the button mid-redirect.
|
||||
const skipDuringDeepLink = (label: string) => {
|
||||
if (getDeepLinkAuthState().isProcessing) {
|
||||
console.debug(`[oauth-button][${provider.id}] ${label} — skip (deep-link processing)`);
|
||||
log('[%s] %s - skip (deep-link processing)', provider.id, label);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -119,7 +154,7 @@ const OAuthProviderButton = ({
|
||||
// browser. On most platforms this lifts the loading state immediately.
|
||||
const handleFocus = () => {
|
||||
if (skipDuringDeepLink('focus')) return;
|
||||
console.debug(`[oauth-button][${provider.id}] window focus → reset isLoading`);
|
||||
log('[%s] window focus -> reset isLoading', provider.id);
|
||||
reset();
|
||||
probeBackendOnReturn('focus');
|
||||
};
|
||||
@@ -130,13 +165,13 @@ const OAuthProviderButton = ({
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (skipDuringDeepLink('visibilitychange')) return;
|
||||
console.debug(`[oauth-button][${provider.id}] visibilitychange visible → reset isLoading`);
|
||||
log('[%s] visibilitychange visible -> reset isLoading', provider.id);
|
||||
reset();
|
||||
probeBackendOnReturn('visibilitychange');
|
||||
};
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
console.debug(`[oauth-button][${provider.id}] timeout → reset isLoading`);
|
||||
log('[%s] timeout -> reset isLoading', provider.id);
|
||||
reset();
|
||||
// 90s with no deep-link is a strong "something went wrong" signal even
|
||||
// if the user never refocused the app. Probe so we can attribute it.
|
||||
@@ -161,29 +196,35 @@ const OAuthProviderButton = ({
|
||||
|
||||
if (externalDisabled || isLoading) return;
|
||||
|
||||
console.debug(`[oauth-button][${provider.id}] starting OAuth login (isTauri=${isTauri()})`);
|
||||
log('[%s] starting OAuth login (isTauri=%s)', provider.id, isTauri());
|
||||
|
||||
setStartupError(null);
|
||||
setIsLoading(true);
|
||||
beginDeepLinkAuthProcessing();
|
||||
browserOpenedRef.current = false;
|
||||
|
||||
// Fail-fast pre-flight: hitting `api.tinyhumans.ai/health` before opening
|
||||
// the browser lets us catch Cloudflare 504s / DNS outages immediately
|
||||
// (issue #1985) instead of sending the user into a system browser that
|
||||
// lands on a gateway-error page with no path back into the app.
|
||||
const preflight = await checkBackendHealthy({ timeoutMs: OAUTH_PREFLIGHT_TIMEOUT_MS });
|
||||
if (!preflight.healthy) {
|
||||
console.warn(`[oauth-button][${provider.id}] preflight → backend unhealthy`, {
|
||||
reason: preflight.reason,
|
||||
latencyMs: preflight.latencyMs,
|
||||
status: 'status' in preflight ? preflight.status : undefined,
|
||||
});
|
||||
setStartupError(BACKEND_UNAVAILABLE_MESSAGE);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fail-fast pre-flight: hitting `api.tinyhumans.ai/health` before opening
|
||||
// the browser lets us catch Cloudflare 504s / DNS outages immediately
|
||||
// (issue #1985) instead of sending the user into a system browser that
|
||||
// lands on a gateway-error page with no path back into the app.
|
||||
const preflight = await checkBackendHealthy({ timeoutMs: OAUTH_PREFLIGHT_TIMEOUT_MS });
|
||||
if (!preflight.healthy) {
|
||||
warnLog('[%s] preflight -> backend unhealthy %o', provider.id, {
|
||||
reason: preflight.reason,
|
||||
latencyMs: preflight.latencyMs,
|
||||
status: 'status' in preflight ? preflight.status : undefined,
|
||||
});
|
||||
completeDeepLinkAuthProcessing();
|
||||
setStartupError(BACKEND_UNAVAILABLE_MESSAGE);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTauri()) {
|
||||
await prepareOAuthLoginLaunch();
|
||||
}
|
||||
|
||||
// Reuse the URL the preflight already resolved — `getBackendUrl()` may
|
||||
// hit a Tauri IPC round-trip and the result hasn't changed within a
|
||||
// single click handler.
|
||||
@@ -206,9 +247,11 @@ const OAuthProviderButton = ({
|
||||
window.location.href = loginUrl;
|
||||
}
|
||||
browserOpenedRef.current = true;
|
||||
completeDeepLinkAuthProcessing();
|
||||
} catch (error) {
|
||||
const message = getOAuthStartupFailureMessage(provider);
|
||||
console.error(`[oauth-button][${provider.id}] OAuth startup failed`, {
|
||||
completeDeepLinkAuthProcessing();
|
||||
const message = getOAuthStartupFailureMessage(provider, error);
|
||||
errorLog('[%s] OAuth startup failed %o', provider.id, {
|
||||
provider: provider.id,
|
||||
providerName: provider.name,
|
||||
reason: summarizeOAuthStartupError(error),
|
||||
|
||||
@@ -2,7 +2,12 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { checkBackendHealthy } from '../../../services/backendHealth';
|
||||
import { getDeepLinkAuthState } from '../../../store/deepLinkAuthState';
|
||||
import {
|
||||
beginDeepLinkAuthProcessing,
|
||||
completeDeepLinkAuthProcessing,
|
||||
getDeepLinkAuthState,
|
||||
} from '../../../store/deepLinkAuthState';
|
||||
import { prepareOAuthLoginLaunch } from '../../../utils/oauthAppVersionGate';
|
||||
import { openUrl } from '../../../utils/openUrl';
|
||||
import { isTauri } from '../../../utils/tauriCommands';
|
||||
import OAuthProviderButton from '../OAuthProviderButton';
|
||||
@@ -11,9 +16,17 @@ vi.mock('../../../services/backendHealth', () => ({ checkBackendHealthy: vi.fn()
|
||||
|
||||
vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() }));
|
||||
|
||||
vi.mock('../../../utils/oauthAppVersionGate', () => ({
|
||||
prepareOAuthLoginLaunch: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/tauriCommands', () => ({ isTauri: vi.fn() }));
|
||||
|
||||
vi.mock('../../../store/deepLinkAuthState', () => ({ getDeepLinkAuthState: vi.fn() }));
|
||||
vi.mock('../../../store/deepLinkAuthState', () => ({
|
||||
beginDeepLinkAuthProcessing: vi.fn(),
|
||||
completeDeepLinkAuthProcessing: vi.fn(),
|
||||
getDeepLinkAuthState: vi.fn(),
|
||||
}));
|
||||
|
||||
const stubProvider = {
|
||||
id: 'google' as const,
|
||||
@@ -67,6 +80,10 @@ describe('OAuthProviderButton', () => {
|
||||
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(beginDeepLinkAuthProcessing).toHaveBeenCalledTimes(1);
|
||||
expect(completeDeepLinkAuthProcessing).toHaveBeenCalledTimes(1);
|
||||
expect(prepareOAuthLoginLaunch).toHaveBeenCalledTimes(1);
|
||||
expect(checkBackendHealthy).toHaveBeenCalledTimes(1);
|
||||
expect(openUrl).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^https:\/\/backend\.test\/auth\/google\/login(\?.*)?$/)
|
||||
);
|
||||
@@ -212,15 +229,26 @@ describe('OAuthProviderButton', () => {
|
||||
'Twitter/X sign-in could not start. Check that the Twitter OAuth app callback URL, client ID/secret, and requested scopes match the OpenHuman backend, then try again.'
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Twitter' })).toBeEnabled();
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[oauth-button][twitter] OAuth startup failed',
|
||||
expect.objectContaining({
|
||||
provider: 'twitter',
|
||||
providerName: 'Twitter',
|
||||
guidance: expect.stringContaining('Twitter/X sign-in could not start'),
|
||||
reason: expect.not.stringContaining('token=secret'),
|
||||
})
|
||||
);
|
||||
expect(completeDeepLinkAuthProcessing).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('surfaces safe readiness messages when the pre-launch readiness check fails', async () => {
|
||||
const readinessMessage =
|
||||
'OpenHuman could not reach its local runtime. Quit and reopen the app, then try signing in again.';
|
||||
vi.mocked(prepareOAuthLoginLaunch).mockRejectedValueOnce(new Error(readinessMessage));
|
||||
|
||||
render(<OAuthProviderButton provider={stubProvider} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
|
||||
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(readinessMessage);
|
||||
expect(screen.getByRole('button', { name: 'Google' })).toBeEnabled();
|
||||
expect(completeDeepLinkAuthProcessing).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// --- Pre-flight + post-failure backend health probe (issue #1985) ---
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getCoreStateSnapshot } from '../../../lib/coreState/store';
|
||||
import { bootCheckTransport } from '../../../services/bootCheckService';
|
||||
import { testCoreRpcConnection } from '../../../services/coreRpcClient';
|
||||
import { isTauri } from '../../../services/webviewAccountService';
|
||||
import { getStoredCoreMode } from '../../../utils/configPersistence';
|
||||
import {
|
||||
oauthAuthReadinessUserMessage,
|
||||
prepareOAuthLoginLaunch,
|
||||
waitForOAuthAuthReadiness,
|
||||
} from '../oauthAuthReadiness';
|
||||
|
||||
vi.mock('../../../lib/coreState/store', () => ({ getCoreStateSnapshot: vi.fn() }));
|
||||
|
||||
vi.mock('../../../services/coreRpcClient', () => ({
|
||||
getCoreRpcUrl: vi.fn().mockResolvedValue('http://127.0.0.1:7788/rpc'),
|
||||
testCoreRpcConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/bootCheckService', () => ({
|
||||
bootCheckTransport: { invokeCmd: vi.fn().mockResolvedValue(undefined), callRpc: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/configPersistence', () => ({ getStoredCoreMode: vi.fn() }));
|
||||
|
||||
vi.mock('../../../services/webviewAccountService', () => ({
|
||||
isTauri: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
describe('oauthAuthReadiness', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getStoredCoreMode).mockReturnValue('local');
|
||||
vi.mocked(getCoreStateSnapshot).mockReturnValue({
|
||||
isBootstrapping: false,
|
||||
isReady: true,
|
||||
snapshot: {
|
||||
sessionToken: null,
|
||||
auth: { isAuthenticated: false, userId: null, user: null, profileId: null },
|
||||
currentUser: null,
|
||||
onboardingCompleted: false,
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, onboardingTasks: null },
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
teams: [],
|
||||
teamMembersById: {},
|
||||
teamInvitesById: {},
|
||||
});
|
||||
vi.mocked(testCoreRpcConnection).mockResolvedValue({ ok: true } as Response);
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('returns core_mode_unset when BootCheckGate has not committed a mode', async () => {
|
||||
vi.mocked(getStoredCoreMode).mockReturnValue(null);
|
||||
|
||||
const result = await waitForOAuthAuthReadiness(500);
|
||||
|
||||
expect(result).toEqual({ ready: false, reason: 'core_mode_unset' });
|
||||
expect(oauthAuthReadinessUserMessage('core_mode_unset')).toMatch(/setup screen/i);
|
||||
});
|
||||
|
||||
it('returns ready when core mode and ping are satisfied', async () => {
|
||||
const result = await waitForOAuthAuthReadiness(2_000);
|
||||
|
||||
expect(result).toEqual({ ready: true });
|
||||
expect(bootCheckTransport.invokeCmd).toHaveBeenCalledWith('start_core_process', {});
|
||||
expect(testCoreRpcConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns core_unreachable when ping never succeeds', async () => {
|
||||
vi.mocked(testCoreRpcConnection).mockResolvedValue({ ok: false } as Response);
|
||||
|
||||
const result = await waitForOAuthAuthReadiness(600);
|
||||
|
||||
expect(result).toEqual({ ready: false, reason: 'core_unreachable' });
|
||||
});
|
||||
|
||||
it('does not block first-login callbacks on CoreStateProvider bootstrap once ping succeeds', async () => {
|
||||
vi.mocked(getCoreStateSnapshot).mockReturnValue({
|
||||
isBootstrapping: true,
|
||||
isReady: false,
|
||||
snapshot: {
|
||||
sessionToken: null,
|
||||
auth: { isAuthenticated: false, userId: null, user: null, profileId: null },
|
||||
currentUser: null,
|
||||
onboardingCompleted: false,
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, onboardingTasks: null },
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
teams: [],
|
||||
teamMembersById: {},
|
||||
teamInvitesById: {},
|
||||
});
|
||||
|
||||
const result = await waitForOAuthAuthReadiness(3_000);
|
||||
|
||||
expect(result).toEqual({ ready: true });
|
||||
});
|
||||
|
||||
it('does not start the local core on web builds', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false);
|
||||
|
||||
await waitForOAuthAuthReadiness(1_000);
|
||||
|
||||
expect(bootCheckTransport.invokeCmd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the local core only once during pre-launch readiness', async () => {
|
||||
await prepareOAuthLoginLaunch();
|
||||
|
||||
expect(bootCheckTransport.invokeCmd).toHaveBeenCalledTimes(1);
|
||||
expect(bootCheckTransport.invokeCmd).toHaveBeenCalledWith('start_core_process', {});
|
||||
});
|
||||
|
||||
it('rejects with the readiness message when pre-launch core readiness fails', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(testCoreRpcConnection).mockResolvedValue({ ok: false } as Response);
|
||||
|
||||
try {
|
||||
const launch = expect(prepareOAuthLoginLaunch()).rejects.toThrow(
|
||||
oauthAuthReadinessUserMessage('core_unreachable')
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
|
||||
await launch;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import debug from 'debug';
|
||||
|
||||
import { getCoreStateSnapshot } from '../../lib/coreState/store';
|
||||
import { bootCheckTransport } from '../../services/bootCheckService';
|
||||
import { getCoreRpcUrl, testCoreRpcConnection } from '../../services/coreRpcClient';
|
||||
import { isTauri } from '../../services/webviewAccountService';
|
||||
import { getStoredCoreMode } from '../../utils/configPersistence';
|
||||
|
||||
const logPrefix = '[oauth-auth-readiness]';
|
||||
const log = debug('oauth:auth-readiness');
|
||||
const warnLog = debug('oauth:auth-readiness:warn');
|
||||
|
||||
const DEFAULT_MAX_WAIT_MS = 30_000;
|
||||
const POLL_MS = 200;
|
||||
|
||||
export type OAuthAuthReadinessFailure = 'core_mode_unset' | 'core_unreachable';
|
||||
|
||||
export type OAuthAuthReadinessResult =
|
||||
| { ready: true }
|
||||
| { ready: false; reason: OAuthAuthReadinessFailure };
|
||||
|
||||
const delay = (ms: number): Promise<void> =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
async function pingCoreRpc(): Promise<boolean> {
|
||||
try {
|
||||
const rpcUrl = await getCoreRpcUrl();
|
||||
const response = await testCoreRpcConnection(rpcUrl);
|
||||
return response.ok;
|
||||
} catch (err) {
|
||||
log(`${logPrefix} core.ping probe failed`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLocalCoreProcessStarted(): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
if (getStoredCoreMode() !== 'local') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await bootCheckTransport.invokeCmd('start_core_process', {});
|
||||
log(`${logPrefix} start_core_process invoked`);
|
||||
} catch (err) {
|
||||
log(`${logPrefix} start_core_process skipped or failed`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block OAuth sign-in until the BootCheckGate has committed a core mode,
|
||||
* and the embedded core answers `core.ping`.
|
||||
*
|
||||
* First-launch sign-in often failed with a generic "Sign-in failed" because
|
||||
* the deep-link handler only waited ~1.5s while `isBootstrapping` stayed true
|
||||
* behind the runtime picker, then called RPC against a core that was not up yet.
|
||||
* The login callback itself can proceed before CoreStateProvider completes its
|
||||
* first snapshot refresh; requiring that UI bootstrap here deadlocks some E2E
|
||||
* and first-login paths where the callback is what creates the session.
|
||||
*/
|
||||
export async function waitForOAuthAuthReadiness(
|
||||
maxWaitMs = DEFAULT_MAX_WAIT_MS
|
||||
): Promise<OAuthAuthReadinessResult> {
|
||||
const deadline = Date.now() + maxWaitMs;
|
||||
let sawCoreMode = false;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const mode = getStoredCoreMode();
|
||||
if (mode) {
|
||||
sawCoreMode = true;
|
||||
break;
|
||||
}
|
||||
await delay(POLL_MS);
|
||||
}
|
||||
|
||||
if (!sawCoreMode) {
|
||||
warnLog(`${logPrefix} timed out waiting for core mode selection`);
|
||||
return { ready: false, reason: 'core_mode_unset' };
|
||||
}
|
||||
|
||||
await ensureLocalCoreProcessStarted();
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const coreState = getCoreStateSnapshot();
|
||||
if (await pingCoreRpc()) {
|
||||
log(`${logPrefix} ready`, {
|
||||
authBootstrapComplete: !coreState.isBootstrapping,
|
||||
hasSessionToken: Boolean(coreState.snapshot.sessionToken),
|
||||
coreMode: getStoredCoreMode(),
|
||||
});
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
await delay(POLL_MS);
|
||||
}
|
||||
|
||||
if (!(await pingCoreRpc())) {
|
||||
warnLog(`${logPrefix} core RPC unreachable after ${maxWaitMs}ms`);
|
||||
return { ready: false, reason: 'core_unreachable' };
|
||||
}
|
||||
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
export function oauthAuthReadinessUserMessage(reason: OAuthAuthReadinessFailure): string {
|
||||
switch (reason) {
|
||||
case 'core_mode_unset':
|
||||
return (
|
||||
'Finish choosing how OpenHuman runs (tap Continue on the setup screen), ' +
|
||||
'then try signing in again.'
|
||||
);
|
||||
case 'core_unreachable':
|
||||
return (
|
||||
'OpenHuman could not reach its local runtime. Quit and reopen the app, ' +
|
||||
'then try signing in again.'
|
||||
);
|
||||
default:
|
||||
return 'Sign-in is still starting up. Wait a few seconds and try again.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight preflight before opening the system browser for OAuth.
|
||||
* Blocks browser launch when the local auth runtime is not ready yet.
|
||||
* `waitForOAuthAuthReadiness()` starts the local core when needed.
|
||||
*/
|
||||
export async function prepareOAuthLoginLaunch(): Promise<void> {
|
||||
const quick = await waitForOAuthAuthReadiness(8_000);
|
||||
if (!quick.ready) {
|
||||
warnLog(`${logPrefix} pre-launch readiness`, quick);
|
||||
throw new Error(oauthAuthReadinessUserMessage(quick.reason));
|
||||
}
|
||||
}
|
||||
@@ -417,5 +417,23 @@ describe('configPersistence', () => {
|
||||
clearStoredCoreMode();
|
||||
expect(getStoredCoreMode()).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the E2E default local mode when no marker has been written', async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock('../config', () => ({
|
||||
CORE_RPC_URL: 'http://127.0.0.1:7788/rpc',
|
||||
E2E_DEFAULT_CORE_MODE: 'local',
|
||||
}));
|
||||
|
||||
try {
|
||||
localStorage.removeItem(MODE_STORAGE_KEY);
|
||||
const mod = await import('../configPersistence');
|
||||
|
||||
expect(mod.getStoredCoreMode()).toBe('local');
|
||||
} finally {
|
||||
vi.doUnmock('../config');
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,19 @@ vi.mock('../../lib/coreState/store', () => ({
|
||||
patchCoreStateSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
const waitForOAuthAuthReadiness = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue({ ready: true as const })
|
||||
);
|
||||
|
||||
vi.mock('../oauthAppVersionGate', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../oauthAppVersionGate')>();
|
||||
return {
|
||||
...actual,
|
||||
waitForOAuthAuthReadiness,
|
||||
oauthAuthReadinessUserMessage: (reason: string) => `blocked:${reason}`,
|
||||
};
|
||||
});
|
||||
|
||||
const windowControls = vi.hoisted(() => ({
|
||||
show: vi.fn().mockResolvedValue(undefined),
|
||||
unminimize: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -42,6 +55,10 @@ describe('desktopDeepLinkListener', () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
vi.mocked(getCurrent).mockResolvedValue(null);
|
||||
vi.mocked(onOpenUrl).mockResolvedValue(() => {});
|
||||
waitForOAuthAuthReadiness.mockReset();
|
||||
waitForOAuthAuthReadiness.mockResolvedValue({ ready: true });
|
||||
vi.mocked(storeSession).mockReset();
|
||||
vi.mocked(storeSession).mockResolvedValue(undefined);
|
||||
windowControls.show.mockClear();
|
||||
windowControls.unminimize.mockClear();
|
||||
windowControls.setFocus.mockClear();
|
||||
@@ -104,6 +121,19 @@ describe('desktopDeepLinkListener', () => {
|
||||
expect(state.isProcessing).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces readiness failures instead of a generic sign-in error', async () => {
|
||||
waitForOAuthAuthReadiness.mockResolvedValueOnce({ ready: false, reason: 'core_mode_unset' });
|
||||
|
||||
vi.mocked(getCurrent).mockResolvedValue(['openhuman://auth?token=abc&key=auth']);
|
||||
|
||||
await setupDesktopDeepLinkListener();
|
||||
|
||||
const state = getDeepLinkAuthState();
|
||||
expect(state.errorMessage).toBe('blocked:core_mode_unset');
|
||||
expect(state.isProcessing).toBe(false);
|
||||
expect(storeSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps requiresAppDataReset false for non-decryption auth failures', async () => {
|
||||
vi.mocked(storeSession).mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
@@ -117,6 +147,34 @@ describe('desktopDeepLinkListener', () => {
|
||||
expect(state.errorMessage).toBe('Sign-in failed. Please try again.');
|
||||
});
|
||||
|
||||
it('does not make the E2E deep-link helper wait for auth readiness', async () => {
|
||||
let resolveReadiness!: (_value: { ready: true }) => void;
|
||||
waitForOAuthAuthReadiness.mockReturnValueOnce(
|
||||
new Promise<{ ready: true }>(resolve => {
|
||||
resolveReadiness = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
await setupDesktopDeepLinkListener();
|
||||
|
||||
const simulateDeepLink = (
|
||||
window as Window & { __simulateDeepLink?: (url: string) => Promise<void> }
|
||||
).__simulateDeepLink;
|
||||
|
||||
expect(simulateDeepLink).toBeTypeOf('function');
|
||||
await expect(simulateDeepLink!('openhuman://auth?token=abc&key=auth')).resolves.toBeUndefined();
|
||||
expect(storeSession).not.toHaveBeenCalled();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(waitForOAuthAuthReadiness).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveReadiness({ ready: true });
|
||||
await waitForAuthSettled();
|
||||
|
||||
expect(storeSession).toHaveBeenCalledWith('abc', {});
|
||||
expect(getDeepLinkAuthState().isProcessing).toBe(false);
|
||||
});
|
||||
|
||||
it('sanitizes provider and error code values from OAuth error deep links', async () => {
|
||||
const oauthErrorEvents: CustomEvent[] = [];
|
||||
window.addEventListener('oauth:error', event => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Handles storing/retrieving user preferences like RPC URL using
|
||||
* localStorage (web) or Tauri store (desktop).
|
||||
*/
|
||||
import { CORE_RPC_URL } from './config';
|
||||
import { CORE_RPC_URL, E2E_DEFAULT_CORE_MODE } from './config';
|
||||
import { isTauri } from './tauriCommands';
|
||||
|
||||
// Storage key for RPC URL preference
|
||||
@@ -239,10 +239,15 @@ export function clearStoredCoreToken(): void {
|
||||
export function getStoredCoreMode(): 'local' | 'cloud' | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(CORE_MODE_STORAGE_KEY)?.trim();
|
||||
if (stored === 'local' || stored === 'cloud') return stored;
|
||||
if (stored) {
|
||||
if (stored === 'local' || stored === 'cloud') return stored;
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
console.warn('[configPersistence] Unable to access localStorage');
|
||||
}
|
||||
|
||||
if (E2E_DEFAULT_CORE_MODE === 'local') return 'local';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as Sentry from '@sentry/react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
|
||||
import { getCoreStateSnapshot, patchCoreStateSnapshot } from '../lib/coreState/store';
|
||||
import { patchCoreStateSnapshot } from '../lib/coreState/store';
|
||||
import { consumeLoginToken } from '../services/api/authApi';
|
||||
import {
|
||||
beginDeepLinkAuthProcessing,
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
failDeepLinkAuthProcessing,
|
||||
} from '../store/deepLinkAuthState';
|
||||
import { BILLING_DASHBOARD_URL } from './links';
|
||||
import { evaluateOAuthAppVersionGate } from './oauthAppVersionGate';
|
||||
import {
|
||||
evaluateOAuthAppVersionGate,
|
||||
oauthAuthReadinessUserMessage,
|
||||
waitForOAuthAuthReadiness,
|
||||
} from './oauthAppVersionGate';
|
||||
import { openUrl } from './openUrl';
|
||||
import { storeSession } from './tauriCommands';
|
||||
import { isTauri as coreIsTauri } from './tauriCommands/common';
|
||||
@@ -71,22 +75,6 @@ const focusMainWindow = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const waitForAuthReadiness = async (maxAttempts = 10, delayMs = 150) => {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const coreState = getCoreStateSnapshot();
|
||||
if (!coreState.isBootstrapping || coreState.snapshot.sessionToken) {
|
||||
console.log('[DeepLink][auth] app ready', {
|
||||
attempt,
|
||||
hasToken: Boolean(coreState.snapshot.sessionToken),
|
||||
authBootstrapComplete: !coreState.isBootstrapping,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
console.warn('[DeepLink][auth] readiness timeout; continuing');
|
||||
};
|
||||
|
||||
const applySessionToken = async (sessionToken: string): Promise<void> => {
|
||||
await storeSession(sessionToken, {});
|
||||
patchCoreStateSnapshot({ snapshot: { sessionToken } });
|
||||
@@ -109,7 +97,13 @@ const handleAuthDeepLink = async (parsed: URL) => {
|
||||
|
||||
try {
|
||||
await focusMainWindow();
|
||||
await waitForAuthReadiness();
|
||||
|
||||
const readiness = await waitForOAuthAuthReadiness();
|
||||
if (!readiness.ready) {
|
||||
console.warn('[DeepLink][auth] OAuth readiness gate blocked login', readiness);
|
||||
failDeepLinkAuthProcessing(oauthAuthReadinessUserMessage(readiness.reason));
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionToken = key === 'auth' ? token : await consumeLoginToken(token);
|
||||
await applySessionToken(sessionToken);
|
||||
@@ -334,7 +328,9 @@ export const setupDesktopDeepLinkListener = async () => {
|
||||
// window.__simulateDeepLink('openhuman://oauth/success?integrationId=69cafd0b103bd070232d3223&provider=notion')
|
||||
// window.__simulateDeepLink('openhuman://oauth/success?integrationId=69cafd0b103bd070232d3223&skillId=discord')
|
||||
const win = window as Window & { __simulateDeepLink?: (url: string) => Promise<void> };
|
||||
win.__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]);
|
||||
win.__simulateDeepLink = async (url: string) => {
|
||||
void handleDeepLinkUrls([url]);
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[DeepLink] Setup failed:', err);
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
|
||||
import {
|
||||
type OAuthAuthReadinessFailure,
|
||||
type OAuthAuthReadinessResult,
|
||||
oauthAuthReadinessUserMessage,
|
||||
prepareOAuthLoginLaunch,
|
||||
waitForOAuthAuthReadiness,
|
||||
} from '../components/oauth/oauthAuthReadiness';
|
||||
import { LATEST_APP_DOWNLOAD_URL, MINIMUM_SUPPORTED_APP_VERSION } from './config';
|
||||
import { isVersionAtLeast, parseSemverParts } from './semver';
|
||||
import { isTauri } from './tauriCommands/common';
|
||||
|
||||
export {
|
||||
oauthAuthReadinessUserMessage,
|
||||
prepareOAuthLoginLaunch,
|
||||
waitForOAuthAuthReadiness,
|
||||
type OAuthAuthReadinessFailure,
|
||||
type OAuthAuthReadinessResult,
|
||||
};
|
||||
|
||||
export type OAuthAppVersionGateResult =
|
||||
| { ok: true }
|
||||
| { ok: false; current: string; minimum: string; downloadUrl: string };
|
||||
|
||||
@@ -21,10 +21,17 @@ import { renderWithProviders } from '../src/test/test-utils';
|
||||
// Module mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({
|
||||
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.
|
||||
@@ -49,6 +56,15 @@ 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
|
||||
|
||||
@@ -21,10 +21,17 @@ import { renderWithProviders } from '../src/test/test-utils';
|
||||
// Module mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({
|
||||
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.
|
||||
@@ -49,6 +56,15 @@ 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
|
||||
|
||||
@@ -24,10 +24,17 @@ import { renderWithProviders } from '../src/test/test-utils';
|
||||
// (which are hoisted to the top of the file by Vitest).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({
|
||||
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.
|
||||
@@ -54,6 +61,15 @@ 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);
|
||||
});
|
||||
|
||||
// IS_DEV is set to `true` by the global setup mock of '../utils/config'
|
||||
|
||||
|
||||
@@ -21,10 +21,17 @@ import { renderWithProviders } from '../src/test/test-utils';
|
||||
// Module mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({
|
||||
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.
|
||||
@@ -49,6 +56,15 @@ 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
|
||||
|
||||
@@ -81,6 +81,15 @@ function supportsWebDriverScriptExecute(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAuthDeepLink(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'openhuman:' && parsed.hostname === 'auth';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When WebDriver can execute JS in the app WebView, dispatch the same URLs as the
|
||||
* deep-link plugin via `window.__simulateDeepLink` (see desktopDeepLinkListener).
|
||||
@@ -200,6 +209,12 @@ export async function triggerDeepLink(url: string): Promise<void> {
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
if (isAuthDeepLink(url)) {
|
||||
await dismissBootCheckGateIfVisibleInline().catch(err => {
|
||||
deepLinkDebug('pre-auth deep-link BootCheckGate dismiss failed (continuing):', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof browser !== 'undefined') {
|
||||
// Strategy 1: WebView simulate — the only reliable path on the unified
|
||||
// CEF/Appium-Chromium harness. If it succeeds we're done; if it throws
|
||||
|
||||
Reference in New Issue
Block a user