From 1e82f89ba76c461879f5221722037cdfda349637 Mon Sep 17 00:00:00 2001 From: Stephen Hanson Date: Tue, 12 Dec 2023 17:08:15 -0600 Subject: [PATCH 1/3] Add React Query command This follows the template that [we previously coded in react-native-templates](https://github.com/thoughtbot/react-native-templates/pull/1/files#diff-fc130cd2eebfc7cbeb229f509cb090c6fb5837a169b9270709cfa73ff46c9a56). * Installs React Query * Installs testing/mocking utility, MSW * Creates a mocking and testing strategy * Adds an example API call and mock and test For now, the "create" command automatically uses React Query, but we will likely decide to prompt the user if they'd like to use this or Apollo (for GraphQL) in the future. Co-authored-by: Frida Casas --- src/cli.ts | 6 +- src/commands/__tests__/reactQuery.test.ts | 66 +++----- src/commands/createApp.ts | 4 + src/commands/reactQuery.ts | 22 +++ src/util/__tests__/prependToFile.test.ts | 17 ++ src/util/addToGitignore.ts | 10 +- src/util/appendToFile.ts | 12 ++ src/util/prependToFile.ts | 12 ++ templates/boilerplate/App.tsx | 9 +- .../boilerplate/src/__tests__/App.test.tsx | 24 ++- .../src/screens/HomeScreen/HomeScreen.tsx | 2 + templates/createApp/src/test/render.tsx | 44 ++++++ .../src/components/ExampleCoffees.tsx | 49 ++++++ templates/reactQuery/src/test/mock.ts | 148 ++++++++++++++++++ templates/reactQuery/src/test/server.ts | 9 ++ templates/reactQuery/src/util/api/api.ts | 44 ++++++ .../reactQuery/src/util/api/queryClient.ts | 11 ++ templates/testingLibrary/src/test/render.tsx | 4 +- 18 files changed, 435 insertions(+), 58 deletions(-) create mode 100644 src/commands/reactQuery.ts create mode 100644 src/util/__tests__/prependToFile.test.ts create mode 100644 src/util/appendToFile.ts create mode 100644 src/util/prependToFile.ts create mode 100644 templates/createApp/src/test/render.tsx create mode 100644 templates/reactQuery/src/components/ExampleCoffees.tsx create mode 100644 templates/reactQuery/src/test/mock.ts create mode 100644 templates/reactQuery/src/test/server.ts create mode 100644 templates/reactQuery/src/util/api/api.ts create mode 100644 templates/reactQuery/src/util/api/queryClient.ts diff --git a/src/cli.ts b/src/cli.ts index 09daa43..f70998a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -60,7 +60,11 @@ export default function runCli() { '--no-interactive', 'Pass true to skip all prompts and use default values', ) - .action(buildAction(import('./commands/notifications'))); + .action(buildAction(import('./commands/notifications'))) + + .command('react-query') + .description('Add React Query') + .action(buildAction(import('./commands/reactQuery'))); printWelcome(); program.parse(); diff --git a/src/commands/__tests__/reactQuery.test.ts b/src/commands/__tests__/reactQuery.test.ts index 17c2e92..ff2d2de 100644 --- a/src/commands/__tests__/reactQuery.test.ts +++ b/src/commands/__tests__/reactQuery.test.ts @@ -1,8 +1,7 @@ import { fs, vol } from 'memfs'; import { Mock, afterEach, expect, test, vi } from 'vitest'; -import addDependency from '../../util/addDependency'; import print from '../../util/print'; -import addTypescript from '../typescript'; +import addReactQuery from '../reactQuery'; vi.mock('../../util/addDependency'); vi.mock('../../util/print', () => ({ default: vi.fn() })); @@ -12,65 +11,52 @@ afterEach(() => { (print as Mock).mockReset(); }); -test('exits with message if tsconfig.json already exists', async () => { +test('installs React Query and copies templates', async () => { const json = { 'package.json': JSON.stringify({ scripts: {}, dependencies: {}, }), 'tsconfig.json': '1', + 'jest.setup.js': 'import React from "react";\n\n// stuff', }; - vol.fromJSON(json, './'); - await addTypescript(); - expect(print).toHaveBeenCalledWith( - expect.stringMatching(/tsconfig\.json already exists/), - ); + vol.fromJSON(json, './'); - // doesn't modify - expect(fs.readFileSync('tsconfig.json', 'utf8')).toEqual('1'); -}); + await addReactQuery(); -test('writes new tsconfig.json, adds dependencies', async () => { - vol.fromJSON({ - 'package.json': JSON.stringify({ - scripts: {}, - dependencies: { - expo: '1.0.0', - }, - }), - }); + expect(fs.existsSync('src/util/api/api.ts')).toBe(true); - await addTypescript(); + expect(fs.readFileSync('jest.setup.js', 'utf8')).toMatchInlineSnapshot(` + "import server from 'src/test/server'; + import React from \\"react\\"; - expect(addDependency).toHaveBeenCalledWith('typescript @types/react', { - dev: true, - }); + // stuff - expect(fs.readFileSync('tsconfig.json', 'utf8')).toMatch( - '"extends": "expo/tsconfig.base"', - ); + // listen with MSW server. Individual tests can pass mocks to 'render' function + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + afterAll(() => server.close()); - expect(print).not.toHaveBeenCalledWith( - expect.stringMatching(/already exists/), - ); + beforeEach(() => { + server.resetHandlers() + }); + " + `); }); -test("doesn't extend expo/tsconfig.base if not an Expo project", async () => { - vol.fromJSON({ +test('creates jest.setup.js if doesnt exist', async () => { + const json = { 'package.json': JSON.stringify({ scripts: {}, dependencies: {}, }), - }); + 'tsconfig.json': '1', + 'jest.setup.js': 'import React from "react";\n\n// stuff', + }; - await addTypescript(); + vol.fromJSON(json, './'); - expect(addDependency).toHaveBeenCalledWith('typescript @types/react', { - dev: true, - }); + await addReactQuery(); - expect(fs.readFileSync('tsconfig.json', 'utf8')).not.toMatch( - 'expo/tsconfig.base', - ); + expect(fs.existsSync('jest.setup.js')).toBe(true); }); diff --git a/src/commands/createApp.ts b/src/commands/createApp.ts index 5e4e734..641b010 100644 --- a/src/commands/createApp.ts +++ b/src/commands/createApp.ts @@ -64,6 +64,10 @@ export async function createApp( await commit('Initial commit'); spinner.succeed('Installed dependencies'); + spinner.start('Formatting codebase'); + await exec('npm run fix:prettier'); + spinner.succeed('Formatted codebase'); + print(chalk.green(`\n\nšŸ‘– ${appName} successfully configured!`)); print(` diff --git a/src/commands/reactQuery.ts b/src/commands/reactQuery.ts new file mode 100644 index 0000000..56b288e --- /dev/null +++ b/src/commands/reactQuery.ts @@ -0,0 +1,22 @@ +import addDependency from '../util/addDependency'; +import appendToFile from '../util/appendToFile'; +import copyTemplateDirectory from '../util/copyTemplateDirectory'; +import prependToFile from '../util/prependToFile'; + +export default async function addReactQuery() { + await addDependency('@tanstack/react-query axios msw'); + await copyTemplateDirectory({ templateDir: 'reactQuery' }); + await prependToFile('jest.setup.js', "import server from 'src/test/server';"); + await appendToFile( + 'jest.setup.js', + ` +// listen with MSW server. Individual tests can pass mocks to 'render' function +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterAll(() => server.close()); + +beforeEach(() => { + server.resetHandlers() +}); +`, + ); +} diff --git a/src/util/__tests__/prependToFile.test.ts b/src/util/__tests__/prependToFile.test.ts new file mode 100644 index 0000000..bdcb0a7 --- /dev/null +++ b/src/util/__tests__/prependToFile.test.ts @@ -0,0 +1,17 @@ +import { fs, vol } from 'memfs'; +import { expect, test } from 'vitest'; +import prependToFile from '../prependToFile'; + +test('prepends contents', async () => { + const json = { + 'package.json': '1', + 'src/myFile.txt': 'hello world', + }; + + vol.fromJSON(json, './'); + + await prependToFile('src/myFile.txt', 'prepended\ntwo'); + expect(fs.readFileSync('src/myFile.txt', 'utf8')).toMatch( + 'prepended\ntwo\nhello world', + ); +}); diff --git a/src/util/addToGitignore.ts b/src/util/addToGitignore.ts index fe11220..7fb16cf 100644 --- a/src/util/addToGitignore.ts +++ b/src/util/addToGitignore.ts @@ -1,12 +1,8 @@ -import fs from 'fs-extra'; -import path from 'path'; -import getProjectDir from './getProjectDir'; +import appendToFile from './appendToFile'; + /** * lines should be separated by newlines */ export default async function addToGitignore(lines: string) { - return fs.appendFile( - path.join(await getProjectDir(), '.gitignore'), - `\n${lines}`, - ); + return appendToFile('.gitignore', lines); } diff --git a/src/util/appendToFile.ts b/src/util/appendToFile.ts new file mode 100644 index 0000000..0ee864f --- /dev/null +++ b/src/util/appendToFile.ts @@ -0,0 +1,12 @@ +import fs from 'fs-extra'; +import path from 'path'; +import getProjectDir from './getProjectDir'; +/** + * lines should be separated by newlines + */ +export default async function appendToFile(filename: string, lines: string) { + return fs.appendFile( + path.join(await getProjectDir(), filename), + `\n${lines}`, + ); +} diff --git a/src/util/prependToFile.ts b/src/util/prependToFile.ts new file mode 100644 index 0000000..f570419 --- /dev/null +++ b/src/util/prependToFile.ts @@ -0,0 +1,12 @@ +import fs from 'fs-extra'; +import path from 'path'; +import getProjectDir from './getProjectDir'; + +/** + * lines should be separated by newlines + */ +export default async function prependToFile(filename: string, lines: string) { + const fullFilename = path.join(await getProjectDir(), filename); + const contents = await fs.readFile(fullFilename, 'utf8'); + return fs.writeFile(fullFilename, `${lines}\n${contents}`); +} diff --git a/templates/boilerplate/App.tsx b/templates/boilerplate/App.tsx index 76b2838..496acc2 100644 --- a/templates/boilerplate/App.tsx +++ b/templates/boilerplate/App.tsx @@ -1,11 +1,16 @@ import { NavigationContainer } from '@react-navigation/native'; - +import { QueryClientProvider } from '@tanstack/react-query'; import Providers, { Provider } from 'src/components/Providers'; import RootNavigator from 'src/navigators/RootNavigator'; +import queryClient from '../reactQuery/src/util/api/queryClient'; -// Add providers to this array +// Add providers to this array. They will be wrapped around the app, with the +// first items in the array wrapping the last items in the array. const providers: Provider[] = [ (children) => {children}, + (children) => ( + {children} + ), // CODEGEN:BELT:PROVIDERS - do not remove ]; diff --git a/templates/boilerplate/src/__tests__/App.test.tsx b/templates/boilerplate/src/__tests__/App.test.tsx index 80e945f..6bed27f 100644 --- a/templates/boilerplate/src/__tests__/App.test.tsx +++ b/templates/boilerplate/src/__tests__/App.test.tsx @@ -1,10 +1,24 @@ import { screen } from '@testing-library/react-native'; -import RootNavigator from 'src/navigators/RootNavigator'; -import render from 'src/test/render'; +import mock from 'src/test/mock'; +import { renderApplication } from 'src/test/render'; test('renders', async () => { - jest.useFakeTimers(); - render(); - expect(await screen.findByText(/Open up App.tsx/)).toBeDefined(); + const mocks = [mockCoffees()]; + + renderApplication({ mocks }); + + expect(await screen.findByRole('header', { name: 'Mocha' })).toBeDefined(); }); + +function mockCoffees() { + return mock.get('coffee/hot', { + response: [ + { + id: 1, + title: 'Mocha', + image: 'htps://placehold.it/200x200', + }, + ], + }); +} diff --git a/templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx b/templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx index ba298d5..ae7b666 100644 --- a/templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx +++ b/templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx @@ -1,6 +1,7 @@ import { useNavigation } from '@react-navigation/native'; import { StatusBar } from 'expo-status-bar'; import { Button, StyleSheet, Text, View } from 'react-native'; +import ExampleCoffees from 'src/components/ExampleCoffees'; import { HomeScreenProp } from 'src/navigators/navigatorTypes'; export default function HomeScreen() { @@ -14,6 +15,7 @@ export default function HomeScreen() { onPress={() => navigation.navigate('Information', { owner: 'Will' })} /> + ); } diff --git a/templates/createApp/src/test/render.tsx b/templates/createApp/src/test/render.tsx new file mode 100644 index 0000000..0a346f7 --- /dev/null +++ b/templates/createApp/src/test/render.tsx @@ -0,0 +1,44 @@ +import { NavigationContainer } from '@react-navigation/native'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { + RenderAPI, + // eslint-disable-next-line no-restricted-imports + render as TestingLibraryRender, +} from '@testing-library/react-native'; +import { RequestHandler } from 'msw'; +import { ReactElement } from 'react'; +import Providers, { Provider } from 'src/components/Providers'; +import RootNavigator from 'src/navigators/RootNavigator'; +import queryClient from 'src/util/api/queryClient'; +import server from './server'; + +export type RenderOptions = { + mocks?: Array; +}; + +// TODO: this will become customized as the codebase progresses, so our +// tests can be wrapped with appropriate providers, mocks can be supplied, etc +export default function render( + element: ReactElement, + { mocks }: RenderOptions = {}, +): RenderAPI { + if (mocks) { + server.use(...mocks); + } + + const providers: Provider[] = [ + (children) => ( + {children} + ), + (children) => {children}, + // CODEGEN:BELT:PROVIDERS - do not remove + ]; + + return TestingLibraryRender( + {element}, + ); +} + +export function renderApplication(options: RenderOptions = {}) { + return render(, options); +} diff --git a/templates/reactQuery/src/components/ExampleCoffees.tsx b/templates/reactQuery/src/components/ExampleCoffees.tsx new file mode 100644 index 0000000..46da8b4 --- /dev/null +++ b/templates/reactQuery/src/components/ExampleCoffees.tsx @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query'; +import { FlatList, Image, Text, View } from 'react-native'; +import api, { Coffee as CoffeeType } from 'src/util/api/api'; + +// TODO: sample component, remove +export default function ExampleCoffees() { + const { data } = useQuery({ queryKey: ['coffee'], queryFn: api.coffee }); + + return ( + <> + Coffees + } + keyExtractor={(item) => item.id.toString()} + style={{ flexGrow: 0 }} + /> + + ); +} + +function Coffee({ coffee }: { coffee: CoffeeType }) { + const { title, image } = coffee; + + return ( + + + {title} + + + + ); +} diff --git a/templates/reactQuery/src/test/mock.ts b/templates/reactQuery/src/test/mock.ts new file mode 100644 index 0000000..f424238 --- /dev/null +++ b/templates/reactQuery/src/test/mock.ts @@ -0,0 +1,148 @@ +import { + DefaultBodyType, + HttpResponse, + PathParams, + StrictRequest, + http, +} from 'msw'; +import { BASE_URL, RequestMethod } from 'src/util/api/api'; + +type RequestParams = Record< + string, + string | number | undefined | (string | number)[] +>; +type MockRequestParams = { + method: RequestMethod; + response?: TData; + headers?: Record; + params?: Partial; + status?: number; + delay?: number; + baseUrl?: string; +}; + +function mockRequest( + path: string, + { + method, + status = 200, + response, + headers, + params, + baseUrl = BASE_URL, + }: MockRequestParams, +) { + const methodName = method.toLowerCase() as Lowercase; + return http[methodName]( + `${baseUrl}/${path}`, + async (info) => { + const { request, params: actualParams } = info; + validateHeaders(headers, request); + await validateParams(params, actualParams, request); + + const responseString = + typeof response === 'string' ? response : JSON.stringify(response); + return new HttpResponse(responseString, { status }); + }, + { once: true }, + ); +} + +function validateHeaders( + expectedHeaders: Record | undefined, + req: StrictRequest, +) { + if (!expectedHeaders) { + return; + } + + Object.entries(expectedHeaders).forEach(([key, value]) => { + try { + expect(req.headers.get(key)).toEqual(value); + } catch (e) { + handleAndThrowError(req, e, 'the headers did not match expectation'); + } + }); +} + +async function validateParams( + expectedParams: TParams | undefined, + actualParams: PathParams, + req: StrictRequest, +) { + if (!expectedParams) { + return; + } + + const searchParams = Object.fromEntries(new URL(req.url).searchParams); + const params = Object.keys(searchParams).length ? searchParams : actualParams; + + try { + expect(params).toMatchObject(expectedParams); + } catch (e) { + handleAndThrowError(req, e, 'the params did not match expectation'); + } +} + +function handleAndThrowError( + request: StrictRequest, + e: unknown, + message: string, +) { + const error = e as Error; + if (error.message) { + error.message = `Mock for ${request.method} ${ + request.url + } was called, but ${message}. Verify that the mocks provided to the test are correct.\n\n${ + error.message + }.\n\nThis error occurred in test: ${ + expect.getState().testPath || '' + }. Test name: '${expect.getState().currentTestName || 'unknown'}'`; + } + // eslint-disable-next-line no-console + console.error(error.stack); + throw error; +} + +export type MockParams = Omit< + MockRequestParams, + 'method' +>; + +/** + * mock requests for tests + * Eg. usage: + * const mocks = [ + * mock.post('/user/login', { response: { firstName: 'Debra' }}) + * ]) + * + * render(\, { mocks }) + */ +const mock = { + /** + * mock a GET request to the specified path. + * If params are passed, will throw an error if request params do not match + * */ + get: ( + path: string, + params: MockParams = {}, + ) => mockRequest(path, { ...params, method: 'GET' }), + /** + * mock a POST request to the specified path + * if params are passed, will throw an error if request params do not match + * */ + post: ( + path: string, + params: MockParams = {}, + ) => mockRequest(path, { ...params, method: 'POST' }), + /** + * mock a DELETE request to the specified path + * if params are passed, will throw an error if request params do not match + * */ + delete: ( + path: string, + params: MockParams = {}, + ) => mockRequest(path, { ...params, method: 'DELETE' }), +}; + +export default mock; diff --git a/templates/reactQuery/src/test/server.ts b/templates/reactQuery/src/test/server.ts new file mode 100644 index 0000000..dbe8f29 --- /dev/null +++ b/templates/reactQuery/src/test/server.ts @@ -0,0 +1,9 @@ +import { setupServer } from 'msw/node'; + +/** + * MSW server for mocking network requests + * server is started in jest.setup.js + * individual tests can pass mocks to 'render' function + */ +const server = setupServer(); +export default server; diff --git a/templates/reactQuery/src/util/api/api.ts b/templates/reactQuery/src/util/api/api.ts new file mode 100644 index 0000000..b25ce11 --- /dev/null +++ b/templates/reactQuery/src/util/api/api.ts @@ -0,0 +1,44 @@ +import axios from 'axios'; + +export type RequestMethod = 'GET' | 'POST' | 'DELETE'; + +// Consider moving this out into environment configuration +// to support multiple environments +export const BASE_URL = 'https://api.sampleapis.com'; + +type Params = { + path: string; + method: RequestMethod; + params?: unknown; + parseJson?: boolean; + baseUrl?: string; +}; + +async function makeRequest(options: Params): Promise { + const { path, method, params, baseUrl = BASE_URL } = options; + + const response = await axios(`${baseUrl}/${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + }, + [method === 'GET' ? 'params' : 'data']: params, + }); + + return response.data; +} + +const api = { + coffee: () => makeRequest({ path: 'coffee/hot', method: 'GET' }), +}; + +// TODO: sample data, remove +export type Coffee = { + title: string; + description: string; + /** url */ + image: string; + id: number; +}; + +export default api; diff --git a/templates/reactQuery/src/util/api/queryClient.ts b/templates/reactQuery/src/util/api/queryClient.ts new file mode 100644 index 0000000..e358b6f --- /dev/null +++ b/templates/reactQuery/src/util/api/queryClient.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +export default queryClient; diff --git a/templates/testingLibrary/src/test/render.tsx b/templates/testingLibrary/src/test/render.tsx index 2a7c909..1b1d322 100644 --- a/templates/testingLibrary/src/test/render.tsx +++ b/templates/testingLibrary/src/test/render.tsx @@ -9,7 +9,5 @@ import { ReactElement } from 'react'; // TODO: this will become customized as the codebase progresses, so our // tests can be wrapped with appropriate providers, mocks can be supplied, etc export default function render(element: ReactElement): RenderAPI { - return TestingLibraryRender( - {element}, - ); + return TestingLibraryRender(element); } From fc7a33233e70037443bcc9ef7d4dc4869e34afc0 Mon Sep 17 00:00:00 2001 From: Rakesh Arunachalam Date: Fri, 16 Feb 2024 12:22:21 +0000 Subject: [PATCH 2/3] Fix import of queryClient --- templates/boilerplate/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/boilerplate/App.tsx b/templates/boilerplate/App.tsx index 496acc2..f8d50ac 100644 --- a/templates/boilerplate/App.tsx +++ b/templates/boilerplate/App.tsx @@ -2,7 +2,7 @@ import { NavigationContainer } from '@react-navigation/native'; import { QueryClientProvider } from '@tanstack/react-query'; import Providers, { Provider } from 'src/components/Providers'; import RootNavigator from 'src/navigators/RootNavigator'; -import queryClient from '../reactQuery/src/util/api/queryClient'; +import queryClient from 'src/util/api/queryClient'; // Add providers to this array. They will be wrapped around the app, with the // first items in the array wrapping the last items in the array. From 8f58d5d6d21681a64a7a2207a5be8ae770e395c8 Mon Sep 17 00:00:00 2001 From: Stephen Hanson Date: Fri, 3 May 2024 12:27:33 -0500 Subject: [PATCH 3/3] Move React Query setup into main app boilerplate --- src/cli.ts | 6 +- src/commands/__tests__/reactQuery.test.ts | 62 ------------------- src/commands/createApp.ts | 6 +- src/commands/reactQuery.ts | 22 ------- src/util/__tests__/prependToFile.test.ts | 17 ----- src/util/prependToFile.ts | 12 ---- templates/boilerplate/jest.setup.js | 14 +++++ templates/boilerplate/package.json | 4 ++ .../boilerplate/src/__tests__/App.test.tsx | 7 +++ .../src/components/ExampleCoffees.tsx | 2 +- .../src/test/mock.ts | 0 templates/boilerplate/src/test/render.tsx | 33 +++++++++- .../src/test/server.ts | 0 templates/boilerplate/src/test/sleep.ts | 5 ++ .../boilerplate/src/test/waitForUpdates.ts | 12 ++++ .../src/util/api/api.ts | 2 +- .../src/util/api/queryClient.ts | 0 templates/createApp/src/test/render.tsx | 44 ------------- 18 files changed, 77 insertions(+), 171 deletions(-) delete mode 100644 src/commands/__tests__/reactQuery.test.ts delete mode 100644 src/commands/reactQuery.ts delete mode 100644 src/util/__tests__/prependToFile.test.ts delete mode 100644 src/util/prependToFile.ts rename templates/{reactQuery => boilerplate}/src/components/ExampleCoffees.tsx (97%) rename templates/{reactQuery => boilerplate}/src/test/mock.ts (100%) rename templates/{reactQuery => boilerplate}/src/test/server.ts (100%) create mode 100644 templates/boilerplate/src/test/sleep.ts create mode 100644 templates/boilerplate/src/test/waitForUpdates.ts rename templates/{reactQuery => boilerplate}/src/util/api/api.ts (96%) rename templates/{reactQuery => boilerplate}/src/util/api/queryClient.ts (100%) delete mode 100644 templates/createApp/src/test/render.tsx diff --git a/src/cli.ts b/src/cli.ts index f70998a..09daa43 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -60,11 +60,7 @@ export default function runCli() { '--no-interactive', 'Pass true to skip all prompts and use default values', ) - .action(buildAction(import('./commands/notifications'))) - - .command('react-query') - .description('Add React Query') - .action(buildAction(import('./commands/reactQuery'))); + .action(buildAction(import('./commands/notifications'))); printWelcome(); program.parse(); diff --git a/src/commands/__tests__/reactQuery.test.ts b/src/commands/__tests__/reactQuery.test.ts deleted file mode 100644 index ff2d2de..0000000 --- a/src/commands/__tests__/reactQuery.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { fs, vol } from 'memfs'; -import { Mock, afterEach, expect, test, vi } from 'vitest'; -import print from '../../util/print'; -import addReactQuery from '../reactQuery'; - -vi.mock('../../util/addDependency'); -vi.mock('../../util/print', () => ({ default: vi.fn() })); - -afterEach(() => { - vol.reset(); - (print as Mock).mockReset(); -}); - -test('installs React Query and copies templates', async () => { - const json = { - 'package.json': JSON.stringify({ - scripts: {}, - dependencies: {}, - }), - 'tsconfig.json': '1', - 'jest.setup.js': 'import React from "react";\n\n// stuff', - }; - - vol.fromJSON(json, './'); - - await addReactQuery(); - - expect(fs.existsSync('src/util/api/api.ts')).toBe(true); - - expect(fs.readFileSync('jest.setup.js', 'utf8')).toMatchInlineSnapshot(` - "import server from 'src/test/server'; - import React from \\"react\\"; - - // stuff - - // listen with MSW server. Individual tests can pass mocks to 'render' function - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); - afterAll(() => server.close()); - - beforeEach(() => { - server.resetHandlers() - }); - " - `); -}); - -test('creates jest.setup.js if doesnt exist', async () => { - const json = { - 'package.json': JSON.stringify({ - scripts: {}, - dependencies: {}, - }), - 'tsconfig.json': '1', - 'jest.setup.js': 'import React from "react";\n\n// stuff', - }; - - vol.fromJSON(json, './'); - - await addReactQuery(); - - expect(fs.existsSync('jest.setup.js')).toBe(true); -}); diff --git a/src/commands/createApp.ts b/src/commands/createApp.ts index 641b010..209b64c 100644 --- a/src/commands/createApp.ts +++ b/src/commands/createApp.ts @@ -64,10 +64,6 @@ export async function createApp( await commit('Initial commit'); spinner.succeed('Installed dependencies'); - spinner.start('Formatting codebase'); - await exec('npm run fix:prettier'); - spinner.succeed('Formatted codebase'); - print(chalk.green(`\n\nšŸ‘– ${appName} successfully configured!`)); print(` @@ -106,7 +102,7 @@ async function printIntro(appName: string) { - ESLint - Jest, React Native Testing Library - React Navigation - - Intuitive directory structure + - TanStack Query (formerly known as React Query) `); if (!globals.interactive) { diff --git a/src/commands/reactQuery.ts b/src/commands/reactQuery.ts deleted file mode 100644 index 56b288e..0000000 --- a/src/commands/reactQuery.ts +++ /dev/null @@ -1,22 +0,0 @@ -import addDependency from '../util/addDependency'; -import appendToFile from '../util/appendToFile'; -import copyTemplateDirectory from '../util/copyTemplateDirectory'; -import prependToFile from '../util/prependToFile'; - -export default async function addReactQuery() { - await addDependency('@tanstack/react-query axios msw'); - await copyTemplateDirectory({ templateDir: 'reactQuery' }); - await prependToFile('jest.setup.js', "import server from 'src/test/server';"); - await appendToFile( - 'jest.setup.js', - ` -// listen with MSW server. Individual tests can pass mocks to 'render' function -beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); -afterAll(() => server.close()); - -beforeEach(() => { - server.resetHandlers() -}); -`, - ); -} diff --git a/src/util/__tests__/prependToFile.test.ts b/src/util/__tests__/prependToFile.test.ts deleted file mode 100644 index bdcb0a7..0000000 --- a/src/util/__tests__/prependToFile.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { fs, vol } from 'memfs'; -import { expect, test } from 'vitest'; -import prependToFile from '../prependToFile'; - -test('prepends contents', async () => { - const json = { - 'package.json': '1', - 'src/myFile.txt': 'hello world', - }; - - vol.fromJSON(json, './'); - - await prependToFile('src/myFile.txt', 'prepended\ntwo'); - expect(fs.readFileSync('src/myFile.txt', 'utf8')).toMatch( - 'prepended\ntwo\nhello world', - ); -}); diff --git a/src/util/prependToFile.ts b/src/util/prependToFile.ts deleted file mode 100644 index f570419..0000000 --- a/src/util/prependToFile.ts +++ /dev/null @@ -1,12 +0,0 @@ -import fs from 'fs-extra'; -import path from 'path'; -import getProjectDir from './getProjectDir'; - -/** - * lines should be separated by newlines - */ -export default async function prependToFile(filename: string, lines: string) { - const fullFilename = path.join(await getProjectDir(), filename); - const contents = await fs.readFile(fullFilename, 'utf8'); - return fs.writeFile(fullFilename, `${lines}\n${contents}`); -} diff --git a/templates/boilerplate/jest.setup.js b/templates/boilerplate/jest.setup.js index 7d448c7..00da6e6 100644 --- a/templates/boilerplate/jest.setup.js +++ b/templates/boilerplate/jest.setup.js @@ -2,6 +2,8 @@ import '@testing-library/jest-native/extend-expect'; import { configure } from '@testing-library/react-native'; import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock'; import mockBackHandler from 'react-native/Libraries/Utilities/__mocks__/BackHandler.js'; +import server from 'src/test/server'; +import queryClient from 'src/util/api/queryClient'; beforeEach(() => { jest.clearAllMocks(); @@ -35,6 +37,18 @@ jest.mock('@react-native-async-storage/async-storage', () => jest.mock('react-native-keyboard-aware-scroll-view'); +// listen with MSW server. Individual tests can pass mocks to 'render' function +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterAll(() => server.close()); + +beforeEach(() => { + server.resetHandlers(); +}); + +afterEach(() => { + queryClient.clear(); +}); + // configure debug output for RN Testing Library // is way too verbose by default. Only include common // props that might affect test failure. diff --git a/templates/boilerplate/package.json b/templates/boilerplate/package.json index fc062a1..a9d5598 100644 --- a/templates/boilerplate/package.json +++ b/templates/boilerplate/package.json @@ -23,10 +23,13 @@ "@react-navigation/bottom-tabs": "^6.5.20", "@react-navigation/native": "^6.1.10", "@react-navigation/native-stack": "^6.9.18", + "@tanstack/react-query": "^5.32.1", + "axios": "^1.6.8", "expo": "^50.0.17", "expo-status-bar": "~1.11.1", "jest": "^29.3.1", "jest-expo": "~50.0.2", + "msw": "^2.2.14", "react": "18.2.0", "react-native": "0.73.6", "react-native-keyboard-aware-scrollview": "^2.1.0", @@ -40,6 +43,7 @@ "@thoughtbot/eslint-config": "^1.0.2", "@types/jest": "^29.5.12", "@types/react": "~18.2.73", + "@types/react-test-renderer": "^18.0.7", "babel-jest": "^29.7.0", "create-belt-app": "^0.4.0", "eslint": "^8.56.0", diff --git a/templates/boilerplate/src/__tests__/App.test.tsx b/templates/boilerplate/src/__tests__/App.test.tsx index 6bed27f..61ebeca 100644 --- a/templates/boilerplate/src/__tests__/App.test.tsx +++ b/templates/boilerplate/src/__tests__/App.test.tsx @@ -4,6 +4,13 @@ import mock from 'src/test/mock'; import { renderApplication } from 'src/test/render'; test('renders', async () => { + // We would not normally recommend fake timers, but the tests are currently + // throwing a "not wrapped in act" warning after this test finishes. One + // option is to put a `await waitForUpdates()` at the end of the test, but + // fake timers also work here until we find a better solution. The stack trace + // seems to point to React Navigation bottom tabs. + jest.useFakeTimers(); + const mocks = [mockCoffees()]; renderApplication({ mocks }); diff --git a/templates/reactQuery/src/components/ExampleCoffees.tsx b/templates/boilerplate/src/components/ExampleCoffees.tsx similarity index 97% rename from templates/reactQuery/src/components/ExampleCoffees.tsx rename to templates/boilerplate/src/components/ExampleCoffees.tsx index 46da8b4..bf1a228 100644 --- a/templates/reactQuery/src/components/ExampleCoffees.tsx +++ b/templates/boilerplate/src/components/ExampleCoffees.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { FlatList, Image, Text, View } from 'react-native'; import api, { Coffee as CoffeeType } from 'src/util/api/api'; -// TODO: sample component, remove +// TODO: sample data, remove export default function ExampleCoffees() { const { data } = useQuery({ queryKey: ['coffee'], queryFn: api.coffee }); diff --git a/templates/reactQuery/src/test/mock.ts b/templates/boilerplate/src/test/mock.ts similarity index 100% rename from templates/reactQuery/src/test/mock.ts rename to templates/boilerplate/src/test/mock.ts diff --git a/templates/boilerplate/src/test/render.tsx b/templates/boilerplate/src/test/render.tsx index 2a7c909..0a346f7 100644 --- a/templates/boilerplate/src/test/render.tsx +++ b/templates/boilerplate/src/test/render.tsx @@ -1,15 +1,44 @@ import { NavigationContainer } from '@react-navigation/native'; +import { QueryClientProvider } from '@tanstack/react-query'; import { RenderAPI, // eslint-disable-next-line no-restricted-imports render as TestingLibraryRender, } from '@testing-library/react-native'; +import { RequestHandler } from 'msw'; import { ReactElement } from 'react'; +import Providers, { Provider } from 'src/components/Providers'; +import RootNavigator from 'src/navigators/RootNavigator'; +import queryClient from 'src/util/api/queryClient'; +import server from './server'; + +export type RenderOptions = { + mocks?: Array; +}; // TODO: this will become customized as the codebase progresses, so our // tests can be wrapped with appropriate providers, mocks can be supplied, etc -export default function render(element: ReactElement): RenderAPI { +export default function render( + element: ReactElement, + { mocks }: RenderOptions = {}, +): RenderAPI { + if (mocks) { + server.use(...mocks); + } + + const providers: Provider[] = [ + (children) => ( + {children} + ), + (children) => {children}, + // CODEGEN:BELT:PROVIDERS - do not remove + ]; + return TestingLibraryRender( - {element}, + {element}, ); } + +export function renderApplication(options: RenderOptions = {}) { + return render(, options); +} diff --git a/templates/reactQuery/src/test/server.ts b/templates/boilerplate/src/test/server.ts similarity index 100% rename from templates/reactQuery/src/test/server.ts rename to templates/boilerplate/src/test/server.ts diff --git a/templates/boilerplate/src/test/sleep.ts b/templates/boilerplate/src/test/sleep.ts new file mode 100644 index 0000000..6172333 --- /dev/null +++ b/templates/boilerplate/src/test/sleep.ts @@ -0,0 +1,5 @@ +export default function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/templates/boilerplate/src/test/waitForUpdates.ts b/templates/boilerplate/src/test/waitForUpdates.ts new file mode 100644 index 0000000..20000bd --- /dev/null +++ b/templates/boilerplate/src/test/waitForUpdates.ts @@ -0,0 +1,12 @@ +import { act } from '@testing-library/react-native'; +import sleep from './sleep'; + +/** + * Wait a specified time, wrapped in act + * Usually, it is better to use waitFor or a findBy* matcher, + * but this is sometimes required + * @param time + */ +export default async function waitForUpdates(time = 2) { + return act(() => sleep(time)); +} diff --git a/templates/reactQuery/src/util/api/api.ts b/templates/boilerplate/src/util/api/api.ts similarity index 96% rename from templates/reactQuery/src/util/api/api.ts rename to templates/boilerplate/src/util/api/api.ts index b25ce11..13d8203 100644 --- a/templates/reactQuery/src/util/api/api.ts +++ b/templates/boilerplate/src/util/api/api.ts @@ -36,7 +36,7 @@ const api = { export type Coffee = { title: string; description: string; - /** url */ + /** the url to the image */ image: string; id: number; }; diff --git a/templates/reactQuery/src/util/api/queryClient.ts b/templates/boilerplate/src/util/api/queryClient.ts similarity index 100% rename from templates/reactQuery/src/util/api/queryClient.ts rename to templates/boilerplate/src/util/api/queryClient.ts diff --git a/templates/createApp/src/test/render.tsx b/templates/createApp/src/test/render.tsx deleted file mode 100644 index 0a346f7..0000000 --- a/templates/createApp/src/test/render.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { NavigationContainer } from '@react-navigation/native'; -import { QueryClientProvider } from '@tanstack/react-query'; -import { - RenderAPI, - // eslint-disable-next-line no-restricted-imports - render as TestingLibraryRender, -} from '@testing-library/react-native'; -import { RequestHandler } from 'msw'; -import { ReactElement } from 'react'; -import Providers, { Provider } from 'src/components/Providers'; -import RootNavigator from 'src/navigators/RootNavigator'; -import queryClient from 'src/util/api/queryClient'; -import server from './server'; - -export type RenderOptions = { - mocks?: Array; -}; - -// TODO: this will become customized as the codebase progresses, so our -// tests can be wrapped with appropriate providers, mocks can be supplied, etc -export default function render( - element: ReactElement, - { mocks }: RenderOptions = {}, -): RenderAPI { - if (mocks) { - server.use(...mocks); - } - - const providers: Provider[] = [ - (children) => ( - {children} - ), - (children) => {children}, - // CODEGEN:BELT:PROVIDERS - do not remove - ]; - - return TestingLibraryRender( - {element}, - ); -} - -export function renderApplication(options: RenderOptions = {}) { - return render(, options); -}