Skip to content
Open
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
19 changes: 18 additions & 1 deletion src/components/StepRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ import { ScreenRecordingRejection } from './interface/ScreenRecordingRejection';
import { ReplayContext, useReplay } from '../store/hooks/useReplay';
import { DeviceWarning } from './interface/DeviceWarning';
import { handleBeforeUnload, shouldConfirmTabClose } from '../utils/closeTabConfirmation';
import { useStorageEngine } from '../storage/storageEngineHooks';

const STUDY_BROWSER_WIDTH = 360;

export function StepRenderer() {
const windowEvents = useRef<EventType[]>([]);
const dispatch = useStoreDispatch();
const { toggleStudyBrowser } = useStoreActions();
const { toggleStudyBrowser, setAlertModal } = useStoreActions();
const { storageEngine } = useStorageEngine();

const isAnalysis = useIsAnalysis();
const studyConfig = useStudyConfig();
Expand All @@ -58,6 +60,21 @@ export function StepRenderer() {
const analysisHasScreenRecording = useStoreSelector((state) => state.analysisHasScreenRecording);
const analysisCanPlayScreenRecording = useStoreSelector((state) => state.analysisCanPlayScreenRecording);

useEffect(() => {
if (!storageEngine) {
return undefined;
}

return storageEngine.subscribeToParticipantDataWriteErrors((error) => {
console.error('Failed to save participant response data', error);
dispatch(setAlertModal({
show: true,
message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.',
title: 'Failed to Save Response',
}));
});
}, [dispatch, setAlertModal, storageEngine]);

// Attach event listeners
useEffect(() => {
// Focus
Expand Down
20 changes: 16 additions & 4 deletions src/components/interface/AlertModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,17 @@ export function AlertModal() {
const close = useCallback(() => storeDispatch(setAlertModal({ ...alertModal, show: false, title: '' })), [alertModal, setAlertModal, storeDispatch]);

useEffect(() => setOpened(alertModal.show), [alertModal.show]);
const isStorageEngineAlert = alertModal.title === 'Failed to connect to the storage engine';
const isStorageEngineAlert = alertModal.title === 'Failed to connect to the storage engine' || alertModal.title === 'Failed to Save Response';
const handleClose = useCallback(() => {
if (isStorageEngineAlert) {
return;
}
close();
}, [close, isStorageEngineAlert]);

const handleReconnect = useCallback(() => {
window.location.reload();
}, []);

const diagnosticsMessage = useMemo(() => {
if (!opened || !isStorageEngineAlert) {
Expand Down Expand Up @@ -55,14 +65,14 @@ export function AlertModal() {
centered
size={isStorageEngineAlert ? '70%' : 'lg'}
withCloseButton={false}
onClose={close}
onClose={handleClose}
>
<Alert
color="red"
radius="xs"
title={alertModal.title}
icon={<IconAlertCircle />}
onClose={close}
onClose={isStorageEngineAlert ? undefined : close}
styles={{ root: { backgroundColor: 'unset' } }}
>
<Text my="xs">
Expand Down Expand Up @@ -101,7 +111,9 @@ export function AlertModal() {
)}

<Group w="100%" justify="end">
<Button onClick={close} color="red" variant="filled" m="xs">Continue Study</Button>
<Button onClick={isStorageEngineAlert ? handleReconnect : close} color="red" variant="filled" m="xs">
{isStorageEngineAlert ? 'Reconnect' : 'Continue Study'}
</Button>
</Group>
</Alert>
</Modal>
Expand Down
2 changes: 1 addition & 1 deletion src/components/interface/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d
const timeoutId = window.setTimeout(() => {
storeDispatch(setAlertModal({
show: true,
message: 'You may be behind a firewall blocking access, or the server collecting data may be down. Study data will not be saved. If you\'re taking the study you will not be compensated for your efforts. You are welcome to look around.',
message: 'You may be behind a firewall blocking access, or the server collecting data may be down. Study data will not be saved. If you\'re taking the study you will not be compensated for your efforts.',
title: 'Failed to connect to the storage engine',
}));
setFirstMount(false);
Expand Down
62 changes: 59 additions & 3 deletions src/components/interface/tests/AlertModal.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { ReactNode } from 'react';
import {
cleanup, fireEvent, render, screen,
} from '@testing-library/react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
beforeEach, describe, expect, test, vi,
afterEach, beforeEach, describe, expect, test, vi,
} from 'vitest';
import { AlertModal } from '../AlertModal';

// ── mutable state ─────────────────────────────────────────────────────────────

let mockAlertModal = { show: false, title: '', message: '' };
let mockSetAlertModal = vi.fn();
let mockStoreDispatch = vi.fn();

// ── mocks ─────────────────────────────────────────────────────────────────────

Expand All @@ -24,8 +29,8 @@ vi.mock('../../../store/store', () => ({
language: 'en',
},
}),
useStoreActions: () => ({ setAlertModal: vi.fn() }),
useStoreDispatch: () => vi.fn(),
useStoreActions: () => ({ setAlertModal: mockSetAlertModal }),
useStoreDispatch: () => mockStoreDispatch,
}));

vi.mock('@mantine/core', () => ({
Expand Down Expand Up @@ -67,6 +72,13 @@ vi.mock('@tabler/icons-react', () => ({
describe('AlertModal', () => {
beforeEach(() => {
mockAlertModal = { show: false, title: '', message: '' };
mockSetAlertModal = vi.fn((payload) => payload);
mockStoreDispatch = vi.fn();
});

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

test('renders nothing when alertModal.show is false', () => {
Expand All @@ -91,6 +103,19 @@ describe('AlertModal', () => {
expect(html).toContain('mailto:[email protected]');
expect(html).toContain('Study ID: test-study');
expect(html).toContain('Participant ID: p1');
expect(html).toContain('Reconnect');
});

test('treats mid-study save failure alert as a blocking storage alert', () => {
mockAlertModal = {
show: true,
title: 'Failed to Save Response',
message: 'Your response could not be saved',
};
const html = renderToStaticMarkup(<AlertModal />);
expect(html).toContain('Reconnect');
expect(html).not.toContain('Continue Study');
expect(html).toContain('Study ID: test-study');
});

test('does not show diagnostics for regular (non-storage) alert', () => {
Expand All @@ -105,4 +130,35 @@ describe('AlertModal', () => {
const html = renderToStaticMarkup(<AlertModal />);
expect(html).toContain('Continue Study');
});

test('reconnect reloads the page without closing storage engine alert', () => {
const reloadSpy = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
configurable: true,
value: {
href: 'https://example.com/study',
reload: reloadSpy,
},
});
mockAlertModal = {
show: true,
title: 'Failed to connect to the storage engine',
message: 'Connection refused',
};

try {
render(<AlertModal />);
fireEvent.click(screen.getByRole('button', { name: 'Reconnect' }));

expect(reloadSpy).toHaveBeenCalledTimes(1);
expect(mockSetAlertModal).not.toHaveBeenCalled();
expect(mockStoreDispatch).not.toHaveBeenCalled();
} finally {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
}
});
});
56 changes: 54 additions & 2 deletions src/components/tests/StepRenderer.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import { ReactNode } from 'react';
import { render, act } from '@testing-library/react';
import {
describe, expect, test, vi,
beforeEach, afterEach,
} from 'vitest';
import { StepRenderer } from '../StepRenderer';
import { shouldConfirmTabClose } from '../../utils/closeTabConfirmation';

// ── mocks ─────────────────────────────────────────────────────────────────────

const mockDispatch = vi.fn();
const mockSetAlertModal = vi.fn((payload) => ({ type: 'setAlertModal', payload }));
const mockSubscribeToParticipantDataWriteErrors = vi.fn();

vi.mock('../interface/AppAside', () => ({
AppAside: () => <div data-testid="app-aside" />,
}));
Expand Down Expand Up @@ -97,8 +102,19 @@ vi.mock('../../store/store', () => ({
analysisHasScreenRecording: false,
analysisCanPlayScreenRecording: false,
}),
useStoreDispatch: () => vi.fn(),
useStoreActions: () => ({ toggleStudyBrowser: vi.fn() }),
useStoreDispatch: () => mockDispatch,
useStoreActions: () => ({
toggleStudyBrowser: vi.fn(),
setAlertModal: mockSetAlertModal,
}),
}));

vi.mock('../../storage/storageEngineHooks', () => ({
useStorageEngine: () => ({
storageEngine: {
subscribeToParticipantDataWriteErrors: mockSubscribeToParticipantDataWriteErrors,
},
}),
}));

vi.mock('../../routes/utils', () => ({
Expand Down Expand Up @@ -153,6 +169,42 @@ vi.mock('lodash.debounce', () => ({
// ── tests ─────────────────────────────────────────────────────────────────────

describe('StepRenderer', () => {
beforeEach(() => {
mockDispatch.mockClear();
mockSetAlertModal.mockClear();
mockSubscribeToParticipantDataWriteErrors.mockReset();
vi.mocked(shouldConfirmTabClose).mockReturnValue(false);
vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

test('shows the blocking storage modal when a queued participant data write fails', async () => {
let onParticipantDataWriteError: ((error: Error) => void) | undefined;
const unsubscribe = vi.fn();
mockSubscribeToParticipantDataWriteErrors.mockImplementation((callback) => {
onParticipantDataWriteError = callback;
return unsubscribe;
});

const { unmount } = await act(async () => render(<StepRenderer />));
act(() => {
onParticipantDataWriteError?.(new Error('write failed'));
});

expect(mockSetAlertModal).toHaveBeenCalledWith({
show: true,
message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.',
title: 'Failed to Save Response',
});
expect(mockDispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'setAlertModal' }));

unmount();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});

test('renders the app shell', async () => {
const { getByTestId } = await act(async () => render(<StepRenderer />));
expect(getByTestId('app-shell')).toBeDefined();
Expand Down
16 changes: 14 additions & 2 deletions src/controllers/ComponentController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,22 @@ export function ComponentController() {
const storeDispatch = useStoreDispatch();
const { setAlertModal } = useStoreActions();
useEffect(() => {
if (storageEngine?.getEngine() !== import.meta.env.VITE_STORAGE_ENGINE) {
const configuredStorageEngine = import.meta.env.VITE_STORAGE_ENGINE;
const activeStorageEngine = storageEngine?.getEngine();
if (!configuredStorageEngine || activeStorageEngine === configuredStorageEngine) {
return;
}

if (activeStorageEngine === 'localStorage' && !import.meta.env.PROD) {
storeDispatch(setAlertModal({
show: true,
message: `There was an issue connecting to the ${configuredStorageEngine} database, so this development build is using localStorage instead. Study data will not be saved to cloud storage.`,
title: 'Using localStorage fallback',
}));
} else {
storeDispatch(setAlertModal({
show: true,
message: `There was an issue connecting to the ${import.meta.env.VITE_STORAGE_ENGINE} database. This could be caused by a network issue or your adblocker. If you are using an adblocker, please disable it for this website and refresh.`,
message: `There was an issue connecting to the ${configuredStorageEngine} database. This could be caused by a network issue or your adblocker. If you are using an adblocker, please disable it for this website and refresh.`,
title: 'Failed to connect to the storage engine',
}));
}
Expand Down
36 changes: 34 additions & 2 deletions src/controllers/tests/ComponentController.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,10 @@ vi.mock('@visdesignlab/upset2-react', () => ({
Upset: () => null,
}));

afterEach(() => vi.restoreAllMocks());
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

// ── typed fixtures ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -554,11 +557,40 @@ describe('ComponentController — effect coverage (render-based)', () => {
);
});

test('setAlertModal dispatched when engine does not match env', async () => {
test('shows localStorage fallback warning when cloud engine falls back in development', async () => {
vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase');
vi.stubEnv('PROD', false);
const mockDispatch = vi.fn();
vi.mocked(useStoreDispatch).mockReturnValue(mockDispatch);
vi.mocked(useStorageEngine).mockReturnValue({
storageEngine: makeStorageEngine({ getEngine: vi.fn<() => 'localStorage'>(() => 'localStorage') }),
setStorageEngine: vi.fn(),
});
render(<ComponentController />);
await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
expect(mockStoreActions.setAlertModal).toHaveBeenCalledWith({
show: true,
message: 'There was an issue connecting to the firebase database, so this development build is using localStorage instead. Study data will not be saved to cloud storage.',
title: 'Using localStorage fallback',
});
});

test('shows storage disconnected alert when cloud engine falls back in production', async () => {
vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase');
vi.stubEnv('PROD', true);
const mockDispatch = vi.fn();
vi.mocked(useStoreDispatch).mockReturnValue(mockDispatch);
vi.mocked(useStorageEngine).mockReturnValue({
storageEngine: makeStorageEngine({ getEngine: vi.fn<() => 'localStorage'>(() => 'localStorage') }),
setStorageEngine: vi.fn(),
});
render(<ComponentController />);
await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
expect(mockStoreActions.setAlertModal).toHaveBeenCalledWith({
show: true,
message: 'There was an issue connecting to the firebase database. This could be caused by a network issue or your adblocker. If you are using an adblocker, please disable it for this website and refresh.',
title: 'Failed to connect to the storage engine',
});
});

test('isAnalysis=true returns early from block effect', async () => {
Expand Down
17 changes: 16 additions & 1 deletion src/storage/engines/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ export abstract class StorageEngine {

private participantDataWriteError: Error | null = null;

private participantDataWriteErrorListeners = new Set<(error: Error) => void>();

private pendingAssetUploads = new Map<string, Promise<void>>();

private pendingAssetOperations = new Set<Promise<unknown>>();
Expand All @@ -205,6 +207,14 @@ export abstract class StorageEngine {
return this.cloudEngine;
}

subscribeToParticipantDataWriteErrors(callback: (error: Error) => void) {
this.participantDataWriteErrorListeners.add(callback);

return () => {
this.participantDataWriteErrorListeners.delete(callback);
};
}

protected shouldDeferInitialParticipantDataPersistence() {
return false;
}
Expand Down Expand Up @@ -438,7 +448,12 @@ export abstract class StorageEngine {
}

private recordParticipantDataWriteError(error: unknown) {
this.participantDataWriteError = normalizeError(error);
const normalizedError = normalizeError(error);
this.participantDataWriteError = normalizedError;

this.participantDataWriteErrorListeners.forEach((listener) => {
listener(normalizedError);
});
}

private consumeParticipantDataWriteError() {
Expand Down
Loading
Loading