├── .eslintrc.js ├── .gitignore ├── README.md ├── __playwright__ └── example.spec.ts ├── app ├── entry.client.tsx ├── entry.server.tsx ├── msw-handlers.ts ├── msw-server.ts ├── root.tsx └── routes │ └── index.tsx ├── package-lock.json ├── package.json ├── playwright.config.ts ├── public └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── scripts └── setup-playwright-test.sh ├── tests-examples └── demo-todo-app.spec.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | /test-results/ 8 | /playwright-report/ 9 | /playwright/.cache/ 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Demo: Using mswjs to create serverside & client side mocks + work on your app offline 2 | 3 | 4 | ### Demo Video 5 | https://user-images.githubusercontent.com/6743796/210154192-580e9f19-7be8-4f72-82a5-5152a7a6e313.mp4 6 | 7 | demo video showcases: 8 | - making an API call to example.com, and MSW intercepting the request and returning a response. 9 | - running playwright test suite, it runs my app as a user would, fetches data, but recieved the mocked responses from MSW 10 | 11 | ### Highlights 12 | - run your E2E/Integration test suite faster by using MSW inside of your node server 13 | - API responses are fast b/c data is local, no need to go to external machine etc 14 | - ability to share your handler code on the server or browser 15 | - MSW node initialization 16 | - in this app, all data fetching happens through the remix server, so this is why we initialize MSW there instead of our web browser 17 | - MSW browser initialization 18 | - you could use the same handlers in the browser & those would be useful if wanted to test a single component in isolation with data from an API (which MSW would return) 19 | - work entirely offline on your app with the help of MSW 20 | - Example: you're working with a 3rd party API & its down or you don't want to get rate limited, so you create an MSW handler to intercept requests to that particular URL 21 | - Example: you're going to travel & have no internet, so you create an MSW handler to prepare for offline coding 22 | 23 | 24 | ## Questions ❓❔ 25 | Feel free to ask questions in the discussion thread here to help folks in the community: 26 | https://github.com/remix-run/remix/discussions/4982 27 | 28 | 29 | 30 | ## Development 31 | From your terminal: 32 | 33 | ```sh 34 | npm run dev 35 | ``` 36 | 37 | This starts your app in development mode, rebuilding assets on file changes. 38 | 39 | 40 | ## Integration/E2E tests 41 | 42 | ``` 43 | # if you want to view playwright visually 44 | npm run playwright:visualize 45 | 46 | # if want to run playwright headlessly 47 | npm run playwright 48 | ``` 49 | 50 | Run the app in production mode: 51 | 52 | ```sh 53 | npm start 54 | ``` 55 | 56 | -------------------------------------------------------------------------------- /__playwright__/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('should render list of items', async ({ page }) => { 4 | await page.goto('http://localhost:3000/'); 5 | const listItemEl = page.getByText('Anna') 6 | expect(await listItemEl.textContent()).toEqual('Anna') 7 | 8 | // Expect a title "to contain" a substring. 9 | // await expect(page).toHaveTitle(/Playwright/); 10 | 11 | // // create a locator 12 | // const getStarted = page.getByRole('link', { name: 'Get started' }); 13 | 14 | // // Expect an attribute "to be strictly equal" to the value. 15 | // await expect(getStarted).toHaveAttribute('href', '/docs/intro'); 16 | 17 | // // Click the get started link. 18 | // await getStarted.click(); 19 | 20 | // // Expects the URL to contain intro. 21 | // await expect(page).toHaveURL(/.*intro/); 22 | }); 23 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { startTransition, StrictMode } from "react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | 5 | function hydrate() { 6 | startTransition(() => { 7 | hydrateRoot( 8 | document, 9 | 10 | 11 | 12 | ); 13 | }); 14 | } 15 | 16 | if (window.requestIdleCallback) { 17 | window.requestIdleCallback(hydrate); 18 | } else { 19 | // Safari doesn't support requestIdleCallback 20 | // https://caniuse.com/requestidlecallback 21 | window.setTimeout(hydrate, 1); 22 | } 23 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "stream"; 2 | import type { EntryContext } from "@remix-run/node"; 3 | import { Response } from "@remix-run/node"; 4 | import { RemixServer } from "@remix-run/react"; 5 | import isbot from "isbot"; 6 | import { renderToPipeableStream } from "react-dom/server"; 7 | import { startMockServer } from './msw-server' 8 | import { animalHandlers, peopleHandlers} from './msw-handlers' 9 | 10 | 11 | const ABORT_DELAY = 5000; 12 | 13 | // if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") { 14 | // Initialize MSW by uncommenting the line and handlers you wish to use below 15 | startMockServer([ 16 | ...peopleHandlers 17 | 18 | // ⬇️ Example of excluding out handlers for other areas of your app / APIs you're mocking ⬇️ 19 | // ...animalHandlers, 20 | ]); 21 | // } 22 | 23 | 24 | export default function handleRequest( 25 | request: Request, 26 | responseStatusCode: number, 27 | responseHeaders: Headers, 28 | remixContext: EntryContext 29 | ) { 30 | return isbot(request.headers.get("user-agent")) 31 | ? handleBotRequest( 32 | request, 33 | responseStatusCode, 34 | responseHeaders, 35 | remixContext 36 | ) 37 | : handleBrowserRequest( 38 | request, 39 | responseStatusCode, 40 | responseHeaders, 41 | remixContext 42 | ); 43 | } 44 | 45 | function handleBotRequest( 46 | request: Request, 47 | responseStatusCode: number, 48 | responseHeaders: Headers, 49 | remixContext: EntryContext 50 | ) { 51 | return new Promise((resolve, reject) => { 52 | let didError = false; 53 | 54 | const { pipe, abort } = renderToPipeableStream( 55 | , 56 | { 57 | onAllReady() { 58 | const body = new PassThrough(); 59 | 60 | responseHeaders.set("Content-Type", "text/html"); 61 | 62 | resolve( 63 | new Response(body, { 64 | headers: responseHeaders, 65 | status: didError ? 500 : responseStatusCode, 66 | }) 67 | ); 68 | 69 | pipe(body); 70 | }, 71 | onShellError(error: unknown) { 72 | reject(error); 73 | }, 74 | onError(error: unknown) { 75 | didError = true; 76 | 77 | console.error(error); 78 | }, 79 | } 80 | ); 81 | 82 | setTimeout(abort, ABORT_DELAY); 83 | }); 84 | } 85 | 86 | function handleBrowserRequest( 87 | request: Request, 88 | responseStatusCode: number, 89 | responseHeaders: Headers, 90 | remixContext: EntryContext 91 | ) { 92 | return new Promise((resolve, reject) => { 93 | let didError = false; 94 | 95 | const { pipe, abort } = renderToPipeableStream( 96 | , 97 | { 98 | onShellReady() { 99 | const body = new PassThrough(); 100 | 101 | responseHeaders.set("Content-Type", "text/html"); 102 | 103 | resolve( 104 | new Response(body, { 105 | headers: responseHeaders, 106 | status: didError ? 500 : responseStatusCode, 107 | }) 108 | ); 109 | 110 | pipe(body); 111 | }, 112 | onShellError(err: unknown) { 113 | reject(err); 114 | }, 115 | onError(error: unknown) { 116 | didError = true; 117 | 118 | console.error(error); 119 | }, 120 | } 121 | ); 122 | 123 | setTimeout(abort, ABORT_DELAY); 124 | }); 125 | } 126 | -------------------------------------------------------------------------------- /app/msw-handlers.ts: -------------------------------------------------------------------------------- 1 | import { rest } from "msw"; 2 | 3 | export const peopleHandlers = [ 4 | rest.get("https://example.com/people", ({ params }) => { 5 | const data = [{name: 'Anna'}, {name: 'Ben'}] 6 | return new Response(JSON.stringify(data), { 7 | headers: { 8 | "Content-Type": "application/json; charset=utf-8", 9 | }, 10 | status: 200, 11 | }); 12 | }), 13 | ] 14 | 15 | export const animalHandlers = [ 16 | rest.get("https://example.com/animals", ({ params }) => { 17 | const data = [{name: 'Buddy'}, {name: 'Scooby'}] 18 | return new Response(JSON.stringify(data), { 19 | headers: { 20 | "Content-Type": "application/json; charset=utf-8", 21 | }, 22 | status: 200, 23 | }); 24 | }), 25 | ] -------------------------------------------------------------------------------- /app/msw-server.ts: -------------------------------------------------------------------------------- 1 | import { type RequestHandler } from "msw"; 2 | import { setupServer, type SetupServerApi } from "msw/node"; 3 | 4 | /* 5 | During development, we need to save the instance of our MSW server in a global variable. 6 | Remix purges the 'require' cache on every request in development to support functionality from the server to the browser. 7 | To make sure our cache survives these purges during development, we need to assign it to the `global` object 8 | Details: https://stackoverflow.com/questions/72661999/how-do-i-use-in-memory-cache-in-remix-run-dev-mode 9 | */ 10 | declare global { 11 | var __MSW_SERVER: SetupServerApi | undefined; 12 | } 13 | 14 | export const setup = (handlers: RequestHandler[]): SetupServerApi => setupServer(...handlers); 15 | 16 | export const start = (server: SetupServerApi) => { 17 | server.listen({ onUnhandledRequest: "bypass" }); 18 | console.info("🔶 MSW mock server running..."); 19 | 20 | process.once("SIGINT", () => { 21 | globalThis.__MSW_SERVER = undefined; 22 | server.close(); 23 | }); 24 | process.once("SIGTERM", () => { 25 | globalThis.__MSW_SERVER = undefined; 26 | server.close(); 27 | }); 28 | }; 29 | 30 | const restart = (server: SetupServerApi, handlers: RequestHandler[]) => { 31 | server.close(); 32 | console.info("🔶 Shutting down MSW Mock Server..."); 33 | 34 | const _server = setup(handlers); 35 | globalThis.__MSW_SERVER = _server; 36 | 37 | console.info("🔶 Attempting to restart MSW Mock Server..."); 38 | start(_server); 39 | }; 40 | 41 | export const startMockServer = (handlers: RequestHandler[]) => { 42 | const IS_MSW_SERVER_RUNNING = globalThis.__MSW_SERVER !== undefined; 43 | 44 | if (IS_MSW_SERVER_RUNNING === false) { 45 | const server = setup(handlers); 46 | globalThis.__MSW_SERVER = server; 47 | start(server); 48 | } 49 | if (IS_MSW_SERVER_RUNNING) { 50 | const server = globalThis.__MSW_SERVER; 51 | if (server) { 52 | restart(server, handlers); 53 | } 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | import { 3 | Links, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | } from "@remix-run/react"; 10 | 11 | export const meta: MetaFunction = () => ({ 12 | charset: "utf-8", 13 | title: "New Remix App", 14 | viewport: "width=device-width,initial-scale=1", 15 | }); 16 | 17 | export default function App() { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | import { useLoaderData } from "@remix-run/react"; 4 | 5 | export async function loader({ request }:LoaderArgs) { 6 | const response = await fetch('https://example.com/people'); 7 | const data = await response.json() as Array<{ name: string }>; 8 | 9 | return json(data) 10 | } 11 | 12 | export default function Index() { 13 | const people = useLoaderData(); 14 | 15 | return ( 16 |
17 |

Welcome to Remix

18 |
    19 | {people.map((person, index) => { 20 | return
  • {person.name}
  • 21 | })} 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "scripts": { 5 | "build": "remix build", 6 | "dev": "remix dev", 7 | "start": "npm run build && remix-serve build", 8 | "playwright": "npm run playwright:prepare && playwright test && lsof -t -i:3000 && kill -9 $( lsof -i:3000 -t )", 9 | "playwright:visualize": "npm run playwright:prepare && PWDEBUG=1 playwright test --headed && lsof -t -i:3000 && kill -9 $( lsof -i:3000 -t )", 10 | "playwright:prepare": "./scripts/setup-playwright-test.sh", 11 | "typecheck": "tsc -b" 12 | }, 13 | "dependencies": { 14 | "@remix-run/node": "^1.13.0", 15 | "@remix-run/react": "^1.13.0", 16 | "@remix-run/serve": "^1.13.0", 17 | "isbot": "^3.6.5", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0" 20 | }, 21 | "devDependencies": { 22 | "@playwright/test": "^1.31.2", 23 | "@remix-run/dev": "^1.13.0", 24 | "@remix-run/eslint-config": "^1.13.0", 25 | "@types/react": "^18.0.26", 26 | "@types/react-dom": "^18.0.10", 27 | "eslint": "^8.32.0", 28 | "msw": "0.0.0-fetch.rc-3", 29 | "typescript": "^4.9.5", 30 | "zx": "^7.1.1" 31 | }, 32 | "engines": { 33 | "node": ">=17" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | import { devices } from '@playwright/test'; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: './__playwright__', 15 | /* Maximum time one test can run for. */ 16 | timeout: 30 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 0, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: 'html', 34 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 35 | use: { 36 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 37 | actionTimeout: 0, 38 | /* Base URL to use in actions like `await page.goto('/')`. */ 39 | // baseURL: 'http://localhost:3000', 40 | 41 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 42 | trace: 'on-first-retry', 43 | }, 44 | 45 | /* Configure projects for major browsers */ 46 | projects: [ 47 | { 48 | name: 'chromium', 49 | use: { 50 | ...devices['Desktop Chrome'], 51 | }, 52 | }, 53 | 54 | /* Test against mobile viewports. */ 55 | // { 56 | // name: 'Mobile Chrome', 57 | // use: { 58 | // ...devices['Pixel 5'], 59 | // }, 60 | // }, 61 | // { 62 | // name: 'Mobile Safari', 63 | // use: { 64 | // ...devices['iPhone 12'], 65 | // }, 66 | // }, 67 | 68 | /* Test against branded browsers. */ 69 | // { 70 | // name: 'Microsoft Edge', 71 | // use: { 72 | // channel: 'msedge', 73 | // }, 74 | // }, 75 | // { 76 | // name: 'Google Chrome', 77 | // use: { 78 | // channel: 'chrome', 79 | // }, 80 | // }, 81 | ], 82 | 83 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 84 | // outputDir: 'test-results/', 85 | 86 | /* Run your local dev server before starting the tests */ 87 | // webServer: { 88 | // // Spin up server, curl it entry.server is called & MSW is setup 89 | // command: 'npm run playwright:prepare', 90 | // port: 3000, 91 | // reuseExistingServer: true, 92 | // }, 93 | }; 94 | 95 | export default config; 96 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cliffordfajardo/remix-msw-node-with-playwright/33d97bdaf40d091dcece53cc4a4fd7cb43c4a94a/public/favicon.ico -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | module.exports = { 3 | ignoredRouteFiles: ["**/.*"], 4 | // appDirectory: "app", 5 | // assetsBuildDirectory: "public/build", 6 | // serverBuildPath: "build/index.js", 7 | // publicPath: "/build/", 8 | }; 9 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /scripts/setup-playwright-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Build the app before serving 4 | echo "Preparing to build app ...." 5 | npm run build; 6 | 7 | # Start up the app before having playwright visit it 8 | echo "Preparing to start app ...." 9 | npm run start & disown; 10 | 11 | sleep 6; 12 | echo "Preparing curl the app in order to trigger MSW mock server activation" 13 | curl --silent --output /dev/null http://localhost:3000; -------------------------------------------------------------------------------- /tests-examples/demo-todo-app.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, type Page } from '@playwright/test'; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('https://demo.playwright.dev/todomvc'); 5 | }); 6 | 7 | const TODO_ITEMS = [ 8 | 'buy some cheese', 9 | 'feed the cat', 10 | 'book a doctors appointment' 11 | ]; 12 | 13 | test.describe('New Todo', () => { 14 | test('should allow me to add todo items', async ({ page }) => { 15 | // create a new todo locator 16 | const newTodo = page.getByPlaceholder('What needs to be done?'); 17 | 18 | // Create 1st todo. 19 | await newTodo.fill(TODO_ITEMS[0]); 20 | await newTodo.press('Enter'); 21 | 22 | // Make sure the list only has one todo item. 23 | await expect(page.getByTestId('todo-title')).toHaveText([ 24 | TODO_ITEMS[0] 25 | ]); 26 | 27 | // Create 2nd todo. 28 | await newTodo.fill(TODO_ITEMS[1]); 29 | await newTodo.press('Enter'); 30 | 31 | // Make sure the list now has two todo items. 32 | await expect(page.getByTestId('todo-title')).toHaveText([ 33 | TODO_ITEMS[0], 34 | TODO_ITEMS[1] 35 | ]); 36 | 37 | await checkNumberOfTodosInLocalStorage(page, 2); 38 | }); 39 | 40 | test('should clear text input field when an item is added', async ({ page }) => { 41 | // create a new todo locator 42 | const newTodo = page.getByPlaceholder('What needs to be done?'); 43 | 44 | // Create one todo item. 45 | await newTodo.fill(TODO_ITEMS[0]); 46 | await newTodo.press('Enter'); 47 | 48 | // Check that input is empty. 49 | await expect(newTodo).toBeEmpty(); 50 | await checkNumberOfTodosInLocalStorage(page, 1); 51 | }); 52 | 53 | test('should append new items to the bottom of the list', async ({ page }) => { 54 | // Create 3 items. 55 | await createDefaultTodos(page); 56 | 57 | // create a todo count locator 58 | const todoCount = page.getByTestId('todo-count') 59 | 60 | // Check test using different methods. 61 | await expect(page.getByText('3 items left')).toBeVisible(); 62 | await expect(todoCount).toHaveText('3 items left'); 63 | await expect(todoCount).toContainText('3'); 64 | await expect(todoCount).toHaveText(/3/); 65 | 66 | // Check all items in one call. 67 | await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); 68 | await checkNumberOfTodosInLocalStorage(page, 3); 69 | }); 70 | }); 71 | 72 | test.describe('Mark all as completed', () => { 73 | test.beforeEach(async ({ page }) => { 74 | await createDefaultTodos(page); 75 | await checkNumberOfTodosInLocalStorage(page, 3); 76 | }); 77 | 78 | test.afterEach(async ({ page }) => { 79 | await checkNumberOfTodosInLocalStorage(page, 3); 80 | }); 81 | 82 | test('should allow me to mark all items as completed', async ({ page }) => { 83 | // Complete all todos. 84 | await page.getByLabel('Mark all as complete').check(); 85 | 86 | // Ensure all todos have 'completed' class. 87 | await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); 88 | await checkNumberOfCompletedTodosInLocalStorage(page, 3); 89 | }); 90 | 91 | test('should allow me to clear the complete state of all items', async ({ page }) => { 92 | const toggleAll = page.getByLabel('Mark all as complete'); 93 | // Check and then immediately uncheck. 94 | await toggleAll.check(); 95 | await toggleAll.uncheck(); 96 | 97 | // Should be no completed classes. 98 | await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); 99 | }); 100 | 101 | test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { 102 | const toggleAll = page.getByLabel('Mark all as complete'); 103 | await toggleAll.check(); 104 | await expect(toggleAll).toBeChecked(); 105 | await checkNumberOfCompletedTodosInLocalStorage(page, 3); 106 | 107 | // Uncheck first todo. 108 | const firstTodo = page.getByTestId('todo-item').nth(0); 109 | await firstTodo.getByRole('checkbox').uncheck(); 110 | 111 | // Reuse toggleAll locator and make sure its not checked. 112 | await expect(toggleAll).not.toBeChecked(); 113 | 114 | await firstTodo.getByRole('checkbox').check(); 115 | await checkNumberOfCompletedTodosInLocalStorage(page, 3); 116 | 117 | // Assert the toggle all is checked again. 118 | await expect(toggleAll).toBeChecked(); 119 | }); 120 | }); 121 | 122 | test.describe('Item', () => { 123 | 124 | test('should allow me to mark items as complete', async ({ page }) => { 125 | // create a new todo locator 126 | const newTodo = page.getByPlaceholder('What needs to be done?'); 127 | 128 | // Create two items. 129 | for (const item of TODO_ITEMS.slice(0, 2)) { 130 | await newTodo.fill(item); 131 | await newTodo.press('Enter'); 132 | } 133 | 134 | // Check first item. 135 | const firstTodo = page.getByTestId('todo-item').nth(0); 136 | await firstTodo.getByRole('checkbox').check(); 137 | await expect(firstTodo).toHaveClass('completed'); 138 | 139 | // Check second item. 140 | const secondTodo = page.getByTestId('todo-item').nth(1); 141 | await expect(secondTodo).not.toHaveClass('completed'); 142 | await secondTodo.getByRole('checkbox').check(); 143 | 144 | // Assert completed class. 145 | await expect(firstTodo).toHaveClass('completed'); 146 | await expect(secondTodo).toHaveClass('completed'); 147 | }); 148 | 149 | test('should allow me to un-mark items as complete', async ({ page }) => { 150 | // create a new todo locator 151 | const newTodo = page.getByPlaceholder('What needs to be done?'); 152 | 153 | // Create two items. 154 | for (const item of TODO_ITEMS.slice(0, 2)) { 155 | await newTodo.fill(item); 156 | await newTodo.press('Enter'); 157 | } 158 | 159 | const firstTodo = page.getByTestId('todo-item').nth(0); 160 | const secondTodo = page.getByTestId('todo-item').nth(1); 161 | const firstTodoCheckbox = firstTodo.getByRole('checkbox'); 162 | 163 | await firstTodoCheckbox.check(); 164 | await expect(firstTodo).toHaveClass('completed'); 165 | await expect(secondTodo).not.toHaveClass('completed'); 166 | await checkNumberOfCompletedTodosInLocalStorage(page, 1); 167 | 168 | await firstTodoCheckbox.uncheck(); 169 | await expect(firstTodo).not.toHaveClass('completed'); 170 | await expect(secondTodo).not.toHaveClass('completed'); 171 | await checkNumberOfCompletedTodosInLocalStorage(page, 0); 172 | }); 173 | 174 | test('should allow me to edit an item', async ({ page }) => { 175 | await createDefaultTodos(page); 176 | 177 | const todoItems = page.getByTestId('todo-item'); 178 | const secondTodo = todoItems.nth(1); 179 | await secondTodo.dblclick(); 180 | await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); 181 | await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); 182 | await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); 183 | 184 | // Explicitly assert the new text value. 185 | await expect(todoItems).toHaveText([ 186 | TODO_ITEMS[0], 187 | 'buy some sausages', 188 | TODO_ITEMS[2] 189 | ]); 190 | await checkTodosInLocalStorage(page, 'buy some sausages'); 191 | }); 192 | }); 193 | 194 | test.describe('Editing', () => { 195 | test.beforeEach(async ({ page }) => { 196 | await createDefaultTodos(page); 197 | await checkNumberOfTodosInLocalStorage(page, 3); 198 | }); 199 | 200 | test('should hide other controls when editing', async ({ page }) => { 201 | const todoItem = page.getByTestId('todo-item').nth(1); 202 | await todoItem.dblclick(); 203 | await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); 204 | await expect(todoItem.locator('label', { 205 | hasText: TODO_ITEMS[1], 206 | })).not.toBeVisible(); 207 | await checkNumberOfTodosInLocalStorage(page, 3); 208 | }); 209 | 210 | test('should save edits on blur', async ({ page }) => { 211 | const todoItems = page.getByTestId('todo-item'); 212 | await todoItems.nth(1).dblclick(); 213 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); 214 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); 215 | 216 | await expect(todoItems).toHaveText([ 217 | TODO_ITEMS[0], 218 | 'buy some sausages', 219 | TODO_ITEMS[2], 220 | ]); 221 | await checkTodosInLocalStorage(page, 'buy some sausages'); 222 | }); 223 | 224 | test('should trim entered text', async ({ page }) => { 225 | const todoItems = page.getByTestId('todo-item'); 226 | await todoItems.nth(1).dblclick(); 227 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); 228 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); 229 | 230 | await expect(todoItems).toHaveText([ 231 | TODO_ITEMS[0], 232 | 'buy some sausages', 233 | TODO_ITEMS[2], 234 | ]); 235 | await checkTodosInLocalStorage(page, 'buy some sausages'); 236 | }); 237 | 238 | test('should remove the item if an empty text string was entered', async ({ page }) => { 239 | const todoItems = page.getByTestId('todo-item'); 240 | await todoItems.nth(1).dblclick(); 241 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); 242 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); 243 | 244 | await expect(todoItems).toHaveText([ 245 | TODO_ITEMS[0], 246 | TODO_ITEMS[2], 247 | ]); 248 | }); 249 | 250 | test('should cancel edits on escape', async ({ page }) => { 251 | const todoItems = page.getByTestId('todo-item'); 252 | await todoItems.nth(1).dblclick(); 253 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); 254 | await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); 255 | await expect(todoItems).toHaveText(TODO_ITEMS); 256 | }); 257 | }); 258 | 259 | test.describe('Counter', () => { 260 | test('should display the current number of todo items', async ({ page }) => { 261 | // create a new todo locator 262 | const newTodo = page.getByPlaceholder('What needs to be done?'); 263 | 264 | // create a todo count locator 265 | const todoCount = page.getByTestId('todo-count') 266 | 267 | await newTodo.fill(TODO_ITEMS[0]); 268 | await newTodo.press('Enter'); 269 | 270 | await expect(todoCount).toContainText('1'); 271 | 272 | await newTodo.fill(TODO_ITEMS[1]); 273 | await newTodo.press('Enter'); 274 | await expect(todoCount).toContainText('2'); 275 | 276 | await checkNumberOfTodosInLocalStorage(page, 2); 277 | }); 278 | }); 279 | 280 | test.describe('Clear completed button', () => { 281 | test.beforeEach(async ({ page }) => { 282 | await createDefaultTodos(page); 283 | }); 284 | 285 | test('should display the correct text', async ({ page }) => { 286 | await page.locator('.todo-list li .toggle').first().check(); 287 | await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); 288 | }); 289 | 290 | test('should remove completed items when clicked', async ({ page }) => { 291 | const todoItems = page.getByTestId('todo-item'); 292 | await todoItems.nth(1).getByRole('checkbox').check(); 293 | await page.getByRole('button', { name: 'Clear completed' }).click(); 294 | await expect(todoItems).toHaveCount(2); 295 | await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); 296 | }); 297 | 298 | test('should be hidden when there are no items that are completed', async ({ page }) => { 299 | await page.locator('.todo-list li .toggle').first().check(); 300 | await page.getByRole('button', { name: 'Clear completed' }).click(); 301 | await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); 302 | }); 303 | }); 304 | 305 | test.describe('Persistence', () => { 306 | test('should persist its data', async ({ page }) => { 307 | // create a new todo locator 308 | const newTodo = page.getByPlaceholder('What needs to be done?'); 309 | 310 | for (const item of TODO_ITEMS.slice(0, 2)) { 311 | await newTodo.fill(item); 312 | await newTodo.press('Enter'); 313 | } 314 | 315 | const todoItems = page.getByTestId('todo-item'); 316 | const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); 317 | await firstTodoCheck.check(); 318 | await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); 319 | await expect(firstTodoCheck).toBeChecked(); 320 | await expect(todoItems).toHaveClass(['completed', '']); 321 | 322 | // Ensure there is 1 completed item. 323 | await checkNumberOfCompletedTodosInLocalStorage(page, 1); 324 | 325 | // Now reload. 326 | await page.reload(); 327 | await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); 328 | await expect(firstTodoCheck).toBeChecked(); 329 | await expect(todoItems).toHaveClass(['completed', '']); 330 | }); 331 | }); 332 | 333 | test.describe('Routing', () => { 334 | test.beforeEach(async ({ page }) => { 335 | await createDefaultTodos(page); 336 | // make sure the app had a chance to save updated todos in storage 337 | // before navigating to a new view, otherwise the items can get lost :( 338 | // in some frameworks like Durandal 339 | await checkTodosInLocalStorage(page, TODO_ITEMS[0]); 340 | }); 341 | 342 | test('should allow me to display active items', async ({ page }) => { 343 | const todoItem = page.getByTestId('todo-item'); 344 | await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); 345 | 346 | await checkNumberOfCompletedTodosInLocalStorage(page, 1); 347 | await page.getByRole('link', { name: 'Active' }).click(); 348 | await expect(todoItem).toHaveCount(2); 349 | await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); 350 | }); 351 | 352 | test('should respect the back button', async ({ page }) => { 353 | const todoItem = page.getByTestId('todo-item'); 354 | await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); 355 | 356 | await checkNumberOfCompletedTodosInLocalStorage(page, 1); 357 | 358 | await test.step('Showing all items', async () => { 359 | await page.getByRole('link', { name: 'All' }).click(); 360 | await expect(todoItem).toHaveCount(3); 361 | }); 362 | 363 | await test.step('Showing active items', async () => { 364 | await page.getByRole('link', { name: 'Active' }).click(); 365 | }); 366 | 367 | await test.step('Showing completed items', async () => { 368 | await page.getByRole('link', { name: 'Completed' }).click(); 369 | }); 370 | 371 | await expect(todoItem).toHaveCount(1); 372 | await page.goBack(); 373 | await expect(todoItem).toHaveCount(2); 374 | await page.goBack(); 375 | await expect(todoItem).toHaveCount(3); 376 | }); 377 | 378 | test('should allow me to display completed items', async ({ page }) => { 379 | await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); 380 | await checkNumberOfCompletedTodosInLocalStorage(page, 1); 381 | await page.getByRole('link', { name: 'Completed' }).click(); 382 | await expect(page.getByTestId('todo-item')).toHaveCount(1); 383 | }); 384 | 385 | test('should allow me to display all items', async ({ page }) => { 386 | await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); 387 | await checkNumberOfCompletedTodosInLocalStorage(page, 1); 388 | await page.getByRole('link', { name: 'Active' }).click(); 389 | await page.getByRole('link', { name: 'Completed' }).click(); 390 | await page.getByRole('link', { name: 'All' }).click(); 391 | await expect(page.getByTestId('todo-item')).toHaveCount(3); 392 | }); 393 | 394 | test('should highlight the currently applied filter', async ({ page }) => { 395 | await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); 396 | 397 | //create locators for active and completed links 398 | const activeLink = page.getByRole('link', { name: 'Active' }); 399 | const completedLink = page.getByRole('link', { name: 'Completed' }); 400 | await activeLink.click(); 401 | 402 | // Page change - active items. 403 | await expect(activeLink).toHaveClass('selected'); 404 | await completedLink.click(); 405 | 406 | // Page change - completed items. 407 | await expect(completedLink).toHaveClass('selected'); 408 | }); 409 | }); 410 | 411 | async function createDefaultTodos(page) { 412 | // create a new todo locator 413 | const newTodo = page.getByPlaceholder('What needs to be done?'); 414 | 415 | for (const item of TODO_ITEMS) { 416 | await newTodo.fill(item); 417 | await newTodo.press('Enter'); 418 | } 419 | } 420 | 421 | async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { 422 | return await page.waitForFunction(e => { 423 | return JSON.parse(localStorage['react-todos']).length === e; 424 | }, expected); 425 | } 426 | 427 | async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { 428 | return await page.waitForFunction(e => { 429 | return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e; 430 | }, expected); 431 | } 432 | 433 | async function checkTodosInLocalStorage(page: Page, title: string) { 434 | return await page.waitForFunction(t => { 435 | return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t); 436 | }, title); 437 | } 438 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "allowJs": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | 19 | // Remix takes care of building everything in `remix build`. 20 | "noEmit": true 21 | } 22 | } 23 | --------------------------------------------------------------------------------