├── .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 |
--------------------------------------------------------------------------------