fix(oauth): surface backend outage instead of opening browser to 504 (#1985) (#2147)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
CodeGhost21
2026-05-19 20:32:22 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 1ae31ba14c
commit 9c14c13752
8 changed files with 510 additions and 27 deletions
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { getBackendUrl } from '../../services/backendUrl';
import { checkBackendHealthy } from '../../services/backendHealth';
import { getDeepLinkAuthState } from '../../store/deepLinkAuthState';
import type { OAuthProviderConfig } from '../../types/oauth';
import { IS_DEV } from '../../utils/config';
@@ -20,6 +20,15 @@ interface OAuthProviderButtonProps {
// redirect fails so the `openhuman://` deep link never fires.
const OAUTH_LOADING_TIMEOUT_MS = 90_000;
// Pre-flight budget for `/health` before we open the system browser. Kept
// short so a healthy backend adds barely any perceptible click→browser delay,
// while an outage (Cloudflare 504, DNS, offline) is caught fast enough that
// the user never sees the broken provider page.
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 => {
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.';
@@ -51,12 +60,50 @@ const OAuthProviderButton = ({
const { t } = useT();
const [isLoading, setIsLoading] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
// Tracks whether the user actually got dispatched to the system browser on
// this attempt. Lets the focus/visibility handlers distinguish "user came
// back from the browser" (probe for backend health) from "click never even
// reached openUrl" (no probe needed — we already set a startup error).
const browserOpenedRef = useRef(false);
useEffect(() => {
if (!isLoading) return;
const reset = () => setIsLoading(false);
// Confirm backend health when the user returns without a deep-link
// callback. Healthy → silent reset (user just cancelled in the browser).
// Unhealthy → surface a clear banner so the user understands why the
// browser landed on an error page (issue #1985).
const probeBackendOnReturn = (label: string) => {
if (!browserOpenedRef.current) return;
// Consume the flag so the second of a focus/visibilitychange pair (macOS
// can fire both back-to-back when returning from the system browser)
// becomes a no-op instead of triggering a redundant concurrent probe.
browserOpenedRef.current = false;
void checkBackendHealthy()
.then(result => {
if (!result.healthy) {
console.warn(`[oauth-button][${provider.id}] ${label} probe → backend unhealthy`, {
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`, {
status: result.status,
latencyMs: result.latencyMs,
});
}
})
.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);
});
};
// Skip reset when a deep-link auth round-trip is already in flight — the
// OAuth callback flips `isProcessing=true` AFTER the OS focus event fires,
// and resetting first would briefly re-enable the button mid-redirect.
@@ -74,6 +121,7 @@ const OAuthProviderButton = ({
if (skipDuringDeepLink('focus')) return;
console.debug(`[oauth-button][${provider.id}] window focus → reset isLoading`);
reset();
probeBackendOnReturn('focus');
};
// Backup path: macOS Spaces / virtual desktops sometimes restore window
@@ -84,11 +132,15 @@ const OAuthProviderButton = ({
if (skipDuringDeepLink('visibilitychange')) return;
console.debug(`[oauth-button][${provider.id}] visibilitychange visible → reset isLoading`);
reset();
probeBackendOnReturn('visibilitychange');
};
const timer = window.setTimeout(() => {
console.debug(`[oauth-button][${provider.id}] timeout → reset isLoading`);
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.
probeBackendOnReturn('timeout');
}, OAUTH_LOADING_TIMEOUT_MS);
window.addEventListener('focus', handleFocus);
@@ -113,9 +165,29 @@ const OAuthProviderButton = ({
setStartupError(null);
setIsLoading(true);
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 {
const backendUrl = await getBackendUrl();
// 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.
const backendUrl = preflight.backendUrl;
const loginUrl = `${backendUrl}/auth/${provider.id}/login${IS_DEV ? '?responseType=json' : ''}`;
if (IS_DEV) {
@@ -133,6 +205,7 @@ const OAuthProviderButton = ({
// Web fallback: direct OAuth flow in current window
window.location.href = loginUrl;
}
browserOpenedRef.current = true;
} catch (error) {
const message = getOAuthStartupFailureMessage(provider);
console.error(`[oauth-button][${provider.id}] OAuth startup failed`, {
@@ -1,13 +1,13 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getBackendUrl } from '../../../services/backendUrl';
import { checkBackendHealthy } from '../../../services/backendHealth';
import { getDeepLinkAuthState } from '../../../store/deepLinkAuthState';
import { openUrl } from '../../../utils/openUrl';
import { isTauri } from '../../../utils/tauriCommands';
import OAuthProviderButton from '../OAuthProviderButton';
vi.mock('../../../services/backendUrl', () => ({ getBackendUrl: vi.fn() }));
vi.mock('../../../services/backendHealth', () => ({ checkBackendHealthy: vi.fn() }));
vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() }));
@@ -29,10 +29,17 @@ const stubProvider = {
const twitterProvider = { ...stubProvider, id: 'twitter' as const, name: 'Twitter' };
const healthyResult = {
healthy: true as const,
status: 200,
latencyMs: 12,
backendUrl: 'https://backend.test',
};
describe('OAuthProviderButton', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(getBackendUrl).mockResolvedValue('https://backend.test');
vi.mocked(checkBackendHealthy).mockResolvedValue(healthyResult);
vi.mocked(openUrl).mockResolvedValue(undefined);
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(getDeepLinkAuthState).mockReturnValue({
@@ -55,11 +62,11 @@ describe('OAuthProviderButton', () => {
// Drain the microtasks queued by the async click handler so openUrl resolves.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl
// → openUrl, plus any internal `.then` chains.
for (let i = 0; i < 6; i++) await Promise.resolve();
});
expect(getBackendUrl).toHaveBeenCalledTimes(1);
expect(openUrl).toHaveBeenCalledWith(
expect.stringMatching(/^https:\/\/backend\.test\/auth\/google\/login(\?.*)?$/)
);
@@ -71,8 +78,9 @@ describe('OAuthProviderButton', () => {
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl
// → openUrl, plus any internal `.then` chains.
for (let i = 0; i < 6; i++) await Promise.resolve();
});
expect(screen.getByText('Connecting...')).toBeInTheDocument();
@@ -96,8 +104,9 @@ describe('OAuthProviderButton', () => {
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl
// → openUrl, plus any internal `.then` chains.
for (let i = 0; i < 6; i++) await Promise.resolve();
});
expect(screen.getByText('Connecting...')).toBeInTheDocument();
@@ -115,8 +124,9 @@ describe('OAuthProviderButton', () => {
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl
// → openUrl, plus any internal `.then` chains.
for (let i = 0; i < 6; i++) await Promise.resolve();
});
expect(screen.getByText('Connecting...')).toBeInTheDocument();
@@ -138,8 +148,9 @@ describe('OAuthProviderButton', () => {
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl
// → openUrl, plus any internal `.then` chains.
for (let i = 0; i < 6; i++) await Promise.resolve();
});
expect(screen.getByText('Connecting...')).toBeInTheDocument();
@@ -160,7 +171,7 @@ describe('OAuthProviderButton', () => {
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
expect(override).toHaveBeenCalledTimes(1);
expect(getBackendUrl).not.toHaveBeenCalled();
expect(checkBackendHealthy).not.toHaveBeenCalled();
expect(openUrl).not.toHaveBeenCalled();
expect(screen.queryByText('Connecting...')).not.toBeInTheDocument();
});
@@ -173,11 +184,12 @@ describe('OAuthProviderButton', () => {
fireEvent.click(button);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl
// → openUrl, plus any internal `.then` chains.
for (let i = 0; i < 6; i++) await Promise.resolve();
});
expect(getBackendUrl).toHaveBeenCalledTimes(1);
expect(checkBackendHealthy).toHaveBeenCalledTimes(1);
expect(openUrl).toHaveBeenCalledTimes(1);
});
@@ -191,8 +203,9 @@ describe('OAuthProviderButton', () => {
fireEvent.click(screen.getByRole('button', { name: 'Twitter' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Drain enough microtasks to cover: checkBackendHealthy → getBackendUrl
// → openUrl, plus any internal `.then` chains.
for (let i = 0; i < 6; i++) await Promise.resolve();
});
expect(screen.getByRole('alert')).toHaveTextContent(
@@ -209,4 +222,140 @@ describe('OAuthProviderButton', () => {
})
);
});
// --- Pre-flight + post-failure backend health probe (issue #1985) ---
it.each([
[
'http-5xx',
{ healthy: false as const, reason: 'http-5xx' as const, status: 504, latencyMs: 1234 },
],
['timeout', { healthy: false as const, reason: 'timeout' as const, latencyMs: 4000 }],
['network', { healthy: false as const, reason: 'network' as const, latencyMs: 5 }],
[
'resolve-failure',
{ healthy: false as const, reason: 'resolve-failure' as const, latencyMs: 0 },
],
])(
'pre-flight reason=%s blocks openUrl and shows the "temporarily unavailable" banner',
async (_label, preflightResult) => {
vi.mocked(checkBackendHealthy).mockResolvedValue(preflightResult);
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(
/OpenHuman cloud sign-in is temporarily unavailable/i
);
expect(screen.getByRole('button', { name: 'Google' })).toBeEnabled();
}
);
it('does NOT trigger a post-return probe when pre-flight blocked browser launch', async () => {
vi.mocked(checkBackendHealthy).mockResolvedValueOnce({
healthy: false,
reason: 'http-5xx',
status: 504,
latencyMs: 1500,
});
render(<OAuthProviderButton provider={stubProvider} />);
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
await act(async () => {
for (let i = 0; i < 6; i++) await Promise.resolve();
});
// Pre-flight ran exactly once for the click — the focus/visibility
// handlers should NOT probe again because the browser was never opened.
expect(checkBackendHealthy).toHaveBeenCalledTimes(1);
// Even if the user happens to re-focus the window (e.g. they alt-tabbed),
// we must not fire an additional probe — there's nothing for the user to
// have returned from.
//
// The focus listener is attached only while isLoading=true, which the
// pre-flight failure already cleared. So this is a regression guard:
await act(async () => {
window.dispatchEvent(new FocusEvent('focus'));
for (let i = 0; i < 4; i++) await Promise.resolve();
});
expect(checkBackendHealthy).toHaveBeenCalledTimes(1);
});
it('after browser-return focus, surfaces the banner when the backend is unhealthy', async () => {
// Happy pre-flight so the browser opens and the focus listener gets armed.
vi.mocked(checkBackendHealthy)
.mockResolvedValueOnce(healthyResult)
.mockResolvedValueOnce({ healthy: false, reason: 'http-5xx', status: 504, latencyMs: 800 });
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).toHaveBeenCalledTimes(1);
// User comes back from the browser without a deep-link round-trip.
await act(async () => {
window.dispatchEvent(new FocusEvent('focus'));
// Drain microtasks for the background probe's then-chain.
for (let i = 0; i < 6; i++) await Promise.resolve();
});
expect(checkBackendHealthy).toHaveBeenCalledTimes(2);
expect(screen.getByRole('alert')).toHaveTextContent(
/OpenHuman cloud sign-in is temporarily unavailable/i
);
});
it('after browser-return focus, stays silent when the backend is healthy (user cancelled)', async () => {
vi.mocked(checkBackendHealthy).mockResolvedValue(healthyResult);
render(<OAuthProviderButton provider={stubProvider} />);
fireEvent.click(screen.getByRole('button', { name: 'Google' }));
await act(async () => {
for (let i = 0; i < 6; i++) await Promise.resolve();
});
await act(async () => {
window.dispatchEvent(new FocusEvent('focus'));
for (let i = 0; i < 6; i++) await Promise.resolve();
});
expect(checkBackendHealthy).toHaveBeenCalledTimes(2);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('shows the unavailable banner if the 90s timeout fires while the backend is down', async () => {
vi.mocked(checkBackendHealthy)
.mockResolvedValueOnce(healthyResult)
.mockResolvedValueOnce({ healthy: false, reason: 'timeout', latencyMs: 6000 });
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).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(90_000);
// After the safety timer fires we kick off probeBackendOnReturn().
// Drain enough microtasks for that async probe to resolve and its
// .then() to flush the alert into the DOM.
for (let i = 0; i < 12; i++) await Promise.resolve();
});
expect(checkBackendHealthy).toHaveBeenCalledTimes(2);
expect(screen.getByRole('alert')).toHaveTextContent(
/OpenHuman cloud sign-in is temporarily unavailable/i
);
});
});
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BACKEND_HEALTH_TIMEOUT_MS, checkBackendHealthy } from '../backendHealth';
import { getBackendUrl } from '../backendUrl';
// Local explicit mock — overrides the global one in app/src/test/setup.ts so
// this suite controls `getBackendUrl()` behavior end-to-end (including the
// rejection case for `resolve-failure`) without depending on the global
// mock's resolved value.
vi.mock('../backendUrl', () => ({ getBackendUrl: vi.fn() }));
const mockedGetBackendUrl = vi.mocked(getBackendUrl);
function makeResponse(status: number): Response {
return new Response(JSON.stringify({ status: 'ok' }), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('checkBackendHealthy', () => {
beforeEach(() => {
mockedGetBackendUrl.mockResolvedValue('https://backend.test');
});
afterEach(() => {
vi.clearAllMocks();
});
it('returns healthy when GET /health responds 200', async () => {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200));
const result = await checkBackendHealthy({ fetchImpl });
expect(result.healthy).toBe(true);
if (result.healthy) {
expect(result.status).toBe(200);
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
}
expect(fetchImpl).toHaveBeenCalledWith(
'https://backend.test/health',
expect.objectContaining({ method: 'GET', cache: 'no-store', credentials: 'omit' })
);
});
it('treats 5xx upstream errors (Cloudflare 504) as unhealthy with reason http-5xx', async () => {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(504));
const result = await checkBackendHealthy({ fetchImpl });
expect(result.healthy).toBe(false);
if (!result.healthy) {
expect(result.reason).toBe('http-5xx');
expect(result.status).toBe(504);
}
});
it('treats 4xx as healthy — the backend is at least reachable', async () => {
// /health may not exist in every environment; a 404 still proves the
// edge + origin are answering. Only 5xx + network failures count as
// "service down" for the OAuth banner.
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(404));
const result = await checkBackendHealthy({ fetchImpl });
expect(result.healthy).toBe(true);
});
it('returns reason=timeout when the fetch aborts after the timeout budget', async () => {
const fetchImpl: typeof fetch = vi.fn((_input, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
// Mimic real fetch: reject with an AbortError when the caller's
// AbortSignal fires.
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('aborted', 'AbortError'));
});
});
});
const result = await checkBackendHealthy({ fetchImpl, timeoutMs: 20 });
expect(result.healthy).toBe(false);
if (!result.healthy) {
expect(result.reason).toBe('timeout');
}
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('returns reason=network when fetch rejects (DNS / offline / CORS / TLS)', async () => {
const fetchImpl = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
const result = await checkBackendHealthy({ fetchImpl });
expect(result.healthy).toBe(false);
if (!result.healthy) {
expect(result.reason).toBe('network');
}
});
it('returns reason=resolve-failure when getBackendUrl throws', async () => {
mockedGetBackendUrl.mockRejectedValueOnce(new Error('Core returned an empty backend URL'));
const fetchImpl = vi.fn();
const result = await checkBackendHealthy({ fetchImpl });
expect(result.healthy).toBe(false);
if (!result.healthy) {
expect(result.reason).toBe('resolve-failure');
}
expect(fetchImpl).not.toHaveBeenCalled();
});
it('exposes a default timeout budget that is short enough for a click handler', () => {
// Pre-flight runs inline on OAuth button click — a multi-second default
// would feel like a stuck UI when the backend is slow. Locking the
// public default to <= 6s catches drift.
expect(BACKEND_HEALTH_TIMEOUT_MS).toBeLessThanOrEqual(6_000);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { getBackendUrl } from './backendUrl';
export const BACKEND_HEALTH_TIMEOUT_MS = 6_000;
export type BackendHealthFailureReason =
| 'timeout' // AbortError after BACKEND_HEALTH_TIMEOUT_MS
| 'network' // fetch rejected before any HTTP response (DNS, CORS, offline, TLS)
| 'http-5xx' // upstream/edge returned a 5xx (e.g. Cloudflare 504 gateway timeout)
| 'resolve-failure'; // could not resolve the backend URL at all
export type BackendHealthResult =
| { healthy: true; status: number; latencyMs: number; backendUrl: string }
| { healthy: false; reason: BackendHealthFailureReason; status?: number; latencyMs: number };
interface CheckOptions {
timeoutMs?: number;
fetchImpl?: typeof fetch;
}
/**
* Probes the backend `/health` endpoint.
*
* `GET /health` on the OpenHuman backend returns 200 `{"status":"ok"}`. We
* treat any 2xx/3xx/4xx as "the backend is reachable at all" — the goal of
* this probe is specifically to catch full edge/origin failures (Cloudflare
* 5xx, DNS, offline) so we can surface them on the Welcome screen instead of
* silently sending the user into a system browser that lands on an error page.
*
* **Never throws.** All network, timeout, and URL-resolution failures are
* caught internally and surfaced as `{ healthy: false, reason: … }` results,
* so callers do not need to wrap this in try/catch. The healthy variant also
* returns the resolved `backendUrl` so callers can reuse it without a second
* `getBackendUrl()` round-trip.
*/
export async function checkBackendHealthy(
options: CheckOptions = {}
): Promise<BackendHealthResult> {
const { timeoutMs = BACKEND_HEALTH_TIMEOUT_MS, fetchImpl = fetch } = options;
const start = Date.now();
let backendUrl: string;
try {
backendUrl = await getBackendUrl();
} catch (err) {
console.debug('[backend-health] could not resolve backend URL', err);
return { healthy: false, reason: 'resolve-failure', latencyMs: 0 };
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(`${backendUrl}/health`, {
method: 'GET',
cache: 'no-store',
credentials: 'omit',
signal: controller.signal,
});
const latencyMs = Date.now() - start;
if (response.status >= 500 && response.status < 600) {
console.debug(`[backend-health] unhealthy: HTTP ${response.status} in ${latencyMs}ms`);
return { healthy: false, reason: 'http-5xx', status: response.status, latencyMs };
}
return { healthy: true, status: response.status, latencyMs, backendUrl };
} catch (err) {
const latencyMs = Date.now() - start;
if (err instanceof DOMException && err.name === 'AbortError') {
console.debug(`[backend-health] timeout after ${latencyMs}ms`);
return { healthy: false, reason: 'timeout', latencyMs };
}
console.debug(`[backend-health] network error after ${latencyMs}ms`, err);
return { healthy: false, reason: 'network', latencyMs };
} finally {
clearTimeout(timer);
}
}
+17 -1
View File
@@ -21,13 +21,29 @@ import { renderWithProviders } from '../src/test/test-utils';
// Module mocks
// ---------------------------------------------------------------------------
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({
mockGetBackendUrl: vi.fn(),
mockOpenUrl: vi.fn(),
mockIsTauri: 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>>();
+17 -1
View File
@@ -21,13 +21,29 @@ import { renderWithProviders } from '../src/test/test-utils';
// Module mocks
// ---------------------------------------------------------------------------
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({
mockGetBackendUrl: vi.fn(),
mockOpenUrl: vi.fn(),
mockIsTauri: 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>>();
+17 -1
View File
@@ -24,13 +24,29 @@ import { renderWithProviders } from '../src/test/test-utils';
// (which are hoisted to the top of the file by Vitest).
// ---------------------------------------------------------------------------
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({
mockGetBackendUrl: vi.fn(),
mockOpenUrl: vi.fn(),
mockIsTauri: 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 }));
+17 -1
View File
@@ -21,13 +21,29 @@ import { renderWithProviders } from '../src/test/test-utils';
// Module mocks
// ---------------------------------------------------------------------------
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({
const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({
mockGetBackendUrl: vi.fn(),
mockOpenUrl: vi.fn(),
mockIsTauri: 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>>();