Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions e2e/tests/network-resilience.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { test, expect } from '@playwright/test';

const NECTAR_URL = process.env.NECTAR_URL || process.env.BASE_URL || 'http://127.0.0.1:8000';

// Dismiss all tour modals
const dismissAllTours = () => {
localStorage.setItem('seen-landing-tour', 'true');
localStorage.setItem('seen-results-tour', 'true');
localStorage.setItem('seen-abstract-tour', 'true');
};

test.describe('Network Resilience', () => {
test.describe('Offline Indicator', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(dismissAllTours);
});

test('shows offline banner when network is disconnected', async ({ page, context }) => {
await page.goto(NECTAR_URL);
await expect(page.locator('body')).toBeVisible();

// Go offline
await context.setOffline(true);
await page.evaluate(() => window.dispatchEvent(new Event('offline')));

// Should show offline banner
await expect(page.getByText('You are offline. Some features may not be available.')).toBeVisible({
timeout: 5000,
});
});

test('shows reconnected banner when coming back online', async ({ page, context }) => {
await page.goto(NECTAR_URL);

// Go offline first
await context.setOffline(true);
await page.evaluate(() => window.dispatchEvent(new Event('offline')));
await expect(page.getByText('You are offline. Some features may not be available.')).toBeVisible({
timeout: 5000,
});

// Go back online
await context.setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event('online')));

// Should show reconnected banner
await expect(page.getByText('You are back online.')).toBeVisible({ timeout: 5000 });
});

test('reconnected banner can be manually dismissed', async ({ page, context }) => {
await page.goto(NECTAR_URL);
await page.waitForLoadState('networkidle');

// Go offline then online
await context.setOffline(true);
await page.evaluate(() => window.dispatchEvent(new Event('offline')));
await expect(page.getByText('You are offline. Some features may not be available.')).toBeVisible({
timeout: 5000,
});

await context.setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event('online')));

const reconnectedBanner = page.getByText('You are back online.');
await expect(reconnectedBanner).toBeVisible({ timeout: 5000 });

// Click dismiss button
await page.getByRole('button', { name: 'Dismiss' }).click();

await expect(reconnectedBanner).not.toBeVisible();
});
});

test.describe('Search Page Error Handling', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(dismissAllTours);
});

test('shows error alert when search API fails with 500', async ({ page }) => {
// Intercept search API and return 500 for all requests
await page.route('**/search/query**', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { msg: 'Internal Server Error' } }),
});
});

await page.goto(`${NECTAR_URL}/search?q=test`);

// Should show "Something went wrong" error message
await expect(page.getByText('Something went wrong')).toBeVisible({ timeout: 20000 });

// Should have "Try Again" button
await expect(page.getByRole('button', { name: 'Try Again' })).toBeVisible();
});

test('shows error when search API returns 503 service unavailable', async ({ page }) => {
await page.route('**/search/query**', (route) => {
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: { msg: 'Service Unavailable' } }),
});
});

await page.goto(`${NECTAR_URL}/search?q=test`);

// Should show error state with Try Again button
await expect(page.getByRole('button', { name: 'Try Again' })).toBeVisible({ timeout: 20000 });
});

test('automatic retry succeeds after transient failures', async ({ page }) => {
let requestCount = 0;

await page.route('**/search/query**', (route) => {
requestCount++;
if (requestCount <= 2) {
// First 2 requests fail
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { msg: 'Internal Server Error' } }),
});
} else {
// 3rd request succeeds (within retry limit)
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
responseHeader: { status: 0 },
response: {
numFound: 1,
start: 0,
docs: [
{
bibcode: '2020Test..001A',
title: ['Test Article After Retry'],
author: ['Test Author'],
},
],
},
}),
});
}
});

await page.goto(`${NECTAR_URL}/search?q=test`);

// Automatic retries should eventually succeed and show results
await expect(page.getByText('Test Article After Retry')).toBeVisible({ timeout: 20000 });
});
});

test.describe('Page Error Boundaries', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(dismissAllTours);
});

test('search page handles malformed API response gracefully', async ({ page }) => {
await page.route('**/search/query**', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: 'not valid json {{{{',
});
});

await page.goto(`${NECTAR_URL}/search?q=test`);

// Page should still render without crashing
await expect(page.locator('body')).toBeVisible();

// Wait for error handling
await page.waitForTimeout(5000);

// Should show some form of error handling - navigation should still work
const navVisible = await page
.getByRole('navigation')
.isVisible()
.catch(() => false);
const errorVisible = await page
.getByText(/something went wrong|error/i)
.first()
.isVisible()
.catch(() => false);
const tryAgainVisible = await page
.getByRole('button', { name: 'Try Again' })
.isVisible()
.catch(() => false);

// At minimum, navigation should be visible (page didn't completely crash)
expect(navVisible || errorVisible || tryAgainVisible).toBe(true);
});
});
});
1 change: 0 additions & 1 deletion src/api/search/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ export function useSearch<TData = IADSApiSearchResponse['response']>(
queryFn: fetchSearch,
meta: { params },
select,
retry: (failCount, error) => failCount < 1 && axios.isAxiosError(error) && error.response?.status !== 400,
...(options as Omit<UseQueryOptions<IADSApiSearchResponse, ErrorType, TData>, 'queryKey' | 'queryFn' | 'select'>),
});
}
Expand Down
54 changes: 54 additions & 0 deletions src/components/NetworkStatusIndicator/NetworkStatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import { Alert, AlertIcon, AlertDescription, CloseButton, Slide } from '@chakra-ui/react';
import { useNetworkStatus } from '@/lib/useNetworkStatus';

export const NetworkStatusIndicator = () => {
const { isOnline, wasOffline } = useNetworkStatus();
const [showReconnected, setShowReconnected] = useState(false);
const [dismissed, setDismissed] = useState(false);

useEffect(() => {
if (isOnline && wasOffline) {
setShowReconnected(true);
setDismissed(false);
const timer = setTimeout(() => {
setShowReconnected(false);
}, 5000);
return () => clearTimeout(timer);
}
}, [isOnline, wasOffline]);

useEffect(() => {
if (!isOnline) {
setDismissed(false);
}
}, [isOnline]);

const handleDismiss = () => {
setDismissed(true);
setShowReconnected(false);
};

if (dismissed && isOnline) {
return null;
}

return (
<>
<Slide direction="top" in={!isOnline} style={{ zIndex: 1400 }}>
<Alert status="error" variant="solid" justifyContent="center">
<AlertIcon />
<AlertDescription>You are offline. Some features may not be available.</AlertDescription>
</Alert>
</Slide>

<Slide direction="top" in={showReconnected && isOnline} style={{ zIndex: 1400 }}>
<Alert status="success" variant="solid" justifyContent="center">
<AlertIcon />
<AlertDescription>You are back online.</AlertDescription>
<CloseButton position="absolute" right="8px" top="8px" onClick={handleDismiss} aria-label="Dismiss" />
</Alert>
</Slide>
</>
);
};
1 change: 1 addition & 0 deletions src/components/NetworkStatusIndicator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NetworkStatusIndicator } from './NetworkStatusIndicator';
64 changes: 64 additions & 0 deletions src/components/PageErrorBoundary/PageErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { ReactNode } from 'react';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { handleBoundaryError } from '@/lib/errorHandler';
import { PageErrorFallback } from '@/components/PageErrorFallback';
import { Center, Spinner } from '@chakra-ui/react';

interface PageErrorBoundaryProps {
children: ReactNode;
pageName?: string;
fallbackTitle?: string;
onReset?: () => void;
fallbackRender?: (props: FallbackProps) => ReactNode;
hideHomeButton?: boolean;
}

const DefaultLoading = () => (
<Center minH="400px">
<Spinner size="xl" color="blue.500" thickness="3px" />
</Center>
);

export const PageErrorBoundary = ({
children,
pageName = 'Page',
fallbackTitle,
onReset,
fallbackRender,
hideHomeButton,
}: PageErrorBoundaryProps) => {
const renderFallback =
fallbackRender ??
((props: FallbackProps) => (
<PageErrorFallback
error={props.error}
resetErrorBoundary={props.resetErrorBoundary}
title={fallbackTitle}
hideHomeButton={hideHomeButton}
/>
));

return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={() => {
onReset?.();
reset();
}}
onError={(error, errorInfo) => {
handleBoundaryError(error, errorInfo, {
component: pageName,
});
}}
fallbackRender={renderFallback}
>
{children}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
};

export { DefaultLoading };
1 change: 1 addition & 0 deletions src/components/PageErrorBoundary/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PageErrorBoundary, DefaultLoading } from './PageErrorBoundary';
Loading
Loading