Unit Testing (#35)

* add testing lib

* remove coverage folder from git

* finished basic test setup

* fix tests typescript and import paths

* add first snapshot

* testing utils

* rename test files; update script flags; ++tests

* testing utils functions

* testing downloadAndResize wip

* remove download test

* specify unwanted coverage paths;
remove update snapshots flag

* fix strings tests

* testing downloadAndResize method

* increasing testing

* fixing snapshots wip

* fixed shell mobile snapshot

* adding snapshots for the screens

* fix onboard snapshot

* fix typescript issues

* fix TabsSelector snapshot

* Account for testing device's locale in ago() tests

* Remove platform detection on regex

* mocking store state wip

* mocking store state

* increasing test coverage

* increasing test coverage

* increasing test coverage on src/screens

* src/screens (except for profile) above 80% cov

* testing profile screen wip

* increase coverage on Menu and TabsSelector

* mocking profile ui state wip

* mocking profile ui state wip

* fixing mobileshell tests wip

* snapshots using testing-library

* fixing profile tests wip

* removing mobile shell tests

* src/view/com tests wip

* remove unnecessary patch-package

* fixed profile test error

* clear mocks after every test

* fix base mocked store values (getters)

* fix base mocked store values
(hasLoaded, nonReplyFeed)

* profile screen above 80% coverage

* testing custom hooks

* improving composer coverage

* fix tests after merge

* finishing composer coverage

* improving src/com/discover coverage

* improve src/view/com/login coverage
fix SuggestedFollows tests
adding some comments

* fix SuggestedFollows tests

* improve src/view/com/profile coverage
extra minor fixes

* improve src/view/com/notifications coverage

* update coverage ignore patterns

* rename errorMessageTryAgainButton
increase SuggestedFollows converage

* improve src/view/com/posts coverage

* improve src/view/com/onboard coverage

* update snapshot

* improve src/view/com/post coverage

* improve src/view/com/post-thread coverage
rename ErrorMessage tests
test Debug and Log components

* init testing state

* testing root-store

* updating comments

* small fixes

* removed extra console logs

* improve src/state/models coverage
refactor rootStore
rename some spies

* adding cleanup method after tests

* improve src/state/models coverage

* improve src/state/models coverage

* improve src/state/models coverage

* improve src/state/models coverage

* test setInterval in setupState

* Clean up tests and update Home screen state management

* Remove some tests we dont need

* Remove snapshot tests

* Remove any tests that dont demonstrate clear value

* Cleanup

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
João Ferreiro 2023-01-17 16:06:00 +00:00 committed by GitHub
parent 11c861d2d3
commit 5abcc8e336
95 changed files with 2852 additions and 9936 deletions

View file

@ -46,6 +46,7 @@ export function Autocomplete({
<Animated.View style={[styles.outer, pal.view, pal.border, topAnimStyle]}>
{items.map((item, i) => (
<TouchableOpacity
testID="autocompleteButton"
key={i}
style={[pal.border, styles.item]}
onPress={() => onSelect(item.handle)}>

View file

@ -56,11 +56,12 @@ export const ComposePost = observer(function ComposePost({
const [isSelectingPhotos, setIsSelectingPhotos] = useState(false)
const [selectedPhotos, setSelectedPhotos] = useState<string[]>([])
const autocompleteView = useMemo<UserAutocompleteViewModel>(
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
() => new UserAutocompleteViewModel(store),
[store],
)
const localPhotos = useMemo<UserLocalPhotosModel>(
const localPhotos = React.useMemo<UserLocalPhotosModel>(
() => new UserLocalPhotosModel(store),
[store],
)
@ -179,11 +180,14 @@ export const ComposePost = observer(function ComposePost({
return (
<KeyboardAvoidingView
testID="composePostView"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={[pal.view, styles.outer]}>
<SafeAreaView style={s.flex1}>
<View style={styles.topbar}>
<TouchableOpacity onPress={onPressCancel}>
<TouchableOpacity
testID="composerCancelButton"
onPress={onPressCancel}>
<Text style={[pal.link, s.f18]}>Cancel</Text>
</TouchableOpacity>
<View style={s.flex1} />
@ -192,7 +196,9 @@ export const ComposePost = observer(function ComposePost({
<ActivityIndicator />
</View>
) : canPost ? (
<TouchableOpacity onPress={onPressPublish}>
<TouchableOpacity
testID="composerPublishButton"
onPress={onPressPublish}>
<LinearGradient
colors={[gradients.primary.start, gradients.primary.end]}
start={{x: 0, y: 0}}
@ -257,6 +263,7 @@ export const ComposePost = observer(function ComposePost({
size={50}
/>
<TextInput
testID="composerTextInput"
ref={textInput}
multiline
scrollEnabled
@ -283,6 +290,7 @@ export const ComposePost = observer(function ComposePost({
)}
<View style={[pal.border, styles.bottomBar]}>
<TouchableOpacity
testID="composerSelectPhotosButton"
onPress={onPressSelectPhotos}
style={[s.pl5]}
hitSlop={HITSLOP}>

View file

@ -85,21 +85,25 @@ export const PhotoCarouselPicker = ({
return (
<ScrollView
testID="photoCarouselPickerView"
horizontal
style={[pal.view, styles.photosContainer]}
showsHorizontalScrollIndicator={false}>
<TouchableOpacity
testID="openCameraButton"
style={[styles.galleryButton, pal.border, styles.photo]}
onPress={handleOpenCamera}>
<FontAwesomeIcon icon="camera" size={24} style={pal.link} />
</TouchableOpacity>
<TouchableOpacity
testID="openGalleryButton"
style={[styles.galleryButton, pal.border, styles.photo]}
onPress={handleOpenGallery}>
<FontAwesomeIcon icon="image" style={pal.link} size={24} />
</TouchableOpacity>
{localPhotos.photos.map((item: any, index: number) => (
<TouchableOpacity
testID="openSelectPhotoButton"
key={`local-image-${index}`}
style={[pal.border, styles.photoButton]}
onPress={() => handleSelectPhoto(item.node.image.uri)}>

View file

@ -17,6 +17,7 @@ export function ComposePrompt({
const pal = usePalette('default')
return (
<TouchableOpacity
testID="composePromptButton"
style={[
pal.view,
pal.border,

View file

@ -25,13 +25,14 @@ export const SelectedPhoto = ({
)
return selectedPhotos.length !== 0 ? (
<View style={styles.imageContainer}>
<View testID="selectedPhotosView" style={styles.imageContainer}>
{selectedPhotos.length !== 0 &&
selectedPhotos.map((item, index) => (
<View
key={`selected-image-${index}`}
style={[styles.image, imageStyle]}>
<TouchableOpacity
testID="removePhotoButton"
onPress={() => handleRemovePhoto(item)}
style={styles.removePhotoButton}>
<FontAwesomeIcon
@ -41,7 +42,11 @@ export const SelectedPhoto = ({
/>
</TouchableOpacity>
<Image style={[styles.image, imageStyle]} source={{uri: item}} />
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle]}
source={{uri: item}}
/>
</View>
))}
</View>

View file

@ -1,4 +1,4 @@
import React, {useMemo, useEffect, useState} from 'react'
import React, {useEffect, useState} from 'react'
import {
ActivityIndicator,
FlatList,
@ -36,7 +36,8 @@ export const SuggestedFollows = observer(
const store = useStores()
const [follows, setFollows] = useState<Record<string, string>>({})
const view = useMemo<SuggestedActorsViewModel>(
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
const view = React.useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store),
[],
)

View file

@ -171,7 +171,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
const isReady = !!email && !!password && !!handle && is13
return (
<ScrollView style={{flex: 1}}>
<ScrollView testID="createAccount" style={{flex: 1}}>
<KeyboardAvoidingView behavior="padding" style={{flex: 1}}>
<View style={styles.logoHero}>
<Logo />
@ -193,6 +193,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
<View style={styles.groupContent}>
<FontAwesomeIcon icon="globe" style={styles.groupContentIcon} />
<TouchableOpacity
testID="registerSelectServiceButton"
style={styles.textBtn}
onPress={onPressSelectService}>
<Text style={styles.textBtnLabel}>
@ -235,6 +236,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
style={styles.groupContentIcon}
/>
<TextInput
testID="registerEmailInput"
style={[styles.textInput]}
placeholder="Email address"
placeholderTextColor={colors.blue0}
@ -248,6 +250,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
<View style={styles.groupContent}>
<FontAwesomeIcon icon="lock" style={styles.groupContentIcon} />
<TextInput
testID="registerPasswordInput"
style={[styles.textInput]}
placeholder="Choose your password"
placeholderTextColor={colors.blue0}
@ -273,6 +276,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
<View style={styles.groupContent}>
<FontAwesomeIcon icon="at" style={styles.groupContentIcon} />
<TextInput
testID="registerHandleInput"
style={[styles.textInput]}
placeholder="eg alice"
placeholderTextColor={colors.blue0}
@ -317,6 +321,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
</View>
<View style={styles.groupContent}>
<TouchableOpacity
testID="registerIs13Input"
style={styles.textBtn}
onPress={() => setIs13(!is13)}>
<View style={is13 ? styles.checkboxFilled : styles.checkbox}>
@ -339,7 +344,9 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
</TouchableOpacity>
<View style={s.flex1} />
{isReady ? (
<TouchableOpacity onPress={onPressNext}>
<TouchableOpacity
testID="createAccountButton"
onPress={onPressNext}>
{isProcessing ? (
<ActivityIndicator color="#fff" />
) : (
@ -347,7 +354,9 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
)}
</TouchableOpacity>
) : !serviceDescription && error ? (
<TouchableOpacity onPress={onPressRetryConnect}>
<TouchableOpacity
testID="registerRetryButton"
onPress={onPressRetryConnect}>
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Retry</Text>
</TouchableOpacity>
) : !serviceDescription ? (

View file

@ -69,7 +69,7 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
const onPressRetryConnect = () => setRetryDescribeTrigger({})
return (
<KeyboardAvoidingView behavior="padding" style={{flex: 1}}>
<KeyboardAvoidingView testID="signIn" behavior="padding" style={{flex: 1}}>
<View style={styles.logoHero}>
<Logo />
</View>
@ -194,8 +194,9 @@ const LoginForm = ({
const isReady = !!serviceDescription && !!handle && !!password
return (
<>
<View style={styles.group}>
<View testID="loginFormView" style={styles.group}>
<TouchableOpacity
testID="loginSelectServiceButton"
style={[styles.groupTitle, {paddingRight: 0, paddingVertical: 6}]}
onPress={onPressSelectService}>
<Text style={[s.flex1, s.white, s.f18, s.bold]} numberOfLines={1}>
@ -213,6 +214,7 @@ const LoginForm = ({
<View style={styles.groupContent}>
<FontAwesomeIcon icon="at" style={styles.groupContentIcon} />
<TextInput
testID="loginUsernameInput"
style={styles.textInput}
placeholder="Username"
placeholderTextColor={colors.blue0}
@ -227,6 +229,7 @@ const LoginForm = ({
<View style={styles.groupContent}>
<FontAwesomeIcon icon="lock" style={styles.groupContentIcon} />
<TextInput
testID="loginPasswordInput"
style={styles.textInput}
placeholder="Password"
placeholderTextColor={colors.blue0}
@ -238,6 +241,7 @@ const LoginForm = ({
editable={!isProcessing}
/>
<TouchableOpacity
testID="forgotPasswordButton"
style={styles.textInputInnerBtn}
onPress={onPressForgotPassword}>
<Text style={styles.textInputInnerBtnLabel}>Forgot</Text>
@ -260,7 +264,9 @@ const LoginForm = ({
</TouchableOpacity>
<View style={s.flex1} />
{!serviceDescription && error ? (
<TouchableOpacity onPress={onPressRetryConnect}>
<TouchableOpacity
testID="loginRetryButton"
onPress={onPressRetryConnect}>
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Retry</Text>
</TouchableOpacity>
) : !serviceDescription ? (
@ -271,7 +277,7 @@ const LoginForm = ({
) : isProcessing ? (
<ActivityIndicator color="#fff" />
) : isReady ? (
<TouchableOpacity onPress={onPressNext}>
<TouchableOpacity testID="loginNextButton" onPress={onPressNext}>
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
</TouchableOpacity>
) : undefined}
@ -339,8 +345,9 @@ const ForgotPasswordForm = ({
Enter the email you used to create your account. We'll send you a "reset
code" so you can set a new password.
</Text>
<View style={styles.group}>
<View testID="forgotPasswordView" style={styles.group}>
<TouchableOpacity
testID="forgotPasswordSelectServiceButton"
style={[styles.groupContent, {borderTopWidth: 0}]}
onPress={onPressSelectService}>
<FontAwesomeIcon icon="globe" style={styles.groupContentIcon} />
@ -359,6 +366,7 @@ const ForgotPasswordForm = ({
<View style={styles.groupContent}>
<FontAwesomeIcon icon="envelope" style={styles.groupContentIcon} />
<TextInput
testID="forgotPasswordEmail"
style={styles.textInput}
placeholder="Email address"
placeholderTextColor={colors.blue0}
@ -391,7 +399,7 @@ const ForgotPasswordForm = ({
) : !email ? (
<Text style={[s.blue1, s.f18, s.bold, s.pr5]}>Next</Text>
) : (
<TouchableOpacity onPress={onPressNext}>
<TouchableOpacity testID="newPasswordButton" onPress={onPressNext}>
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
</TouchableOpacity>
)}
@ -451,10 +459,11 @@ const SetNewPasswordForm = ({
You will receive an email with a "reset code." Enter that code here,
then enter your new password.
</Text>
<View style={styles.group}>
<View testID="newPasswordView" style={styles.group}>
<View style={[styles.groupContent, {borderTopWidth: 0}]}>
<FontAwesomeIcon icon="ticket" style={styles.groupContentIcon} />
<TextInput
testID="resetCodeInput"
style={[styles.textInput]}
placeholder="Reset code"
placeholderTextColor={colors.blue0}
@ -469,6 +478,7 @@ const SetNewPasswordForm = ({
<View style={styles.groupContent}>
<FontAwesomeIcon icon="lock" style={styles.groupContentIcon} />
<TextInput
testID="newPasswordInput"
style={styles.textInput}
placeholder="New password"
placeholderTextColor={colors.blue0}
@ -501,7 +511,7 @@ const SetNewPasswordForm = ({
) : !resetCode || !password ? (
<Text style={[s.blue1, s.f18, s.bold, s.pr5]}>Next</Text>
) : (
<TouchableOpacity onPress={onPressNext}>
<TouchableOpacity testID="setNewPasswordButton" onPress={onPressNext}>
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
</TouchableOpacity>
)}

View file

@ -1,4 +1,4 @@
import React, {useState} from 'react'
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@ -13,7 +13,8 @@ import {s, colors, gradients} from '../../lib/styles'
export function InviteAccepter({item}: {item: NotificationsViewItemModel}) {
const store = useStores()
const [confirmationUri, setConfirmationUri] = useState<string>('')
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
const [confirmationUri, setConfirmationUri] = React.useState<string>('')
const isMember =
confirmationUri !== '' || store.me.memberships?.isMemberOf(item.author.did)
const onPressAccept = async () => {
@ -54,7 +55,7 @@ export function InviteAccepter({item}: {item: NotificationsViewItemModel}) {
return (
<View style={styles.container}>
{!isMember ? (
<TouchableOpacity onPress={onPressAccept}>
<TouchableOpacity testID="acceptInviteButton" onPress={onPressAccept}>
<LinearGradient
colors={[gradients.primary.start, gradients.primary.end]}
start={{x: 0, y: 0}}
@ -64,7 +65,7 @@ export function InviteAccepter({item}: {item: NotificationsViewItemModel}) {
</LinearGradient>
</TouchableOpacity>
) : (
<View style={styles.inviteAccepted}>
<View testID="inviteAccepted" style={styles.inviteAccepted}>
<FontAwesomeIcon icon="check" size={14} style={s.mr5} />
<Text style={[s.gray5, s.f15]}>Invite accepted</Text>
</View>

View file

@ -1,4 +1,4 @@
import React, {useState, useEffect} from 'react'
import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
import {
@ -18,7 +18,8 @@ export const PostRepostedBy = observer(function PostRepostedBy({
uri: string
}) {
const store = useStores()
const [view, setView] = useState<RepostedByViewModel | undefined>()
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
const [view, setView] = React.useState<RepostedByViewModel | undefined>()
useEffect(() => {
if (view?.params.uri === uri) {

View file

@ -1,4 +1,4 @@
import React, {useState, useEffect} from 'react'
import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
import {
@ -20,7 +20,7 @@ export const PostVotedBy = observer(function PostVotedBy({
direction: 'up' | 'down'
}) {
const store = useStores()
const [view, setView] = useState<VotesViewModel | undefined>()
const [view, setView] = React.useState<VotesViewModel | undefined>()
useEffect(() => {
if (view?.params.uri === uri) {

View file

@ -25,6 +25,7 @@ export const Feed = observer(function Feed({
onPressCompose,
onPressTryAgain,
onScroll,
testID,
}: {
feed: FeedModel
style?: StyleProp<ViewStyle>
@ -32,6 +33,7 @@ export const Feed = observer(function Feed({
onPressCompose: () => void
onPressTryAgain?: () => void
onScroll?: OnScrollCb
testID?: string
}) {
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
// VirtualizedList: You have a large list that is slow to update - make sure your
@ -83,7 +85,7 @@ export const Feed = observer(function Feed({
<View />
)
return (
<View style={style}>
<View testID={testID} style={style}>
{!data && <ComposePrompt onPressCompose={onPressCompose} />}
{feed.isLoading && !data && <PostFeedLoadingPlaceholder />}
{feed.hasError && (

View file

@ -1,4 +1,4 @@
import React, {useState, useEffect} from 'react'
import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
import {
@ -19,7 +19,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
name: string
}) {
const store = useStores()
const [view, setView] = useState<UserFollowersViewModel | undefined>()
const [view, setView] = React.useState<UserFollowersViewModel | undefined>()
useEffect(() => {
if (view?.params.user === name) {

View file

@ -1,4 +1,4 @@
import React, {useState, useEffect} from 'react'
import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
import {
@ -10,7 +10,7 @@ import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {UserAvatar} from '../util/UserAvatar'
import {s, colors} from '../../lib/styles'
import {s} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
export const ProfileFollows = observer(function ProfileFollows({
@ -19,7 +19,7 @@ export const ProfileFollows = observer(function ProfileFollows({
name: string
}) {
const store = useStores()
const [view, setView] = useState<UserFollowsViewModel | undefined>()
const [view, setView] = React.useState<UserFollowsViewModel | undefined>()
useEffect(() => {
if (view?.params.user === name) {

View file

@ -147,7 +147,7 @@ export const ProfileHeader = observer(function ProfileHeader({
// =
if (view.hasError) {
return (
<View>
<View testID="profileHeaderHasError">
<Text>{view.error}</Text>
</View>
)
@ -192,6 +192,7 @@ export const ProfileHeader = observer(function ProfileHeader({
<View style={[styles.buttonsLine]}>
{isMe ? (
<TouchableOpacity
testID="profileHeaderEditProfileButton"
onPress={onPressEditProfile}
style={[styles.btn, styles.mainBtn, pal.btn]}>
<Text type="button" style={pal.text}>
@ -214,7 +215,9 @@ export const ProfileHeader = observer(function ProfileHeader({
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={onPressToggleFollow}>
<TouchableOpacity
testID="profileHeaderToggleFollowButton"
onPress={onPressToggleFollow}>
<LinearGradient
colors={[gradient[1], gradient[0]]}
start={{x: 0, y: 0}}
@ -257,6 +260,7 @@ export const ProfileHeader = observer(function ProfileHeader({
</View>
<View style={styles.metricsLine}>
<TouchableOpacity
testID="profileHeaderFollowersButton"
style={[s.flexRow, s.mr10]}
onPress={onPressFollowers}>
<Text type="body2" style={[s.bold, s.mr2, pal.text]}>
@ -268,6 +272,7 @@ export const ProfileHeader = observer(function ProfileHeader({
</TouchableOpacity>
{view.isUser ? (
<TouchableOpacity
testID="profileHeaderFollowsButton"
style={[s.flexRow, s.mr10]}
onPress={onPressFollows}>
<Text type="body2" style={[s.bold, s.mr2, pal.text]}>
@ -280,6 +285,7 @@ export const ProfileHeader = observer(function ProfileHeader({
) : undefined}
{view.isScene ? (
<TouchableOpacity
testID="profileHeaderMembersButton"
style={[s.flexRow, s.mr10]}
onPress={onPressMembers}>
<Text type="body2" style={[s.bold, s.mr2, pal.text]}>
@ -350,7 +356,9 @@ export const ProfileHeader = observer(function ProfileHeader({
</View>
{view.isScene && view.creator === store.me.did ? (
<View style={[styles.sceneAdminContainer, pal.border]}>
<TouchableOpacity onPress={onPressInviteMembers}>
<TouchableOpacity
testID="profileHeaderInviteMembersButton"
onPress={onPressInviteMembers}>
<LinearGradient
colors={[gradient[1], gradient[0]]}
start={{x: 0, y: 0}}
@ -369,6 +377,7 @@ export const ProfileHeader = observer(function ProfileHeader({
</View>
) : undefined}
<TouchableOpacity
testID="profileHeaderAviButton"
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}
onPress={onPressAvi}>
<UserAvatar

View file

@ -1,4 +1,4 @@
import React, {useState, useEffect} from 'react'
import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {ActivityIndicator, FlatList, View} from 'react-native'
import {MembersViewModel, MemberItem} from '../../../state/models/members-view'
@ -12,7 +12,8 @@ export const ProfileMembers = observer(function ProfileMembers({
name: string
}) {
const store = useStores()
const [view, setView] = useState<MembersViewModel | undefined>()
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
const [view, setView] = React.useState<MembersViewModel | undefined>()
useEffect(() => {
if (view?.params.actor === name) {
@ -37,7 +38,7 @@ export const ProfileMembers = observer(function ProfileMembers({
view.params.actor !== name
) {
return (
<View>
<View testID="profileMembersActivityIndicatorView">
<ActivityIndicator />
</View>
)
@ -68,7 +69,7 @@ export const ProfileMembers = observer(function ProfileMembers({
/>
)
return (
<View>
<View testID="profileMembersFlatList">
<FlatList
data={view.members}
keyExtractor={item => item._reactKey}

View file

@ -115,6 +115,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
<View style={[styles.ctrls, opts.style]}>
<View style={s.flex1}>
<TouchableOpacity
testID="postCtrlsReplyButton"
style={styles.ctrl}
hitSlop={HITSLOP}
onPress={opts.onPressReply}>
@ -130,6 +131,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
</View>
<View style={s.flex1}>
<TouchableOpacity
testID="postCtrlsToggleRepostButton"
hitSlop={HITSLOP}
onPress={onPressToggleRepostWrapper}
style={styles.ctrl}>
@ -156,6 +158,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
</View>
<View style={s.flex1}>
<TouchableOpacity
testID="postCtrlsToggleUpvoteButton"
style={styles.ctrl}
hitSlop={HITSLOP}
onPress={onPressToggleUpvoteWrapper}>

View file

@ -8,7 +8,6 @@ import {
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../text/Text'
import {colors} from '../../../lib/styles'
import {useTheme} from '../../../lib/ThemeContext'
import {usePalette} from '../../../lib/hooks/usePalette'
@ -26,7 +25,7 @@ export function ErrorMessage({
const theme = useTheme()
const pal = usePalette('error')
return (
<View style={[styles.outer, pal.view, style]}>
<View testID="errorMessageView" style={[styles.outer, pal.view, style]}>
<View
style={[styles.errorIcon, {backgroundColor: theme.palette.error.icon}]}>
<FontAwesomeIcon icon="exclamation" style={pal.text} size={16} />
@ -38,7 +37,10 @@ export function ErrorMessage({
{message}
</Text>
{onPressTryAgain && (
<TouchableOpacity style={styles.btn} onPress={onPressTryAgain}>
<TouchableOpacity
testID="errorMessageTryAgainButton"
style={styles.btn}
onPress={onPressTryAgain}>
<FontAwesomeIcon
icon="arrows-rotate"
style={{color: theme.palette.error.icon}}

View file

@ -11,16 +11,18 @@ export function ErrorScreen({
message,
details,
onPressTryAgain,
testID,
}: {
title: string
message: string
details?: string
onPressTryAgain?: () => void
testID?: string
}) {
const theme = useTheme()
const pal = usePalette('error')
return (
<View style={[styles.outer, pal.view]}>
<View testID={testID} style={[styles.outer, pal.view]}>
<View style={styles.errorIconContainer}>
<View
style={[
@ -40,6 +42,7 @@ export function ErrorScreen({
<Text style={[styles.message, pal.textLight]}>{message}</Text>
{details && (
<Text
testID={`${testID}-details`}
type="body2"
style={[
styles.details,
@ -52,6 +55,7 @@ export function ErrorScreen({
{onPressTryAgain && (
<View style={styles.btnContainer}>
<TouchableOpacity
testID="errorScreenTryAgainButton"
style={[styles.btn, {backgroundColor: theme.palette.error.icon}]}
onPress={onPressTryAgain}>
<FontAwesomeIcon icon="arrows-rotate" style={pal.text} size={16} />

View file

@ -75,7 +75,8 @@ export function DropdownButton({
style={style}
onPress={onPress}
hitSlop={HITSLOP}
ref={ref}>
// Fix an issue where specific references cause runtime error in jest environment
ref={process.env.JEST_WORKER_ID != null ? null : ref}>
{children}
</TouchableOpacity>
)

View file

@ -25,7 +25,9 @@ export const Contacts = ({navIdx, visible, params}: ScreenParams) => {
return (
<View>
<View style={styles.section}>
<Text style={styles.title}>Contacts</Text>
<Text testID="contactsTitle" style={styles.title}>
Contacts
</Text>
</View>
<View style={styles.section}>
<View style={styles.searchContainer}>
@ -35,6 +37,7 @@ export const Contacts = ({navIdx, visible, params}: ScreenParams) => {
style={styles.searchIcon}
/>
<TextInput
testID="contactsTextInput"
ref={inputRef}
value={searchText}
style={styles.searchInput}

View file

@ -1,4 +1,4 @@
import React, {useState, useEffect} from 'react'
import React, {useEffect} from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import useAppState from 'react-native-appstate-hook'
@ -24,48 +24,48 @@ export const Home = observer(function Home({
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const safeAreaInsets = useSafeAreaInsets()
const [hasSetup, setHasSetup] = useState<boolean>(false)
const [wasVisible, setWasVisible] = React.useState<boolean>(false)
const {appState} = useAppState({
onForeground: () => doPoll(true),
})
const doPoll = (knownActive = false) => {
if ((!knownActive && appState !== 'active') || !visible) {
return
}
if (store.me.mainFeed.isLoading) {
return
}
store.log.debug('Polling home feed')
store.me.mainFeed.checkForLatest().catch(e => {
store.log.error('Failed to poll feed', e)
})
}
const doPoll = React.useCallback(
(knownActive = false) => {
if ((!knownActive && appState !== 'active') || !visible) {
return
}
if (store.me.mainFeed.isLoading) {
return
}
store.log.debug('Polling home feed')
store.me.mainFeed.checkForLatest().catch(e => {
store.log.error('Failed to poll feed', e)
})
},
[appState, visible, store],
)
useEffect(() => {
let aborted = false
const pollInterval = setInterval(() => doPoll(), 15e3)
if (!visible) {
setWasVisible(false)
return
} else if (wasVisible) {
return
}
setWasVisible(true)
if (hasSetup) {
store.log.debug('Updating home feed')
store.nav.setTitle(navIdx, 'Home')
store.log.debug('Updating home feed')
if (store.me.mainFeed.hasContent) {
store.me.mainFeed.update()
doPoll()
} else {
store.nav.setTitle(navIdx, 'Home')
store.log.debug('Fetching home feed')
store.me.mainFeed.setup().then(() => {
if (aborted) return
setHasSetup(true)
})
store.me.mainFeed.setup()
}
return () => {
clearInterval(pollInterval)
aborted = true
}
}, [visible, store])
}, [visible, store, navIdx, doPoll, wasVisible])
const onPressCompose = () => {
store.shell.openComposer({})
@ -82,6 +82,7 @@ export const Home = observer(function Home({
<View style={s.flex1}>
<ViewHeader title="Bluesky" subtitle="Private Beta" canGoBack={false} />
<Feed
testID="homeFeed"
key="default"
feed={store.me.mainFeed}
scrollElRef={scrollElRef}

View file

@ -35,8 +35,11 @@ const SigninOrCreateAccount = ({
<Text style={styles.title}>Bluesky</Text>
<Text style={styles.subtitle}>[ private beta ]</Text>
</View>
<View style={s.flex1}>
<TouchableOpacity style={styles.btn} onPress={onPressCreateAccount}>
<View testID="signinOrCreateAccount" style={s.flex1}>
<TouchableOpacity
testID="createAccountButton"
style={styles.btn}
onPress={onPressCreateAccount}>
<Text style={styles.btnLabel}>Create a new account</Text>
</TouchableOpacity>
<View style={styles.or}>
@ -60,7 +63,10 @@ const SigninOrCreateAccount = ({
</Svg>
<Text style={styles.orLabel}>or</Text>
</View>
<TouchableOpacity style={styles.btn} onPress={onPressSignin}>
<TouchableOpacity
testID="signInButton"
style={styles.btn}
onPress={onPressSignin}>
<Text style={styles.btnLabel}>Sign in</Text>
</TouchableOpacity>
</View>

View file

@ -7,7 +7,7 @@ import {useStores} from '../../state'
export const NotFound = () => {
const stores = useStores()
return (
<View>
<View testID="notFoundView">
<ViewHeader title="Page not found" />
<View
style={{
@ -16,7 +16,11 @@ export const NotFound = () => {
paddingTop: 100,
}}>
<Text style={{fontSize: 40, fontWeight: 'bold'}}>Page not found</Text>
<Button title="Home" onPress={() => stores.nav.navigate('/')} />
<Button
testID="navigateHomeButton"
title="Home"
onPress={() => stores.nav.navigate('/')}
/>
</View>
</View>
)

View file

@ -1,4 +1,4 @@
import React, {useEffect, useState, useMemo} from 'react'
import React, {useEffect, useState} from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@ -30,7 +30,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const [hasSetup, setHasSetup] = useState<boolean>(false)
const uiState = useMemo(
const uiState = React.useMemo(
() => new ProfileUiModel(store, {user: params.name}),
[params.user],
)
@ -201,6 +201,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
? () => (
<>
<FontAwesomeIcon
testID="shouldAdminButton"
icon="user-xmark"
style={[s.mr5]}
size={14}
@ -242,10 +243,11 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
const title =
uiState.profile.displayName || uiState.profile.handle || params.name
return (
<View style={styles.container}>
<View testID="profileView" style={styles.container}>
<ViewHeader title={title} />
{uiState.profile.hasError ? (
<ErrorScreen
testID="profileErrorScreen"
title="Failed to load profile"
message={`There was an issue when attempting to load ${params.name}`}
details={uiState.profile.error}

View file

@ -57,6 +57,7 @@ export const Search = ({navIdx, visible, params}: ScreenParams) => {
<View style={[pal.view, pal.border, styles.inputContainer]}>
<MagnifyingGlassIcon style={[pal.text, styles.inputIcon]} />
<TextInput
testID="searchTextInput"
ref={textInput}
placeholder="Type your query here..."
placeholderTextColor={pal.colors.textLight}
@ -68,7 +69,7 @@ export const Search = ({navIdx, visible, params}: ScreenParams) => {
</View>
<View style={styles.outputContainer}>
{query ? (
<ScrollView onScroll={Keyboard.dismiss}>
<ScrollView testID="searchScrollView" onScroll={Keyboard.dismiss}>
{autocompleteView.searchRes.map((item, i) => (
<TouchableOpacity
key={i}

View file

@ -75,6 +75,7 @@ export const Menu = observer(
onPress?: () => void
}) => (
<TouchableOpacity
testID="menuItemButton"
style={styles.menuItem}
onPress={onPress ? onPress : () => onNavigate(url || '/')}>
<View style={[styles.menuItemIconWrapper]}>
@ -98,8 +99,9 @@ export const Menu = observer(
)
return (
<ScrollView style={[styles.view, pal.view]}>
<ScrollView testID="menuView" style={[styles.view, pal.view]}>
<TouchableOpacity
testID="profileCardButton"
onPress={() => onNavigate(`/profile/${store.me.handle}`)}
style={styles.profileCard}>
<UserAvatar
@ -123,6 +125,7 @@ export const Menu = observer(
</View>
</TouchableOpacity>
<TouchableOpacity
testID="searchBtn"
style={[styles.searchBtn, pal.btn]}
onPress={() => onNavigate('/search')}>
<MagnifyingGlassIcon

View file

@ -116,11 +116,12 @@ export const TabsSelector = observer(
}
if (!active) {
return <View />
return <View testID="emptyView" />
}
return (
<Animated.View
testID="tabsSelectorView"
style={[
styles.wrapper,
{bottom: insets.bottom + 55},
@ -129,7 +130,9 @@ export const TabsSelector = observer(
<View onLayout={onLayout}>
<View style={[s.p10, styles.section]}>
<View style={styles.btns}>
<TouchableWithoutFeedback onPress={onPressShareTab}>
<TouchableWithoutFeedback
testID="shareButton"
onPress={onPressShareTab}>
<View style={[styles.btn]}>
<View style={styles.btnIcon}>
<FontAwesomeIcon size={16} icon="share" />
@ -137,7 +140,9 @@ export const TabsSelector = observer(
<Text style={styles.btnText}>Share</Text>
</View>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback onPress={onPressCloneTab}>
<TouchableWithoutFeedback
testID="cloneButton"
onPress={onPressCloneTab}>
<View style={[styles.btn]}>
<View style={styles.btnIcon}>
<FontAwesomeIcon size={16} icon={['far', 'clone']} />
@ -145,7 +150,9 @@ export const TabsSelector = observer(
<Text style={styles.btnText}>Clone tab</Text>
</View>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback onPress={onPressNewTab}>
<TouchableWithoutFeedback
testID="newTabButton"
onPress={onPressNewTab}>
<View style={[styles.btn]}>
<View style={styles.btnIcon}>
<FontAwesomeIcon size={16} icon="plus" />
@ -164,6 +171,7 @@ export const TabsSelector = observer(
return (
<Swipeable
key={tab.id}
testID="tabsSwipable"
renderLeftActions={renderSwipeActions}
renderRightActions={renderSwipeActions}
leftThreshold={100}
@ -185,6 +193,7 @@ export const TabsSelector = observer(
isActive && styles.active,
]}>
<TouchableWithoutFeedback
testID="changeTabButton"
onPress={() => onPressChangeTab(tabIndex)}>
<View style={styles.tabInner}>
<View style={styles.tabIcon}>
@ -203,6 +212,7 @@ export const TabsSelector = observer(
</View>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback
testID="closeTabButton"
onPress={() => onCloseTab(tabIndex)}>
<View style={styles.tabClose}>
<FontAwesomeIcon

View file

@ -327,7 +327,7 @@ export const MobileShell: React.FC = observer(() => {
start={{x: 0, y: 0.8}}
end={{x: 0, y: 1}}
style={styles.outerContainer}>
<SafeAreaView style={styles.innerContainer}>
<SafeAreaView testID="noSessionView" style={styles.innerContainer}>
<ErrorBoundary>
<Login />
</ErrorBoundary>
@ -338,7 +338,7 @@ export const MobileShell: React.FC = observer(() => {
}
if (store.onboard.isOnboarding) {
return (
<View style={styles.outerContainer}>
<View testID="onboardOuterView" style={styles.outerContainer}>
<View style={styles.innerContainer}>
<ErrorBoundary>
<Onboard />
@ -355,7 +355,7 @@ export const MobileShell: React.FC = observer(() => {
backgroundColor: theme.colorScheme === 'dark' ? colors.gray7 : colors.gray1,
}
return (
<View style={[styles.outerContainer, pal.view]}>
<View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
<StatusBar
barStyle={
theme.colorScheme === 'dark' ? 'light-content' : 'dark-content'