mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
1ae31ba14c
commit
9c14c13752
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
Reference in New Issue
Block a user