diff --git a/apps/mobile-e2e/.detoxrc.json b/apps/mobile-e2e/.detoxrc.json index 4676bd7..1d0f503 100644 --- a/apps/mobile-e2e/.detoxrc.json +++ b/apps/mobile-e2e/.detoxrc.json @@ -47,7 +47,7 @@ "simulator": { "type": "ios.simulator", "device": { - "type": "iPhone 15 Plus" + "type": "iPhone 16" } }, "emulator": { diff --git a/apps/mobile-e2e/.env.example b/apps/mobile-e2e/.env.example new file mode 100644 index 0000000..8441f51 --- /dev/null +++ b/apps/mobile-e2e/.env.example @@ -0,0 +1 @@ +CAT_API_KEY= diff --git a/apps/mobile-e2e/src/__fixtures__/image.jpg b/apps/mobile-e2e/src/__fixtures__/image.jpg new file mode 100644 index 0000000..ca88f80 Binary files /dev/null and b/apps/mobile-e2e/src/__fixtures__/image.jpg differ diff --git a/apps/mobile-e2e/src/app/index.spec.ts b/apps/mobile-e2e/src/app/index.spec.ts index 7b16664..141a720 100644 --- a/apps/mobile-e2e/src/app/index.spec.ts +++ b/apps/mobile-e2e/src/app/index.spec.ts @@ -1,31 +1,97 @@ import { device, element, by, expect } from 'detox'; +import { deleteImage, uploadImage } from '../utils/api'; -describe('KittyGram home screen', () => { +const getSystemDialog = (label: string) => { + if (device.getPlatform() === 'ios') { + return element(by.label(label)).atIndex(0); + } + return element(by.text(label)); +}; + +describe('KittyGram home screen with a fresh account', () => { beforeEach(async () => { await device.reloadReactNative(); }); - // it('should be able to upload a photo', () => { - // - // }); + it('should show the empty list with no images message', async () => { + await expect(element(by.id('NoData'))).toBeVisible(); + }); + + it.skip('should be able to upload a photo', () => { + // This test is skipped because it requires relatively extensive system + // dialog handling which is not yet supported by Detox on Android. + // See https://wix.github.io/Detox/docs/api/system/#bysystemlabellabel + }); + + it('should be able to remove a photo', async () => { + const image = await uploadImage(); + await device.reloadReactNative(); + + const deleteButton = element(by.id(`Card.Button.${image.id}`)); + await expect(deleteButton).toBeVisible(); + await deleteButton.tap(); + + await getSystemDialog('Confirm').tap(); + + await expect(element(by.id('NoData'))).toBeVisible(); + }); +}); + +describe('KittyGram home screen with an existing account', () => { + let image; + + beforeEach(async () => { + image = await uploadImage(); + await device.reloadReactNative(); + }); + + afterEach(async () => { + await deleteImage(image.id); + }); + + it('should display a single image', async () => { + await expect(element(by.id('List'))).toBeVisible(); + }); + + it('should be able to upvote a photo', async () => { + const upvoteButton = element(by.id(`Card.Button.${image.id}`)); + + await expect(upvoteButton).toBeVisible(); + await expect(element(by.id(`Card.Score.${image.id}`))).toHaveText('0'); + + await upvoteButton.tap(); + await expect(element(by.id(`Card.Score.${image.id}`))).toHaveText('1'); + }); + + it('should be able to downvote a photo', async () => { + const downvoteButton = element(by.id(`Card.Button.${image.id}`)); + + await expect(downvoteButton).toBeVisible(); + await expect(element(by.id(`Card.Score.${image.id}`))).toHaveText('0'); + + await downvoteButton.tap(); + await expect(element(by.id(`Card.Score.${image.id}`))).toHaveText('-1'); + }); + + it('should be able to favourite and unfavourite a photo', async () => { + const favouriteButton = element( + by.id(`Card.Button.${image.id}`) + ); + const unfavouriteButton = element( + by.id(`Card.Button.${image.id}`) + ); - // it('should be able to remove a photo', () => { - // - // }); + await expect(favouriteButton).toBeVisible(); + await expect(unfavouriteButton).not.toBeVisible(); - // it('should be able to upvote a photo', () => { - // - // }); + await favouriteButton.tap(); - // it('should be able to downvote a photo', () => { - // - // }); + await expect(favouriteButton).not.toBeVisible(); + await expect(unfavouriteButton).toBeVisible(); - // it('should be able to favourite a photo', () => { - // - // }); + await unfavouriteButton.tap(); - it('should display welcome message', async () => { - await expect(element(by.id('heading'))).toHaveText('Welcome Mobile 👋'); + await expect(favouriteButton).toBeVisible(); + await expect(unfavouriteButton).not.toBeVisible(); }); }); diff --git a/apps/mobile-e2e/src/utils/api.ts b/apps/mobile-e2e/src/utils/api.ts new file mode 100644 index 0000000..884d877 --- /dev/null +++ b/apps/mobile-e2e/src/utils/api.ts @@ -0,0 +1,53 @@ +import { getFixture } from './fixtures'; + +const getUrl = (endpoint: string) => `https://api.thecatapi.com/v1/${endpoint}`; +const getOptions = (options: RequestInit) => ({ + ...options, + headers: { + 'x-api-key': process.env.CAT_API_KEY, + ...options.headers, + }, +}); + +const makeRequest = async (endpoint: string, options: RequestInit) => { + const r = await fetch(getUrl(endpoint), getOptions(options)); + + if (r.ok && options.method === 'DELETE') { + return; + } + + if (r.ok) { + return r.json(); + } + + const error = await r.text(); + console.error(error); + throw new Error(error); +}; + +export const listImages = async () => { + return makeRequest('images?limit=10', { + method: 'GET', + }); +}; + +export const uploadImage = async () => { + const fileName = 'image.jpg'; + const image = await getFixture(fileName); + const body = new FormData(); + body.append('file', image, fileName); + + return makeRequest('images/upload', { + method: 'POST', + headers: { + ContentType: 'multipart/form-data', + }, + body, + }); +}; + +export const deleteImage = async (imageId: string) => { + return makeRequest(`images/${imageId}`, { + method: 'DELETE', + }); +}; diff --git a/apps/mobile-e2e/src/utils/fixtures.ts b/apps/mobile-e2e/src/utils/fixtures.ts new file mode 100644 index 0000000..72a415f --- /dev/null +++ b/apps/mobile-e2e/src/utils/fixtures.ts @@ -0,0 +1,11 @@ +import * as path from 'node:path'; +import { openAsBlob } from 'node:fs'; +import { lookup } from 'mime-types'; + +const fixturesPath = path.resolve(__dirname, '../__fixtures__'); + +export const getFixture = async (fixtureName: string) => { + const resolvedPath = path.resolve(fixturesPath, fixtureName); + const mimeType = lookup(resolvedPath) || 'image/jpg'; + return openAsBlob(resolvedPath, { type: mimeType }); +}; diff --git a/apps/mobile/src/components/CatButton/CatButton.tsx b/apps/mobile/src/components/CatButton/CatButton.tsx index 4b27246..3c6fbb5 100644 --- a/apps/mobile/src/components/CatButton/CatButton.tsx +++ b/apps/mobile/src/components/CatButton/CatButton.tsx @@ -17,10 +17,11 @@ import Animated, { import { useCallback, useEffect } from 'react'; type CatButtonProps = { + animated?: boolean; onPress: ((event: GestureResponderEvent) => void) | null | undefined; }; -export const CatButton = ({ onPress }: CatButtonProps) => { +export const CatButton = ({ animated, onPress }: CatButtonProps) => { const xVal = useSharedValue(-1); const yVal = useSharedValue(0); const blinkVal = useSharedValue(1); @@ -93,8 +94,8 @@ export const CatButton = ({ onPress }: CatButtonProps) => { }, [blinkVal, boopVal, idle, onPress, xVal, yVal]); useEffect(() => { - idle(); - }, [idle]); + animated && idle(); + }, [animated, idle]); const noseStyle = useAnimatedStyle(() => { return { @@ -145,8 +146,10 @@ export const CatButton = ({ onPress }: CatButtonProps) => { }; }); + const pressAction = animated ? boop : onPress; + return ( - + ; export const UploadButton = ({ style, ...props }: UploadButtonProps) => { @@ -10,7 +12,8 @@ export const UploadButton = ({ style, ...props }: UploadButtonProps) => { return ( ]} {...props}> - + {/* Don't animate in test mode because it causes Detox to hang */} + ); }; diff --git a/apps/mobile/src/features/CatCard/CardActions.tsx b/apps/mobile/src/features/CatCard/CardActions.tsx index 3aa32cd..f6af60f 100644 --- a/apps/mobile/src/features/CatCard/CardActions.tsx +++ b/apps/mobile/src/features/CatCard/CardActions.tsx @@ -13,20 +13,25 @@ export const CardActions = ({ item }: CardProps) => { const score = useMemo(() => { const votes = item.votes || []; - const scoreInteger = votes.reduce((acc, curr) => acc + curr.value, 0); - - const scorePrefix = match(scoreInteger) + return votes.reduce((acc, curr) => acc + curr.value, 0); + }, [item.votes]); + const prefix = useMemo(() => { + return match(score) .with(P.number.positive(), () => '😼') .with(P.number.negative(), () => '😾') .otherwise(() => '🐱'); - return `${scorePrefix} ${scoreInteger}`; - }, [item.votes]); + }, [score]); return ( - {score} + + {prefix} + + + {score} + diff --git a/apps/mobile/src/features/CatCard/CatCard.tsx b/apps/mobile/src/features/CatCard/CatCard.tsx index f7ba604..d3601cf 100644 --- a/apps/mobile/src/features/CatCard/CatCard.tsx +++ b/apps/mobile/src/features/CatCard/CatCard.tsx @@ -9,7 +9,7 @@ export const CatCard = ({ item }: CardProps) => { const { styles } = useStyles(stylesheet); return ( - + diff --git a/apps/mobile/src/features/CatCard/DeleteButton.tsx b/apps/mobile/src/features/CatCard/DeleteButton.tsx index 54c2867..f81a14b 100644 --- a/apps/mobile/src/features/CatCard/DeleteButton.tsx +++ b/apps/mobile/src/features/CatCard/DeleteButton.tsx @@ -14,7 +14,7 @@ export const DeleteButton = ({ item }: CardProps) => { style: 'cancel', }, { - text: 'Delete', + text: 'Confirm', onPress: () => deleteMutationFn(item.id), }, ]); @@ -22,6 +22,7 @@ export const DeleteButton = ({ item }: CardProps) => { return ( .${item.id}`} rounded disabled={isLoading} iconProps={{ name: 'trash', size: 24 }} diff --git a/apps/mobile/src/features/CatCard/DownvoteButton.tsx b/apps/mobile/src/features/CatCard/DownvoteButton.tsx index 2b36bb5..0ea3b07 100644 --- a/apps/mobile/src/features/CatCard/DownvoteButton.tsx +++ b/apps/mobile/src/features/CatCard/DownvoteButton.tsx @@ -11,6 +11,7 @@ export const DownvoteButton = ({ item }: CardProps) => { }, [item.id, downvoteMutationFn]); return ( .${item.id}`} disabled={isLoading} iconProps={{ name: 'thumbsdown', size: 24, color: 'red' }} onPress={handlePress} diff --git a/apps/mobile/src/features/CatCard/FavouriteButton.tsx b/apps/mobile/src/features/CatCard/FavouriteButton.tsx index ee3318a..8b3633d 100644 --- a/apps/mobile/src/features/CatCard/FavouriteButton.tsx +++ b/apps/mobile/src/features/CatCard/FavouriteButton.tsx @@ -6,6 +6,9 @@ import { } from '../../store/services/CatApi'; import { CardProps } from './shared'; +const getTestId = (isFavourite: boolean, id: string) => + `Card.Button<${isFavourite ? 'Unfavourite' : 'Favourite'}>.${id}`; + export const FavouriteButton = ({ item }: CardProps) => { const [favouriteMutationFn, { isLoading: isFavouriteLoading }] = useFavouriteImageMutation(); @@ -21,6 +24,7 @@ export const FavouriteButton = ({ item }: CardProps) => { return ( { return ( .${item.id}`} disabled={isLoading} iconProps={{ name: 'thumbsup', size: 24, color: 'green' }} onPress={handlePress} diff --git a/apps/mobile/src/features/HomePage/ImageList.tsx b/apps/mobile/src/features/HomePage/ImageList.tsx index db3fcba..1ce702b 100644 --- a/apps/mobile/src/features/HomePage/ImageList.tsx +++ b/apps/mobile/src/features/HomePage/ImageList.tsx @@ -13,6 +13,7 @@ export const ImageList = () => { return ( `} // style={styles.list} itemLayoutAnimation={LinearTransition} contentContainerStyle={styles.list} diff --git a/apps/mobile/src/features/HomePage/NoImagesFound.tsx b/apps/mobile/src/features/HomePage/NoImagesFound.tsx index b547494..4133998 100644 --- a/apps/mobile/src/features/HomePage/NoImagesFound.tsx +++ b/apps/mobile/src/features/HomePage/NoImagesFound.tsx @@ -5,7 +5,7 @@ import { createStyleSheet, useStyles } from 'react-native-unistyles'; export const NoImagesFound = () => { const { styles } = useStyles(stylesheet); return ( - + `}> No images found diff --git a/apps/mobile/src/features/UploadImageModal/ImageSourceButton.tsx b/apps/mobile/src/features/UploadImageModal/ImageSourceButton.tsx index 41f546f..e0c98ec 100644 --- a/apps/mobile/src/features/UploadImageModal/ImageSourceButton.tsx +++ b/apps/mobile/src/features/UploadImageModal/ImageSourceButton.tsx @@ -58,11 +58,14 @@ export const ImageSourceButton = ({ } }, [dispatch, onComplete, onImageSelect, thunk, uploadImageFn]); + const displaySource = upperCaseFirstLetter(source); + return ( .${displaySource}`} disabled={isLoading} icon={icon} - label={upperCaseFirstLetter(source)} + label={displaySource} onPress={handleSelectAndUpload} /> ); diff --git a/bun.lockb b/bun.lockb index a2a4647..500d43f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index e206454..76bd3a5 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "@testing-library/jest-native": "~5.4.3", "@testing-library/react-native": "~12.5.0", "@types/jest": "^29.5.12", - "@types/node": "18.16.9", + "@types/mime-types": "^2.1.4", + "@types/node": "^22.7.5", "@types/react": "~18.2.45", "babel-jest": "^29.7.0", "babel-preset-expo": "~11.0.0", @@ -66,6 +67,7 @@ "jest-circus": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-expo": "~51.0.2", + "mime-types": "^2.1.35", "nx": "20.0.0", "prettier": "^2.6.2", "react-test-renderer": "18.2.0",