Skip to content

Commit

Permalink
Display connection errors on main screen
Browse files Browse the repository at this point in the history
Since migrating to React Navigation, connection errors were hidden on
the connection settings screen. Implement a "snackbar" component to
allow for displaying errors on the main screen.

Fixes #107
  • Loading branch information
mhoran committed Jan 5, 2025
1 parent 1271c40 commit 6def0b6
Show file tree
Hide file tree
Showing 10 changed files with 428 additions and 72 deletions.
25 changes: 20 additions & 5 deletions __tests__/lib/weechat/connection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import WeechatConnection, {
ConnectionError
} from '../../../src/lib/weechat/connection';
import type { ConnectionError } from '../../../src/lib/weechat/connection';
import WeechatConnection from '../../../src/lib/weechat/connection';
import {
disconnectAction,
fetchVersionAction
Expand Down Expand Up @@ -52,7 +51,15 @@ describe(WeechatConnection, () => {

mockWebSocket.mock.instances[0].onclose();

expect(onError).toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(
false,
expect.objectContaining<ConnectionError>({
message: expect.any(Function)
})
);
expect(onError.mock.calls[0][1].message()).toMatch(
/Failed to authenticate with weechat relay/
);
expect(onSuccess).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -147,7 +154,15 @@ describe(WeechatConnection, () => {
mockWebSocket.mock.instances[0].onerror();
mockWebSocket.mock.instances[0].close();

expect(onError).toHaveBeenCalledWith(true, ConnectionError.Socket);
expect(onError).toHaveBeenCalledWith(
true,
expect.objectContaining<ConnectionError>({
message: expect.any(Function)
})
);
expect(onError.mock.calls[0][1].message()).toMatch(
/Failed to connect to weechat relay/
);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(2, disconnectAction());
expect(mockWebSocket.mock.instances).toHaveLength(2);
Expand Down
119 changes: 119 additions & 0 deletions __tests__/usecase/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import type { RouteProp } from '@react-navigation/native';
import type { StackNavigationProp } from '@react-navigation/stack';
import { configureStore } from '@reduxjs/toolkit';
import { View } from 'react-native';
import RelayClient from '../../src/lib/weechat/client';
import { reducer } from '../../src/store';
import * as actions from '../../src/store/actions';
import type { AppState } from '../../src/store/app';
import { act, fireEvent, render, screen } from '../../src/test-utils';
import App from '../../src/usecase/App';
import type { RootStackParamList } from '../../src/usecase/Root';
import { Snackbar } from '../../src/usecase/shared/Snackbar';

jest.mock('../../src/usecase/shared/Snackbar');

describe('App', () => {
beforeEach(() => {
jest.mocked(Snackbar).mockReset();
});

describe('when connected', () => {
it('fetches buffer info and clears hotlist for current buffer', () => {
const bufferId = '86c417600';
Expand Down Expand Up @@ -50,6 +58,7 @@ describe('App', () => {
connect={jest.fn()}
disconnect={jest.fn()}
client={client}
connectionError={null}
/>,
{ store }
);
Expand Down Expand Up @@ -106,6 +115,7 @@ describe('App', () => {
connect={jest.fn()}
disconnect={jest.fn()}
client={client}
connectionError={null}
/>,
{
store
Expand Down Expand Up @@ -171,6 +181,7 @@ describe('App', () => {
connect={jest.fn()}
disconnect={jest.fn()}
client={client}
connectionError={null}
/>,
{
store
Expand All @@ -191,4 +202,112 @@ describe('App', () => {
);
});
});

describe('on connection error', () => {
beforeEach(() => {
jest
.mocked(Snackbar)
.mockImplementation(() => <View accessible role="alert" />);
});

it('displays the error', () => {
const client = new RelayClient(jest.fn(), jest.fn(), jest.fn());

render(
<App
route={{} as RouteProp<RootStackParamList, 'App'>}
navigation={{} as StackNavigationProp<RootStackParamList, 'App'>}
connect={jest.fn()}
disconnect={jest.fn()}
client={client}
connectionError={{ message: () => 'There was an error connecting.' }}
/>
);

expect(Snackbar).toHaveBeenCalledWith(
expect.objectContaining({
message: 'There was an error connecting.'
}),
expect.anything()
);

const snackbar = screen.getByRole('alert');
expect(snackbar).toBeOnTheScreen();
});

it('does not display the error after being dismissed', () => {
const client = new RelayClient(jest.fn(), jest.fn(), jest.fn());

render(
<App
route={{} as RouteProp<RootStackParamList, 'App'>}
navigation={{} as StackNavigationProp<RootStackParamList, 'App'>}
connect={jest.fn()}
disconnect={jest.fn()}
client={client}
connectionError={{ message: () => 'There was an error connecting.' }}
/>
);

expect(Snackbar).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
message: 'There was an error connecting.',
onDismiss: expect.any(Function)
}),
expect.anything()
);
const snackbar = screen.getByRole('alert');

jest.mocked(Snackbar).mock.calls[0][0].onDismiss();

expect(snackbar).not.toBeOnTheScreen();
});

it('displays a subsequent error after a previous error was dismissed', () => {
const store = configureStore({
reducer
});
const client = new RelayClient(jest.fn(), jest.fn(), jest.fn());

render(
<App
route={{} as RouteProp<RootStackParamList, 'App'>}
navigation={{} as StackNavigationProp<RootStackParamList, 'App'>}
connect={jest.fn()}
disconnect={jest.fn()}
client={client}
connectionError={{ message: () => 'There was an error connecting.' }}
/>,
{
store
}
);

jest.mocked(Snackbar).mock.calls[0][0].onDismiss();

screen.rerender(
<App
route={{} as RouteProp<RootStackParamList, 'App'>}
navigation={{} as StackNavigationProp<RootStackParamList, 'App'>}
connect={jest.fn()}
disconnect={jest.fn()}
client={client}
connectionError={{
message: () => 'There was a different error connecting.'
}}
/>
);

expect(Snackbar).toHaveBeenCalledWith(
expect.objectContaining({
message: 'There was a different error connecting.'
}),
expect.anything()
);

const snackbar = screen.getByRole('alert');
expect(snackbar).toBeOnTheScreen();
});
});
});
108 changes: 108 additions & 0 deletions __tests__/usecase/shared/Snackbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { render, screen } from '@testing-library/react-native';
import type { PanGesture } from 'react-native-gesture-handler';
import { State } from 'react-native-gesture-handler';
import {
fireGestureHandler,
getByGestureTestId
} from 'react-native-gesture-handler/jest-utils';
import { Snackbar } from '../../../src/usecase/shared/Snackbar';

jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({
default: jest.fn().mockReturnValue({
width: 320,
height: 568
})
}));

jest.unmock('react-native-reanimated');

jest.useFakeTimers();

describe('Snackbar', () => {
describe('on pan', () => {
it('sets translateX to the X position of the pan gesture', () => {
const onDismiss = jest.fn();
render(<Snackbar message="" onDismiss={onDismiss} />);

fireGestureHandler<PanGesture>(getByGestureTestId('snackbarPan'), [
{ state: State.BEGAN, translationX: 0 },
{ state: State.ACTIVE, translationX: 10 },
{ translationX: 20 }
]);

jest.advanceTimersByTime(0);

const snackbar = screen.getByRole('alert');
expect(snackbar.props.jestAnimatedStyle?.value).toEqual(
expect.objectContaining({
transform: expect.arrayContaining([{ translateX: 20 }])
})
);
});

it('resets translateX to 0 if not panned beyond the threshold', () => {
const onDismiss = jest.fn();
render(<Snackbar message="" onDismiss={onDismiss} />);

fireGestureHandler<PanGesture>(getByGestureTestId('snackbarPan'), [
{ state: State.BEGAN, translationX: 0 },
{ state: State.ACTIVE, translationX: 10 },
{ translationX: 20 }
]);

jest.advanceTimersByTime(0);

const snackbar = screen.getByRole('alert');
expect(snackbar.props.jestAnimatedStyle?.value).toEqual(
expect.objectContaining({
transform: expect.arrayContaining([{ translateX: 20 }])
})
);

jest.runAllTimers();

expect(snackbar.props.jestAnimatedStyle?.value).toEqual(
expect.objectContaining({
transform: expect.arrayContaining([{ translateX: 0 }])
})
);
});

it('calls onDismiss when panned to the width of the window', () => {
const onDismiss = jest.fn();
render(<Snackbar message="" onDismiss={onDismiss} />);

fireGestureHandler<PanGesture>(getByGestureTestId('snackbarPan'), [
{ state: State.BEGAN, translationX: 0 },
{ state: State.ACTIVE, translationX: 10 },
{ translationX: 320 },
{ state: State.END, translationX: 320 }
]);

jest.runAllTimers();

expect(onDismiss).toHaveBeenCalled();
});

it('positions the view offscreen panned to the width of the window', () => {
const onDismiss = jest.fn();
render(<Snackbar message="" onDismiss={onDismiss} />);

fireGestureHandler<PanGesture>(getByGestureTestId('snackbarPan'), [
{ state: State.BEGAN, translationX: 0 },
{ state: State.ACTIVE, translationX: 10 },
{ translationX: 320 },
{ state: State.END, translationX: 320 }
]);

jest.runAllTimers();

const snackbar = screen.getByRole('alert');
expect(snackbar.props.jestAnimatedStyle?.value).toEqual(
expect.objectContaining({
transform: expect.arrayContaining([{ translateX: 320 }])
})
);
});
});
});
2 changes: 2 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import '@testing-library/react-native/extend-expect';
import 'react-native-gesture-handler/jestSetup';
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ module.exports = {
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|react-redux)'
],
setupFiles: ['./node_modules/react-native-gesture-handler/jestSetup.js']
setupFilesAfterEnv: ['./jest-setup.ts']
};
21 changes: 16 additions & 5 deletions src/lib/weechat/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,20 @@ import { WeeChatProtocol } from './parser';

const protocol = new WeeChatProtocol();

export enum ConnectionError {
Socket = 1,
Authentication
export interface ConnectionError {
message: () => string;
}

class SocketError implements ConnectionError {
message = () => {
return 'Failed to connect to weechat relay. Check hostname and SSL configuration.';
};
}

class AuthenticationError implements ConnectionError {
message = () => {
return 'Failed to authenticate with weechat relay. Check password.';
};
}

enum State {
Expand Down Expand Up @@ -66,13 +77,13 @@ export default class WeechatConnection {
private handleError(event: Event): void {
console.log(event);
this.reconnect = this.state === State.CONNECTED;
this.onError(this.reconnect, ConnectionError.Socket);
this.onError(this.reconnect, new SocketError());
}

private handleClose(): void {
if (this.state === State.AUTHENTICATING) {
this.state = State.DISCONNECTED;
this.onError(false, ConnectionError.Authentication);
this.onError(false, new AuthenticationError());
return;
}

Expand Down
Loading

0 comments on commit 6def0b6

Please sign in to comment.