diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..54c6511 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +v24 diff --git a/exercises/01.fundamentals/02.problem.running-the-app/README.mdx b/exercises/01.fundamentals/02.problem.running-the-app/README.mdx index 0dbed98..91d693d 100644 --- a/exercises/01.fundamentals/02.problem.running-the-app/README.mdx +++ b/exercises/01.fundamentals/02.problem.running-the-app/README.mdx @@ -1,11 +1,13 @@ # Running the app +Alright, so you've got your first end-to-end test written, but it doesn't bring you much value yet (well, because Epic Web isn't the app you want to test!). Now is the time we change that. + ## Your task -πŸ‘¨β€πŸ’Ό It's time for us to start testing _our_ app! But there's a couple of things you have to do before that. +πŸ‘¨β€πŸ’Ό Now is your turn to start testing _your_ app. But before you do that, you need to make sure Playwright runs it before your tests so they have something to interact with. -🐨 First, let's make sure that Playwright runs your application before it runs your tests. Head to `playwright.config.ts` and follow the instructions to configure the `webServer` property in Playwright's configuration. +🐨 First, head to `playwright.config.ts` and follow the instructions to configure the `webServer` property in Playwright's configuration. You will use that option to tell Playwright how to spawn your application. -🐨 Now that `webServer` is configured, it's time to write some tests! Let's start from testing the homepage of the app. I've already got a test file prepared for you at `tests/e2e/homepage.test.ts`. Open that file and write a test case there. +🐨 Next, once `webServer` is configured, write a simple test for the homepage at `tests/e2e/homepage.test.ts`. It will be quite similar to the test you've written in the previous exercise, but now interacting and asserting directly on your application. -Once you're done, make sure that the added test is passing by running the `npm test` command in your terminal. +And, of course, verify your solution by running the tests (`npm run test:e2e`) and seeing them pass. diff --git a/exercises/01.fundamentals/02.solution.running-the-app/README.mdx b/exercises/01.fundamentals/02.solution.running-the-app/README.mdx index 93ff3bd..03734fa 100644 --- a/exercises/01.fundamentals/02.solution.running-the-app/README.mdx +++ b/exercises/01.fundamentals/02.solution.running-the-app/README.mdx @@ -1,21 +1,79 @@ # Running the app -## Summary +## The Epic Stack -1. Introduce to the Epic Stack app, briefly. +You have noticed that things have changed in your playground directory. That's because we have an entire Epic Stack application at our disposal now. It includes React components, routes, server handlers, database schemas... That is a _lot!_ But you don't have to know it in-and-out to test it efficiently on an end-to-end level. Those implementation details vanish entirely, and all that's left is a _real_ application that you will be interacting with inside your tests. ---- +But first, we need to tell Playwright to run it. -1. Changes in `playwright.config.ts`, mainly: - 1. `PORT` at which the app will be running. `use.baseURL` to we can use relative URLs in our tests. - 1. The `webServer` option to spawn our app and wait at the given `PORT`. - 1. `reuseExistingServer` allows us to use an already running app locally for faster tests. - 1. `testDir` set to `./tests/e2e` since our app now also has other types of tests. +## Configuring `webServer` ---- +In `playwright.config.ts`, let's add a property `webServer` to our Playwright configuration, which will describe how our application should be spawned for testing. -1. New test at `tests/e2e/homepage.test.ts`. We need to test our app, not running `epicweb.dev`. -1. The basic structure of the test remains the same though: - 1. Visit the homepage `/` (since we enabled relative URLs). - 1. Assert that the heading text is visible to the user. -1. Verify the test via `npm run test:e2e`. +```ts filename=playwright.config.ts add=4-14 +export default defineConfig({ + // ... + + webServer: { + command: process.env.CI ? 'npm run start:mocks' : 'npm run dev', + port: Number(PORT), + reuseExistingServer: true, + stdout: 'pipe', + stderr: 'pipe', + env: { + PORT, + NODE_ENV: 'test', + }, + }, +}) +``` + +Here's what we are doing step-by-step: + +- `command` lists the exact command Playwright will run to spawn our app. Here, we are making it conditional, using `npm run start:mocks` on CI and `npm run dev` for local testing; +- `port` is the port number where Playwright will expect the application to occupy. Once it sees something is running at that port, it will consider the application successfully spawned; +- `reuseExistingServer` tells Playwright to reuse an application on the given `port` if it's already running. This is handy when running our end-to-end tests during development so we don't have to spawn _another_ app instance; +- `stdout` and `stderr` are the quality-of-life options that `'pipe'` the standard output and errors, respectively, from the application's process to the test runner's process so we could observe them before, during, and after the test run; +- `env`, as the name suggests, lists the environment variables we can provide to the application: + - `PORT` to spawn the application at the same port that Playwright expects it on; + - `NODE_ENV` as a flag that tells our app that it's being run for testing purposes. + +You can provide an array of web server configurations in the `webServer` option for Playwright to spawn _them all in parallel_ before the test run. + +## Writing tests + +From the test's perspective, not much will change. We still want to navigate to the page we want (the homepage), find the heading element, and make sure that it's visible to the user. + +```ts filename=tests/e2e/homepage.test.ts +import { test, expect } from '@playwright/test' + +test('displays the welcome heading', async ({ page }) => { + await page.goto('/') + + await expect( + page.getByRole('heading', { name: 'The Epic Stack' }), + ).toBeVisible() +}) +``` + +Notice that I'm providing a _relative_ path to the homepage in `page.goto('/')`. How does Playwright know what to resolve it relatively to? It doesn't! I told it in `playwright.config.ts`: + +```ts filename=playwright.config.ts add=4-6 +export default defineConfig({ + // ... + + use: { + baseURL: `http://localhost:${PORT}/`, + }, +}) +``` + +Playwright resolves any relative URLs against `use.baseURL`, if provided. + +## Running the test + +Finally, let's run the test to verify that our application is spawned correctly and all the instructions we have in our test run against that spawned instance. + +``` +npm run test:e2e +``` diff --git a/exercises/01.fundamentals/03.problem.custom-fixtures/README.mdx b/exercises/01.fundamentals/03.problem.custom-fixtures/README.mdx index 430e5e7..b704d5e 100644 --- a/exercises/01.fundamentals/03.problem.custom-fixtures/README.mdx +++ b/exercises/01.fundamentals/03.problem.custom-fixtures/README.mdx @@ -1,11 +1,25 @@ # Custom fixtures +One thing you will do in every end-to-end testβ€”and do quite oftenβ€”is navigating between pages. As such, it would be great if you could catch mistakes in navigation before you run your tests. + +For example, when making a typo in the page's URL: + +```ts +await page.goto('/hoempage') +``` + +> If you're a fast typer like me, you know how painful this is πŸ˜”. + +While this certainly will fail your test, the error you'll get will be a _result_ of the mistake (navigation timeout, failed to find elements, timing out assertions), not **the mistake itself**. + +We can do better here. We can make such mistakes produce a _type error_, notifying us way before we run the tests. We can have **type-safe routing** in our tests! + ## Your task -πŸ‘¨β€πŸ’Ό In this one, you are going to implement a custom fixture called `navigate`. The purpose of this fixture is to make page navigations in tests _type-safe_! You will use the types generated by React Router to achieve that. +πŸ‘¨β€πŸ’Ό To achieve type-safe routing, you are going to implement a custom fixture called `navigate`. The purpose of this fixture is to _infer the route types_ from React Router and, as a result, make every page navigation in your tests type-safe. 🐨 Start by opening the `tests/test-extend.ts` file I've created for you. Follow the steps in that file to implement the `navigate` fixture. 🐨 Once the fixture is ready, refactor the `tests/e2e/homepage.test.ts` test suite to use the newly created `navigate()` fixture. -And, of course, verify that the tests are passing in the end by running `npm test`. Good luck! +And, of course, verify that the tests are passing in the end by running `npm test:e2e`. Good luck! diff --git a/exercises/01.fundamentals/03.solution.custom-fixtures/README.mdx b/exercises/01.fundamentals/03.solution.custom-fixtures/README.mdx index 37778b7..cdbb14a 100644 --- a/exercises/01.fundamentals/03.solution.custom-fixtures/README.mdx +++ b/exercises/01.fundamentals/03.solution.custom-fixtures/README.mdx @@ -1,9 +1,123 @@ # Custom fixtures -## Summary +## Type-safe navigation -1. Implement the new `navigate` fixture in `tests/text-extend.ts`. Use generated type definitions from React Router for type-safe navigation in tests. Build the app to generate the type definitions. -1. Change the existing test at `tests/e2e/homepage.test.ts` to use the new `navigate` fixture instead of `page`. Notice the route suggestions! Neat. +It's important to understand that type-safe navigation is not a testing problem. It's an app problem. Just as you can make a mistake by visiting a non-existing page in your test, you can do something much worseβ€”include a link to a non-exsisting page in your app and break your users' experience: + +```tsx + +``` + +This is why every major web framework nowadays comes with type-safe routing. The latter is achieved by _generating type definitions_ for your routes and annotating your navigation-facing APIs, like `` or `href()`, with them. + +In React Router, those type definitions are generated at `.react-router/types/+routes.ts`: + +```ts filename=.react-router/types/+routes.ts +type Pages = { + '/': { + params: {} + } + '/messages/:id': { + params: { + id: string + } + } +} +``` + +We are going to benefit from these generated types in our tests, too! + +## Creating a fixture + +Rightfully, Playwright doesn't know what web framework you're using, if you're using one at all. It has no idea about the type definitions it generates. This is where we have to _teach it_, and we will do so by creating our custom fixture called `navigate()` that will replace `page.goto()` for navigations within our app. + +Create a file at `./tests/test-extend.ts` and declare a `Fixtures` interface in it. There, describe the `navigate` key with the type of a function mirroring the `href()` from `react-router`: + +```ts filename=tests/test-extend.ts highlight=4-6 +import { href, type Register } from 'react-router' + +interface Fixtures { + navigate: ( + ...args: Parameters> + ) => Promise +} +``` + +> Here, we are using a _type argument_ `T` to infer all the keys (pathnames) from `Register['pages']` type in React Router and provide them to the `href` parameters so the call signature of `navigate()` is the same as that of `href()`. + +Implementing a custom fixture means _extending the default `test()` function_ in Playwright. So let's import it first and call `.extend()` on it, assigning the result into a variable called `test` and exporting it: + +```ts filename=tests/test-extend.ts add=1,10-16 +import { test as testBase } from '@playwright/test' +import { href, type Register } from 'react-router' + +interface Fixtures { + navigate: ( + ...args: Parameters> + ) => Promise +} + +export const test = testBase.extend({ + navigate: async ({ page }, use) => { + await use(async (...args) => { + await page.goto(href(...args)) + }) + }, +}) +``` + +Playwright fixtures follow a similar pattern as Vitest fixtures (that's not a coincidence): + +```ts +{ + [fixtureName]: (context, use) => {} +} +``` + +In our navigate fixture, we are taking the `page` object from the test context and tapping into `page.goto()` to trigger the actual navigation in the test. The key here is that we are also using the `href()` function from `react-router` to actually _build_ the end URL we are naviugating to: + +```ts +await page.goto(href(...args)) +``` + +You can visualize the function invocation here like this: + +``` +navigate('/messages/:id', { id: '1' }) + β†’ href('/messages/:id', { id: '1'} ) // "/messages/1" + β†’ page.goto('/messages/1') +``` + +Lastly, because we are going to be importing `test` from our `test-extend.ts` from now on, it would be great to also re-export the `expect` function for consistent and ergonomic imports in our tests: + +```ts filename=tests/test-extend.ts add=1,5 +import { test as testBase, expect } from '@playwright/test' + +// ... + +export { expect } +``` + +## Using custom fixtures + +Both built-in and custom fixtures in Playwright are accessed via the test context. What makes the custom fixtures work is that we are _replacing_ the default `test` function from `@playwright/test` with the one extended with our fixtures. + +```ts filename=tests/e2e/homepage.test.ts remove=1,4,6 add=2,5,7 +import { test, expect } from '@playwright/test' +import { test, expect } from '#tests/test-extend.ts' + +test('displays the welcome heading', async ({ page }) => { +test('displays the welcome heading', async ({ page, navigate }) => { + await page.goto('/') + await navigate('/') + + await expect( + page.getByRole('heading', { name: 'The Epic Stack' }), + ).toBeVisible() +}) +``` + +> Note that while it's technically possible to extend built-in fixtures, like `page.goto()`, you have to be mindful about the scope of your customization. Not every `page.goto()` call is a navigation within our app, but every `navigate()` call is. ## Related materials diff --git a/exercises/02.authentication/01.problem.basic/README.mdx b/exercises/02.authentication/01.problem.basic/README.mdx index 4113413..b00bd68 100644 --- a/exercises/02.authentication/01.problem.basic/README.mdx +++ b/exercises/02.authentication/01.problem.basic/README.mdx @@ -1,3 +1,23 @@ # Basic -- Testing a basic (email+password) authentication. +The most common authentication method you can find on the web is the one using email and password. This means that it's extremely likely to appear in your own apps in one form or another, which, in turn, makes it something you have to be comfortable with testing. + +## Your task + +πŸ‘¨β€πŸ’Ό Here's your goal for this exercise: cover the basic authentication feature of the Epic Stack app with end-to-end tests (a happy path and a single error handling scenario will suffice). But before you can get to writing those, you have to _set things up_. + +🐨 In `tests/db-utils.ts`, follow the instructions to implement a `createUser()` helper utility. It will come in handy for creating test users in the database so you have the credentials to authenticate with during your test. + +🐨 Next, head to `tests/e2e/authentication-basic.test.ts` and write the first test case for a successful authentication flow. It will consist of: + +1. Using the `createUser()` utility to get test credentials; +1. Navigating to the Login page; +1. Filling in the login form with the said credentials; +1. Submitting the form; +1. And, finally, asserting that authentication-dependent UI is visible on the page. + +Verify your test as passing by running `npm run test:e2e`. + +🐨 Finally, add another test case in the same test file for the error handling during the authentication flow. To trigger it, simply try logging in with non-existing credentials and assert that a meaningful error message is presented to the user on the page. + +See you once you're done! diff --git a/exercises/02.authentication/01.solution.basic/README.mdx b/exercises/02.authentication/01.solution.basic/README.mdx index 1f1008f..b24cf46 100644 --- a/exercises/02.authentication/01.solution.basic/README.mdx +++ b/exercises/02.authentication/01.solution.basic/README.mdx @@ -1,25 +1,111 @@ # Basic -- Testing a basic (email+password) authentication. +## Implementing `createUser()` -## Summary +If we want to test authentication, we need to have some user credentials to authenticate with. This is where the `createUser()` utility function comes in as it will create a test user in the database and expose its credentials in the test. -1. In `db-utils,` implement the `createUser` utility, following the steps. -1. Mention the `asyncDispose` for clean up of the created user. Handy! -1. `createUser` creates a _test_ user. You might've heard that "test users" should be avoided. Talk about the difference between the two (we aren't creating a user that acts and asserts on internals; "test user" simply means an actual user record we created for test purposes). +```ts filename=tests/db-utils.ts +export async function createUser() { + const userInfo = generateUserInfo() + const password = 'supersecret' + const user = await prisma.user.create({ + data: { + ...userInfo, + password: { create: { hash: await getPasswordHash(password) } }, + }, + }) ---- + return { + async [Symbol.asyncDispose]() { + await prisma.user.deleteMany({ + where: { id: user.id }, + }) + }, + ...user, + password, + } +} +``` -1. Create new test at `tests/e2e/authentication-basic.test.ts`. -1. Create a new test case for successful authentication using email and password. -1. In the test, use the newly created `createUser()` utility, which generates a new user and creates it in the database. -1. Log in by filling the log in form as the user would. -1. Assert that the authentication is successful based on the user profile link being visible on the screen. -1. `npm run test:e2e`. +We are relying on the existing `generateUserInfo()` function to generate a `username` and `email`, providing a hard-coded password, and using the Prisma client to create the actual user record in the database. ---- +The way you approach creating test users will depend on your application. -1. Another test case in the same file focused on displaying an error when authentication fails. -1. Similar steps but _no setup_ so the user doesn't exist. -1. Assert the error message being shown (correct _role_ and text). -1. `npm run test:e2e`. +Notice that apart from `user` and `password` our utility also returns a `Symbol.asyncDispose` function. That function marks the entire returned object as _disposable_, which we can leverage to have the cleanup (removing the test user) tied to when this function (and its surrounding scope) gets garbage collected. + +:scroll: [Disposable objects](https://www.epicweb.dev/better-test-setup-with-disposable-objects) are extremely handy for the test setup as they allow for collocation of the setup and cleanup logic. Do not sleep on them! + +## Fixtures vs utilities + +Okay, you are probably wondering: _Why isn't `createUser()` a fixture instead_? + +That's a great question. The truth is, it _can_ be a fixture, and nothing is stopping it from being one. As we've previously established, Playwright tests run in Node.js, which means our fixtures can tap into Node.js APIs and third-party packages relying on those. + +But I didn't make `createUser()` into a Playwright fixture for a reason. See, when it comes to deciding between custom fixtures and plain functions, I follow a simple logic: + +- If my utility function depends on the browser APIs or the test context, I make it a fixture; +- If my utility function has nothing to do with the browser or Playwright, I keep it a separate function. + +Think back on our `navigate()` fixture. Its purpose is type-safety but its functionality is tightly coupled with the actual page navigation. Playwright already has an API for thatβ€”`page.goto()`. It would be rather cumbersome for me to trigger a page navigation from outside Playwright's context. So I make `navigate()` a fixture because it _directly benefits_ from easier access to Playwright APIs. + +`createUser()` though, not so much. It's a data seeding utility at its heart and has no dependency on Playwright or the browser whatsoever. As such, it doesn't belong in the fixtures. + +Keep in mind that custom fixtures aren't free. By their very design, they are tightly coupled with Playwright, and Playwright dictates the way they are declared and used. This is not a matter of micro-optimization but instead a conscious design decision for your test setup to benefit from. + +## Testing authentication + +Equipped with our fixtures and utilities, we can now write the first test case for the authentication flow. + +```ts filename=tests/e2e/authentication-basic.test.ts highlight=5,9-10,13 +import { createUser } from '#tests/db-utils.ts' +import { test, expect } from '#tests/test-extend.ts' + +test('authenticates using a email and password', async ({ navigate, page }) => { + await using user = await createUser() + + await navigate('/login') + + await page.getByLabel('Username').fill(user.username) + await page.getByLabel('Password').fill(user.password) + await page.getByRole('button', { name: 'Log in' }).click() + + await expect(page.getByRole('link', { name: user.name! })).toBeVisible() +}) +``` + +Testing authentication is no different from testing anything else on the page. We prepare our application (by creating a test user), visit the login page the user would visit, interact with it in the same manner, and expect UI elements confirming a successful (or failed) authentication that the user would expect. + +### Testing authentication failures + +Testing failure flows of any kind ties our test to the nature of that failure. The simplest way to illustrate that is to add a test case for a failed authentication flow due to the missing/invalid user credentials: + +```ts filename=tests/e2e/authentication-basic.test.ts highlight=7,8,11-13 +test('displays an error message when authenticating with invalid credentials', async ({ + navigate, + page, +}) => { + await navigate('/login') + + await page.getByLabel('Username').fill('non_existing_user') + await page.getByLabel('Password').fill('non_existing_password') + await page.getByRole('button', { name: 'Log in' }).click() + + await expect( + page.getByRole('alert').getByText('Invalid username or password'), + ).toBeVisible() +}) +``` + +From the server's perspective, there is no distinction between missing or invalid credentials, so all we have to do to set up our application for this particular scenario is... nothing. We are not creating any test users because we don't need them. + +The most difficult part of arranging an end-to-end test is to understand the pieces involved in the behavior you're testing. Your test setup addresses the application side of it, tackling its complexity, while your test actions/assertions focus on the user-facing side. + +## Running the tests + +``` +npm run test:e2e +``` + +## Related materials + +- [Better Test Setup with Disposable Objects](https://www.epicweb.dev/better-test-setup-with-disposable-objects) diff --git a/exercises/02.authentication/01.solution.basic/tests/db-utils.ts b/exercises/02.authentication/01.solution.basic/tests/db-utils.ts index 62543fe..78a590d 100644 --- a/exercises/02.authentication/01.solution.basic/tests/db-utils.ts +++ b/exercises/02.authentication/01.solution.basic/tests/db-utils.ts @@ -53,38 +53,6 @@ export async function createUser() { } } -export async function createPasskey(input: { - id: string - userId: string - aaguid: string - publicKey: Uint8Array - counter?: number -}) { - const passkey = await prisma.passkey.create({ - data: { - id: input.id, - aaguid: input.aaguid, - userId: input.userId, - publicKey: input.publicKey, - backedUp: false, - webauthnUserId: input.userId, - deviceType: 'singleDevice', - counter: input.counter || 0, - }, - }) - - return { - async [Symbol.asyncDispose]() { - await prisma.passkey.deleteMany({ - where: { - id: passkey.id, - }, - }) - }, - ...passkey, - } -} - export function createPassword(password: string = faker.internet.password()) { return { hash: bcrypt.hashSync(password, 10), diff --git a/exercises/02.authentication/02.problem.2fa/README.mdx b/exercises/02.authentication/02.problem.2fa/README.mdx index 8c6fb0b..69eae69 100644 --- a/exercises/02.authentication/02.problem.2fa/README.mdx +++ b/exercises/02.authentication/02.problem.2fa/README.mdx @@ -1,7 +1,13 @@ # Two-factor authentication +Two-factor authentication (2FA) is a common addition to any authentication flow. It exists as a separate, additional layer of identity verification not only to supplement authentication but also act as a guard for sensitive and destructive actions of already authenticated users. You've likely seen it in action in both of those ways. + +And now you're going to test it yourself. + ## Your task -- Write the `tests/e2e/authentication-2fa.test.ts`. -- Implement a `createVerification()` utility. -- Use `@epic-web/totp` to generate the OTP in the test case. +πŸ‘¨β€πŸ’Ό In this one, follow the instructions in `tests/e2e/authentication-2fa.test.ts` to write an end-to-end test for the two-factor authentication flow in Epic Stack. + +🐨 Then, in addition to the `createUser()` utility you've prepared earlier, create another utility called `createVerification()` that would collocate a given user with a One-Time Password (OTP), storing that relationship in the database. + +🐨 And, finally, log in with the test user and fill in the OTP input that will appear now as a part of the authentication attempt. As always, have the test passing. diff --git a/exercises/02.authentication/02.problem.2fa/tests/db-utils.ts b/exercises/02.authentication/02.problem.2fa/tests/db-utils.ts index 3b58c39..ece131e 100644 --- a/exercises/02.authentication/02.problem.2fa/tests/db-utils.ts +++ b/exercises/02.authentication/02.problem.2fa/tests/db-utils.ts @@ -59,21 +59,20 @@ export async function createVerification(input: { userId: string }) { const { otp, ...totpConfig } = input.totp - const verification = await prisma.verification.create({ - data: { - ...totpConfig, - type: '2fa', - target: input.userId, - }, - }) + + // 🐨 Create a new 2FA verification record in the database + // and store it in a variable called "verification". + // πŸ’° await prisma.verification.create({ + // data: { ...totpConfig, type: '2fa', target: input.userId } + // }) return { async [Symbol.asyncDispose]() { - await prisma.verification.deleteMany({ - where: { id: verification.id }, - }) + // 🐨 Delete the previously created verification record fr om + // πŸ’° await prisma.verification.deleteMany({ where: { ... } }) }, - ...verification, + // 🐨 Return the verification object by spreading it here. + // πŸ’° ...verification } } diff --git a/exercises/02.authentication/02.problem.2fa/tests/e2e/authentication-2fa.test.ts b/exercises/02.authentication/02.problem.2fa/tests/e2e/authentication-2fa.test.ts index 88ac661..03ee134 100644 --- a/exercises/02.authentication/02.problem.2fa/tests/e2e/authentication-2fa.test.ts +++ b/exercises/02.authentication/02.problem.2fa/tests/e2e/authentication-2fa.test.ts @@ -6,5 +6,38 @@ test('authenticates using two-factor authentication', async ({ navigate, page, }) => { - /** @todo Instructions */ + // 🐨 Create a test user by calling "createUser()" and storing + // the returned disposable object in a variable called "user". + // πŸ’° await using user = await fn() + + // 🐨 Generate a Time-based One-Time Password (TOPT) using the + // "generateTOTP" function from the "@epic-web/totp" package and + // store it in a variable called "totp". + // πŸ’° const totp = await generateTOTP() + + // 🐨 Create a verification for the user by calling "createVerification()" + // with an object containing the "totp" and the "userId" (which is the + // "id" property of the created user). Store the returned disposable + // object in placeholder variable "_". + // πŸ’° await using _ = await fn({ totp, userId: user.id }) + + await navigate('/login') + + // 🐨 Fill in the "user.username" into the field with the label "Username". + // πŸ’° await page.getByLabel(label).fill(value) + + // 🐨 Fill in the "user.password" into the field with the label "Password". + // 🐨 Submit the login form. + + // 🐨 Write an assertion that expects a heading element with the text + // "Check your 2FA app" to be visible on the page. + // πŸ’° await expect(page.getByRole(role, { name: name })).toBeVisible() + + // 🐨 Next, generate and enter a new TOTP into the textbox with an + // accessible name matching /code/i. + // πŸ’° await page.getByRole(role, { name: name }).fill((await generateTOTP(totp)).otp) + + // 🐨 Finally, add an assertion that expects the link element + // with the user's name to be visible on the page. + // πŸ’° await expect(page.getByRole(role, { name: user.name })).toBeVisible() }) diff --git a/exercises/02.authentication/02.solution.2fa/README.mdx b/exercises/02.authentication/02.solution.2fa/README.mdx index 996f571..28022d4 100644 --- a/exercises/02.authentication/02.solution.2fa/README.mdx +++ b/exercises/02.authentication/02.solution.2fa/README.mdx @@ -1,22 +1,116 @@ # Two-factor authentication -## Summary +The 2FA flow introduces a new element to authentication: One-Time Password. Unlike regular passwords, OTP doesn't get stored anywhere as that would defeat the point of the "One-Time" part. Instead, the server stores a _verification_ record associating a user with a _secret_ that can be used to check whether any OTP actually belongs to that user. -1. Install `@epic-web/totp`. We will use it to generate verification codes for our 2FA test. -1. In `test/db-utils.ts`, create a new function called `createVerification`. Go through its implementation step-by-step. -1. Add an `asyncDispose` symbol to auto-cleanup the test data when the test closure it garbage-collected. +Our test setup has to mirror this specifics if we want to achieve reliable test coverage. ---- +## `createVerification()` utility -1. Create a new test at `tests/e2e/authentication-2fa.test.ts`. -1. Use the `createUser` utility to create a random user. -1. Use the `generatTOTP` from `@epic-web/totp` to generate an OTP. -1. Use the new `createVerification` utility to generate a verification code with the given OTP for the given test user. -1. Go to the login page, log in using the credentials. -1. Wait until the 2FA verification prompt becomes visible. -1. Enter the generated verification code. -1. Verify that the auth was successful by the visibility of the user profile link. +Just like creating test users with `createUser()`, the OTP verification record is one more server-side resource we need to seed for the test. To actually create OTP tokens, we are going to use a library called [`@epic-web/totp`](https://github.com/epicweb-dev/totp). This is _not_ a testing library. In fact, if you examine the authentication code for Epic Stack, you can quickly spot that we are using it to back up the actual authentication. This kind of reusage is most welcome in tests. ---- +```ts filename=test/db-utils.ts add=6-12,16-18 +export async function createVerification(input: { + totp: Awaited> + userId: string +}) { + const { otp, ...totpConfig } = input.totp + const verification = await prisma.verification.create({ + data: { + ...totpConfig, + type: '2fa', + target: input.userId, + }, + }) -1. `npm run test:e2e`. to confirm. + return { + async [Symbol.asyncDispose]() { + await prisma.verification.deleteMany({ + where: { id: verification.id }, + }) + }, + ...verification, + } +} +``` + +> The exact shape of the verification record (`type`, `target`, etc.) is, once again, dictated by our application's architecture. Make sure your test setup reflects **your** application. + +## Testing Two-factor authentication + +With `createVerification()` ready, we've got all the pieces we need to put our application into the right state for testing the 2FA flow. + +```ts filename=tests/e2e/authentication-2fa.test.ts +import { generateTOTP } from '@epic-web/totp' +import { createUser, createVerification } from '#tests/db-utils.ts' +import { test, expect } from '#tests/test-extend.ts' + +test('authenticates using two-factor authentication', async ({ + navigate, + page, +}) => { + // Setup. + await using user = await createUser() + const totp = await generateTOTP() + await using _ = await createVerification({ + totp, + userId: user.id, + }) + + await navigate('/login') + + // Actions. + await page.getByLabel('Username').fill(user.username) + await page.getByLabel('Password').fill(user.password) + await page.getByRole('button', { name: 'Log in' }).click() + + // Assertions. + await expect( + page.getByRole('heading', { name: 'Check your 2FA app' }), + ).toBeVisible() + + // Actions. + await page + .getByRole('textbox', { name: /code/i }) + .fill((await generateTOTP(totp)).otp) + + await page.getByRole('button', { name: 'Submit' }).click() + + // Assertions. + await expect(page.getByRole('link', { name: user.name! })).toBeVisible() +}) +``` + +Let's take a moment to appreciate how genuinely straightforward this test is. Despite tackling a rather complex flow, the test case itself reads not unlike a regular component test. **This is not a coincidence but a direct result of managing complexity through the test setup.** + +`createUser()` and `generateTOTP()` and `createVerification()` are complex functions doing complex things, but they aren't leaking any of that complexity into the test case. Instead, they encapsulate it in clean and understandable steps: + +1. Create a test user; +1. Genereate a One-Time Password; +1. Create a verification for this user and this OTP. + +They expose control while offloading the implementation details and the complexity behind them to the backstageβ€”to the test setupβ€”where it belongs. And they give you a nice eagle-eye overview of what this test case involves, which is also nice! + +The key part here is that once we arrive at the "Check your 2FA app" page, we enter a new TOTP the same way a user would open their password manager and copy-paste a new token into our app: + +```ts +await expect( + page.getByRole('heading', { name: 'Check your 2FA app' }), +).toBeVisible() + +await page + .getByRole('textbox', { name: /code/i }) + .fill((await generateTOTP(totp)).otp) +``` + +The rest of this test case interacts with the application and asserts on the UI the same way we did in the previous tests and requires no further explanation. + +## Running tests + +``` +npm run test:e2e +``` + +## Related materials + +- [(Video) Dissecting Complexity in Tests](https://www.youtube.com/watch?v=nUozijHdgMM&t=419s) +- [`@epic-web/totp`](https://github.com/epicweb-dev/totp) diff --git a/exercises/02.authentication/02.solution.2fa/tests/e2e/authentication-2fa.test.ts b/exercises/02.authentication/02.solution.2fa/tests/e2e/authentication-2fa.test.ts index 3ea630a..19cd609 100644 --- a/exercises/02.authentication/02.solution.2fa/tests/e2e/authentication-2fa.test.ts +++ b/exercises/02.authentication/02.solution.2fa/tests/e2e/authentication-2fa.test.ts @@ -6,7 +6,6 @@ test('authenticates using two-factor authentication', async ({ navigate, page, }) => { - // Create a test user and enable 2FA for them directly in the database. await using user = await createUser() const totp = await generateTOTP() await using _ = await createVerification({ @@ -14,7 +13,6 @@ test('authenticates using two-factor authentication', async ({ userId: user.id, }) - // Log in as the created user. await navigate('/login') await page.getByLabel('Username').fill(user.username) diff --git a/exercises/02.authentication/03.problem.passkeys/README.mdx b/exercises/02.authentication/03.problem.passkeys/README.mdx index 57936c8..71be171 100644 --- a/exercises/02.authentication/03.problem.passkeys/README.mdx +++ b/exercises/02.authentication/03.problem.passkeys/README.mdx @@ -1,9 +1,25 @@ # Passkeys +[Passkeys](https://developer.mozilla.org/en-US/docs/Web/Security/Authentication/Passkeys) is a type of a passwordless authentication that provides high security combined with great user experience. The user generates a passkey, then the browser stores its private part while the server stores the public one. Upon subsequent authentication, the browser-server exchange happens without any user involvement (e.g. having to enter a password), and as long as the user has a valid passkey on their device, they will be authenticated. + +So given how innately secure this authentication method is, how does one even test it? + ## Your task -- Write the `tests/e2e/authentication-passkey.test.ts` test suite. -- Create a `createWebAuthnClient()` utility. -- Install `test-passkey` as a dependency. -- Create a `createPasskey()` utility. -- Describe two test cases: successful and unsuccessul auth with passkeys. +πŸ‘¨β€πŸ’Ό Well, that is precisely your task in this exercise! And you problably already know what you're going to start with: the test setup. Passkeys introduce two additional elements to the flow: + +- The private key (the browser part); +- The public key (the server part). + +Naturally, you'd have to create and store both of those in their correct places so the authentication could trigger as it normally does for your users. + +🐨 Start by writing a `createPasskey()` utility. This will be your helper to generate reliastic passkeys for your test users. It will involve using the `test-passkey` library to help you out with that. + +🐨 Then, in `tests/e2e/authentication-passkeys.test.ts`, work on another helper, this side for the browser side of things, called `createWebAuthnClient()`. You'll tap into Chrome DevTools Protocol and the underlying `WebAuthn` APIs to create a virtual authenticator to pair with your test passkey. + +🐨 And, finally, having all the pieces you need, write a few test cases covering the passkey authentication flow. You are primarily interested in the following: + +1. Successful authentication flow with a passkey; +1. Failed authentication flow with a passkey (error handling). + +Give it your best and see you on the other side! diff --git a/exercises/02.authentication/03.problem.passkeys/tests/e2e/authentication-passkeys.test.ts b/exercises/02.authentication/03.problem.passkeys/tests/e2e/authentication-passkeys.test.ts index 57b95cc..e1236bb 100644 --- a/exercises/02.authentication/03.problem.passkeys/tests/e2e/authentication-passkeys.test.ts +++ b/exercises/02.authentication/03.problem.passkeys/tests/e2e/authentication-passkeys.test.ts @@ -4,13 +4,59 @@ import { createPasskey, createUser } from '#tests/db-utils.ts' import { test, expect } from '#tests/test-extend.ts' async function createWebAuthnClient(page: Page) { - /** @todo */ + // 🐨 πŸ’° + // Create a new Chrome DevTools Protocol session on the given page + // and assign it to a variable named `session` + // πŸ’° await page.context().newCDPSession(page) + // + // 🐨 Create a virtual WebAuthn authenticator by sending the + // "WebAuthn.addVirtualAuthenticator" messages to the session. + // Store it as "authenticator". + // πŸ’° await session.send('WebAuthn.addVirtualAuthenticator', options) + // + // 🐨 Provide the following options alongside the "addVirtualAuthenticator" + // message: + // - protocol: "ctap2" + // - transport: "internal" + // - hasResidentKey: true + // - hasUserVerification: true + // - isUserVerified: true + // - automaticPresenceSimulation: true + // + // 🐨 Finally, return an object containing the `session` and the + // `authenticatorId` (which you can get from the response of the + // "addVirtualAuthenticator" message). } test('authenticates using an existing passkey', async ({ navigate, page }) => { + // 🐨 Create a test user with the "createUser" utility. + // πŸ’° await using value = await fn() + await navigate('/login') - /** @todo */ + // 🐨 Create a test passkey by calling the "createTestPasskey" function + // from the "test-passkey" package. Provide this page's hostname as the "rpId" option. + // πŸ’° createTestPasskey({ rpId: new URL(page.url()).hostname }) + + // 🐨 Next, store the public part of the passkey on the server using the + // "createPasskey" utility. Make sure to provide the following properties: + // - id: passkey.credential.credentialId + // - userId: the id of the user you just created + // - aaguid: passkey.credential.aaguid + // - publicKey: passkey.publicKey + // + // πŸ’° await using _ = await createPasskey({ ... }) + + // 🐨 Now, create a new virtual authenticator by calling the "createWebAuthnClient" + // utility and providing it with the current "page". + // πŸ’° const { session, authenticatorId } = await fn(args) + + // 🐨 Locate a button with the text "Login with a passkey" and click it. + // πŸ’° await page.getByRole(role, { name }).click() + + // 🐨 Finally, write an expectation that a link with the text "user.name" + // is visible on the page. + // πŸ’° await expect(page.getByRole(role, { name })).toBeVisible() }) test('displays an error when authenticating via a passkey fails', async ({ @@ -19,5 +65,18 @@ test('displays an error when authenticating via a passkey fails', async ({ }) => { await navigate('/login') - /** @todo */ + // 🐨 For this failure scenario, create a virtual authenticator by calling + // the "createWebAuthnClient" utility function. + // πŸ’° const { session, authenticatorId } = await fn(args) + + // 🐨 Then, send the "WebAuthn.setUserVerified" message on the CDP session + // with the "authenticatorId" and "isUserVerified: false" as options. + // πŸ’° await session.send('WebAuthn.setUserVerified', options`) + + // 🐨 Locate the "Login with a passkey" button and click it. + + // 🐨 Add an assertion that the element with the role "alert" with a child + // element containing the following text is visible on the page: + // πŸ’° "Failed to authenticate with passkey: The operation either timed out or was not allowed" + // πŸ’° await expect(page.getByRole(role).getByText(text)).toBeVisible() }) diff --git a/exercises/02.authentication/03.solution.passkeys/README.mdx b/exercises/02.authentication/03.solution.passkeys/README.mdx index fdaa634..39cfd57 100644 --- a/exercises/02.authentication/03.solution.passkeys/README.mdx +++ b/exercises/02.authentication/03.solution.passkeys/README.mdx @@ -1,18 +1,235 @@ # Passkeys -## Summary - -1. In `tests/db-utils.ts`, create a new utility `createPasskey`. It will only be responsible for storing the given passkey correctly in the database (that it belongs to the given user). -1. Install `test-passkey`. We will use it to generate test passkeys faster for tests. -1. Create a new test at `tests/e2e/authentication-passkeys.test.ts`. -1. Add a test case for successful auth using passkeys. **Navigate to `/login` first. This is important**. -1. Create a test passkey, create a test user. Use `createPasskey` to store the generated passkey in the database. -1. Now, we need to tell Playwright how to handle passkeys. In the test, create the `createWebAuthnClient` function and go step-by-step. -1. Use the created `createWebAuthnClient` function to tell the browser how to react to the passkey input prompt (to use our test passkey). -1. Continue with the login flow. Verify the auth state. -1. `npm run test:e2e`. - ---- - -1. Add another test case for the error scenario. -1. `npm run test:e2e`. +## `createPasskey()` utility + +Let's start by arranging the server side of things, which consists of storing a passkey in the database. + +```ts filename=tests/db-utils.ts add=8-19 +export async function createPasskey(input: { + id: string + userId: string + aaguid: string + publicKey: Uint8Array + counter?: number +}) { + const passkey = await prisma.passkey.create({ + data: { + id: input.id, + aaguid: input.aaguid, + userId: input.userId, + publicKey: input.publicKey, + backedUp: false, + webauthnUserId: input.userId, + deviceType: 'singleDevice', + counter: input.counter || 0, + }, + }) + + return { + async [Symbol.asyncDispose]() { + await prisma.passkey.deleteMany({ + where: { + id: passkey.id, + }, + }) + }, + ...passkey, + } +} +``` + +Notice how the `createPasskey()` utility decouples the actual passkey (the `publicKey` in this case) from the storage mechanism. After all, it only needs the public part and the user context (`userId` and `aaguid`) to produce a valid record. + +Of course, on its own this isn't enough. Something has to actually _create_ that passkey for us to store. Where does it get from in a regular, non-test scenario? Well, it gets provided by the user! And in our case, it will get provided by the test user during the test. + +## Virtual Web Authenticator + +Before we get to the providing part though, we need to have something that would store our test passkeys in the browser. Luckily, we don't have to hack our way into that storage mechanism as the underlying APIs are exposed through the Chrome DevTools Protocol (CDP), which, in turn, is exposed to us by Playwright. + +We will create another helper utility called `createWebAuthnClient()` that will give us back a virtual authenticator session bound to the given page. + +```ts filename=tests/e2e/authentication-passkeys.test.ts +async function createWebAuthnClient(page: Page) { + const session = await page.context().newCDPSession(page) + await session.send('WebAuthn.enable') + + const authenticator = await session.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }) + + return { + session, + authenticatorId: authenticator.authenticatorId, + } +} +``` + +> By setting the `automaticPresenceSimulation` option to `true`, we are selecting the added passkey in the authenticatior automatically, whereas the users would normally be presented with a blocking popup to choose their passkey. + +Here, we are enabling Web Authentication (`'WebAuthn.enable'`) and adding a new virtual authenticator (`WebAuthn.addVirtualAuthenticator`) by sending the respective messages to the CDP session. In return, we get: + +- `session`, the CDP session reference we can use in the test to store our test passkey in the browser; +- `authenticatorId`, the reference to the virtual authenticator we've created so the browser knows where to store our passkeys. + +With that, let's write our first test case that focuses on the successful authentication flow. + +```ts filename=tests/e2e/authentication-passkeys.test.ts highlight=9-11,13-18,20-30 +import { createTestPasskey } from 'test-passkey' +import { createPasskey, createUser } from '#tests/db-utils.ts' + +test('authenticates using an existing passkey', async ({ navigate, page }) => { + await using user = await createUser() + + await navigate('/login') + + const passkey = createTestPasskey({ + rpId: new URL(page.url()).hostname, + }) + + await using _ = await createPasskey({ + id: passkey.credential.credentialId, + userId: user.id, + aaguid: passkey.credential.aaguid || '', + publicKey: passkey.publicKey, + }) + + const { session, authenticatorId } = await createWebAuthnClient(page) + await session.send('WebAuthn.addCredential', { + authenticatorId, + credential: { + ...passkey.credential, + isResidentCredential: true, + userName: user.username, + userHandle: btoa(user.id), + userDisplayName: user.name ?? user.email, + }, + }) + + await page.getByRole('button', { name: 'Login with a passkey' }).click() + + await expect(page.getByRole('link', { name: user.name! })).toBeVisible() +}) +``` + +### Creating a test passkey + +```ts filename=tests/e2e/authentication-passkeys.test.ts +await navigate('/login') + +const passkey = createTestPasskey({ + rpId: new URL(page.url()).hostname, +}) +``` + +We are using the `createTestPasskey()` function from the [`test-passkey`](https://github.com/kettanaito/test-passkey) package to create a realistic passkey bound to the given `rpId`β€”relying party IDβ€”which is the hostname of the application for which the passkey is being issued. + +`rpId` is the integral part of preventing passkeys from being spoofed. Respect that and issue the test passkey specifically for your test application. For that, make sure you have already visited the application (`navigate()`) so `page.url()` would be an actual application URL and not just `about:blank`. + +### Storing the public key + +Now that we have a test `passkey`, let's store its public part on the server, using the `createTestPasskey()` utility we've created earlier. + +```ts filename=tests/e2e/authentication-passkeys.test.ts highlight=8-11 +const passkey = createTestPasskey({ + rpId: new URL(page.url()).hostname, +}) + +// ... + +await using _ = await createPasskey({ + id: passkey.credential.credentialId, + userId: user.id, + aaguid: passkey.credential.aaguid || '', + publicKey: passkey.publicKey, +}) +``` + +You can already spot the key-user collocation happening via `userId` and the passkey's `id` + `publicKey`. + +### Storing the private key + +Storing the private part of the test passkey will involve adding it to the virtual authenticator via the `createWebAuthnClient()` we've prepared. + +```ts filename=tests/e2e/authentication-passkeys.test.ts highlight=11,13-15 +const passkey = createTestPasskey({ + rpId: new URL(page.url()).hostname, +}) + +// ... + +const { session, authenticatorId } = await createWebAuthnClient(page) +await session.send('WebAuthn.addCredential', { + authenticatorId, + credential: { + ...passkey.credential, + isResidentCredential: true, + userName: user.username, + userHandle: btoa(user.id), + userDisplayName: user.name ?? user.email, + }, +}) +``` + +### Authenticating via passkeys + +All that remains now is to continue with the authentication flow, following the user's actions and describing the their expectations. + +```ts filename=tests/e2e/authentication-passkeys.test.ts +await page.getByRole('button', { name: 'Login with a passkey' }).click() + +await expect(page.getByRole('link', { name: user.name! })).toBeVisible() +``` + +Yes, this is literally it. The test case itself is a two-liner that delegates all the heavy-lifting to the test setup. + +## Failed authentication flow + +Unlike the basic authentication, testing a failed passkey scenario will also require some test setup. If we omit the virtual authenticator entirely, the authentication flow will, indeed, fail, but not with an error the user ever be able to reproduce. + +To repvent that and tap into the actual user-facing failure, we will tap into the CDP once more and send the `'WebAuthn.setUserVerified'` directive, marking our virtual authenticatior as non-verified for usage. + +```ts filename=tests/e2e/authentication-passkeys.test.ts highlight=10 +test('displays an error when authenticating via a passkey fails', async ({ + navigate, + page, +}) => { + await navigate('/login') + + const { session, authenticatorId } = await createWebAuthnClient(page) + await session.send('WebAuthn.setUserVerified', { + authenticatorId, + isUserVerified: false, + }) + + await page.getByRole('button', { name: 'Login with a passkey' }).click() + + await expect( + page + .getByRole('alert') + .getByText( + 'Failed to authenticate with passkey: The operation either timed out or was not allowed', + ), + ).toBeVisible() +}) +``` + +This reproduces a scenario when the selected passkey cannot be used for the given `rpId` (application), which allows us to assert on the appropriate error message being communicated to the user. + +## Running the tests + +``` +npm run test:e2e +``` + +## Related materials + +- [`test-passkey`](https://github.com/kettanaito/test-passkey) +- [Authentication via passkeys (MDN)](https://developer.mozilla.org/en-US/docs/Web/Security/Authentication/Passkeys) +- [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) diff --git a/exercises/02.authentication/04.problem.protected-logic/README.mdx b/exercises/02.authentication/04.problem.protected-logic/README.mdx index ec549ef..d210f96 100644 --- a/exercises/02.authentication/04.problem.protected-logic/README.mdx +++ b/exercises/02.authentication/04.problem.protected-logic/README.mdx @@ -1,15 +1,30 @@ # Protected logic -- Testing the logic behind authentication (e.g. user creating notes). +Up to this point you have been testing authentication as a feature. That involved preparing the server-side state (e.g. creating test users, 2FA verifications, passkey records) and going through the login flow as they would. + +But your users log in into your app _to do something_. To post a message, edit an invoice, delete an item from their cart. All of those intentions they want to express live behind authentication, regardless of what authentication option they've chosen. + +In this one, you will learn how to test any behavior behind authentication using _personas_. ## Your task -πŸ‘¨β€πŸ’Ό In this exercise, your task is to set up authentication for tests. To achieve that, you will be using a library called [Playwright Persona](https://github.com/kettanaito/playwright-persona). Please take a moment to read about it before continuing. +πŸ‘¨β€πŸ’Ό The flow you will be testing in this exercise goes like this: + +1. Given the user is logged in; +1. And they go to their "Notes" page; +1. And create a new note; +1. They should see that note in the list of their notes. + +You have already tackled every single step from this user scenario in the previous exercises, even if they took a slightly different shape. + +But this time, there's a catch. + +This time, you should _start_ from the authenticated state, treating it as a part of your test setup. And to help you with that, you will use a third-party library called [Playwright Persona](https://github.com/kettanaito/playwright-persona). -🐨 Start by installing `playwright-persona` as a dependency in your project: +🐨 First, install that library: ```sh -npm i playwright-persona -D +npm i playwright-persona --save-dev ``` 🐨 Next, head to `tests/test-extend.ts` and follow the instructions to define user personas. diff --git a/exercises/02.authentication/04.solution.protected-logic/README.mdx b/exercises/02.authentication/04.solution.protected-logic/README.mdx index d0f9a62..4c220e5 100644 --- a/exercises/02.authentication/04.solution.protected-logic/README.mdx +++ b/exercises/02.authentication/04.solution.protected-logic/README.mdx @@ -1,21 +1,278 @@ # Protected logic -## Summary +## Authentication vs Authentication -1. Install `playwright-persona`. Why it's needed. -1. In `test-extend.ts`, use `definePersona` to define a new authentication persona. Explain what a persona is and what steps it has when defining. -1. Go through the `createSession`, `verifySession`, and `destroySession` implementation steps. Summarize when persona will call these methods during the test life-cycle. -1. Create a new fixture called `authenticate` using the `combinePersonas` utility from `playwright-persona`. +This is not a typo. There are, in fact, _two_ types of authentication you will encounter when end-to-end testing your apps: ---- +1. Authentication as a feature; +1. Authentication as a dependency for other features. -1. Create a new test at `tests/e2e/notes-create.test.ts`. -1. Use the new `authenticate` fixture to authenticate as a `user` persona. Get the returned `user` object to use its data in the test. -1. Go to a protected route, e.g. to create a new note. -1. Fill in the new note form and submit it. -1. Verify the new note is visible and has the correct content. -1. `npm run test:e2e`. +### Authentication as a feature ---- +The purpose of the authentication feature is to allow the user to authenticateβ€”provide their credentials to prove their identity in your system. All the various ways of authentication become different branches of the same feature. Your task here is to verify that every authentication option, every promise you make to your users functions as you think it does (by writing automated tests, of course). -1. Talk a bit more about personas and testing authentication-dependent logic, how it differs from testing authentication itself (where you don't need personas). +A typical authentication-as-a-feature test looks like this (in pseudo-code): + +```ts filename=authentication-as-a-feature.test.ts highlight=6-8 +test('authenticates using email and password', async () => { + // Setup. + await serverSideSetup() + + // Actions. + await navigate('/login') + await fill(loginForm, values) + await submit() + + // Assertions. + await expect(someUiState).toBeVisible() +}) +``` + +Going through the authentication flow is the inseparable part of this test because you want to make sure your users can also go through that same flow. + +### Authentication as a dependency + +Let's compare that with a test for a feature that depends on authentication: + +```ts filename=authentication-as-a-dependency.test.ts highlight=3 +test('publishes a new post', async () => { + // Setup. + await authenticate({ as: role }) + + // Action. + await navigate('/dashboard/posts') + await fill(newPostForm, values) + await submit() + + // Assertions. + await expect(newPostHeading).toBeVisible() +}) +``` + +In order to publish a post, the user must be authenticated. That's your dependency over here. But notice how the authentication itself is _no longer the action the user performs_ in this test. Instead, it has become the _setup_ for it. + +This distinction is purely practical. The test case for publishing a post must assume a correctly authenticated user every time it runs. "_Given_ the user is authenticated..." It is crucial that any failures to `authenticate()` are reported as the setup errors, not action errors or, worse, failed assertions. + +## Playwright Persona + +It is not uncommon to assume different "personas" when testing authentication-dependent logic. A regular user. An admin. A legacy user. A banned user. All of those are personas that combine the authenticated state with the data state. + +Unfortunately, assuming those personas, just like working with authenticated state, doesn't have a first-class API in Playwright. Up until recently, Playwright had no APIs to get or store the current web storages and cookies state on disk (`setStorageState()` has been added in [March 2026](https://github.com/microsoft/playwright/issues/17389#issuecomment-4182828872); `getStorageState()` is still missing). But even if those APIs are present, they are rather low-level and do not tackle several aspects as authentication-as-a-dependency: + +- Persisted authentication state can become stale; +- Authentication state/user persona collocation lacks an explicit contract; +- No way to assume a persona on a test case basis, with the recommende way being establishing a dependency _between test projects_, treating a special test suite as a setup for another test suite. + +This is why I created a library called [Playwright Persona](https://github.com/kettanaito/playwright-persona). It uses the underlying low-level APIs in Playwright to provide a type-safe and ergonomic way of defining and assuming different authentication personas in tests. On top of that, it comes with automatic stale session invalidation and reusage of existing sessions for better test performance. + +### Installation + +First, let's install `playwright-persona` as a dependency: + +```sh +npm i playwright-persona --save-dev +``` + +### Defining personas + +Let's define our first user persona, shall we? + +To do that, import the `definePersona()` function from `playwright-persona` and provide it with two arguments: the name of the persona and the persona options. + +```ts filename=tests/test-extend.ts +import { definePersona } from 'playwright-persona' + +const user = definePersona('user', options) +``` + +> I will keep my authentication personas in the `test-extend.ts` file, but you can organize them in any way you see more fitting to your project. + +Every persona has life-cycle methods you can describe: + +- `createSession`, which describes what setup this persona needs and what actions must be performed in the UI to arrive at the authenticated state corresponding to this persona; +- `verifySession`, which describes how to verify a persisted authenticated state; +- `destroySession` (optional), which acts as a cleanup hook where you can delete any resources related to the current persona (i.e. user). + +Let's define how to create a session for our `user` persona: + +```ts filename=tests/test-extend.ts add=4-20 +import { definePersona } from 'playwright-persona' + +const user = definePersona('user', { + async createSession({ page }) { + const user = await prisma.user.create({ + data: { + ...createUser(), + roles: { connect: { name: 'user' } }, + password: { create: { hash: await getPasswordHash('supersecret') } }, + }, + }) + + await page.goto('/login') + await page.getByLabel('Username').fill(user.username) + await page.getByLabel('Password').fill('supersecret') + await page.getByRole('button', { name: 'Log in' }).click() + await page.getByText(user.name!).waitFor({ state: 'visible' }) + + return { user } + }, +}) +``` + +Notice how a single `createSession()` method combines the server-side resources this persona needs (creating the user record in the database) together with the actions performed to reproduce this authentication state (logging in with the created user's credentials). + +Persona methods, like `createSession()` and `verifySession()`, can use the `page` object to interact with the application the same way you can in your tests. But since those methods run during the setup phase, any exceptions they produce are reported as those happening during the setup. + +Next, we need to teach our persona to tell a fresh persisted session from a stale one. To do that, provide the `verifySession()` method on the persona's options: + +```ts filename=tests/test-extend.ts add=21-26 +import { definePersona } from 'playwright-persona' + +const user = definePersona('user', { + async createSession({ page }) { + const user = await prisma.user.create({ + data: { + ...createUser(), + roles: { connect: { name: 'user' } }, + password: { create: { hash: await getPasswordHash('supersecret') } }, + }, + }) + + await page.goto('/login') + await page.getByLabel('Username').fill(user.username) + await page.getByLabel('Password').fill('supersecret') + await page.getByRole('button', { name: 'Log in' }).click() + await page.getByText(user.name!).waitFor({ state: 'visible' }) + + return { user } + }, + async verifySession({ page, session }) { + await page.goto('/') + await expect(page.getByText(session.user.name!)).toBeVisible({ + timeout: 100, + }) + }, +}) +``` + +Whatever session object you return from the `createSession()` method is exposed to other methods, including `verifySession()`. We can use that to describe context-aware actions to perform on the page _after_ the library restores a persisted authentication session from the disk but _before_ any of your tests run. + +Above, I'm using an assertion over the `session.user.name` text to be present on the page, implying the profile button in the top right corner. If it's present, the application digested the stored state correctly. If not, the state is likely obsolete and a new session must be created before continuing with the tests. + +Notice how I'm setting an explicitly shorter `timeout` on the `expect()` statement. The UI state I expect is a direct reflection of the hydrated session and is **not** eventual. The session is either immediately valid or it is not. There's no need to keep the retry for this `expect()`. That would only slow down the test. + +A really neat part of Playwright Persona is how it handles stale sessions (those where your `verifySession()` method errored): + +1. First, it runs the cleanup (`destroySession()`) with the _old_, stale session. This makes sure that no resources associated with that obsolete session survive in the database; +1. Then, it automatically creates a _new_ session by calling `createSession()` and providing it to your tests. + +And, lastly, I will declare the `destroySession()` method where I will delete the test user once it's no longer needed. + +```ts filename=tests/test-extend.ts add=27-29 +import { definePersona } from 'playwright-persona' + +const user = definePersona('user', { + async createSession({ page }) { + const user = await prisma.user.create({ + data: { + ...createUser(), + roles: { connect: { name: 'user' } }, + password: { create: { hash: await getPasswordHash('supersecret') } }, + }, + }) + + await page.goto('/login') + await page.getByLabel('Username').fill(user.username) + await page.getByLabel('Password').fill('supersecret') + await page.getByRole('button', { name: 'Log in' }).click() + await page.getByText(user.name!).waitFor({ state: 'visible' }) + + return { user } + }, + async verifySession({ page, session }) { + await page.goto('/') + await expect(page.getByText(session.user.name!)).toBeVisible({ + timeout: 100, + }) + }, + async destroySession({ session }) { + await prisma.user.deleteMany({ where: { id: session.user.id } }) + }, +}) +``` + +> The `destroySession()` method does **not** run after each test. It implements an **eventual** cleanup, which guarantees the deletion of stale resources while allowing us to reuse those sessions whose validity survives across tests and even test runs. + +### `authenticate()` fixture + +Remember our "fixture vs utility" rule? Well, authentication is as much about preparing the server-side resources as it is about interacting with the browser to actually authenticate. As such, it makes a nice candidate for a custom fixture! + +The `playwright-persona` package provides a shorthand utility called `combinePersonas` that will combine any given personas into a fixture for you. + +```ts filename=tests/test-extend.ts add=3-4,11,22 +import { + definePersona, + combinePersonas, + type AuthenticateFunction, +} from 'playwright-persona' + +interface Fixtures { + navigate: ( + ...args: Parameters> + ) => Promise + authenticate: AuthenticateFunction<[typeof user]> +} + +const user = definePersona('user', { ... }) + +export const test = testBase.extend({ + async navigate({ page }, use) { + await use(async (...args) => { + await page.goto(href(...args)) + }) + }, + authenticate: combinePersonas(user), +}) +``` + +## Assuming personas in a test + +Import the created `authenticate()` fixture in any test and call it, providing the persona name you wish to use in this test. + +```ts filename=tests/e2e/notes-create.test.ts remove=3 add=4,5 highlight=7 +import { test, expect } from '#tests/test-extend.ts' + +test('creates a new note', async ({ navigate, page }) => { +test('creates a new note', async ({ navigate, page, authenticate }) => { + const { user } = await authenticate({ as: 'user' }) + + await navigate('/users/:username/notes/new', { username: user.username }) + + await page.getByLabel('Title').fill('My new note') + await page.getByLabel('Content').fill('Hello world') + await page.getByRole('button', { name: 'Submit' }).click() + + await expect(page.getByRole('heading', { name: 'My new note' })).toBeVisible() + await expect( + page.getByLabel('My new note').getByText('Hello world'), + ).toBeVisible() +}) +``` + +Let's take a closer look at how the `authenticate()` fixture works. + +```ts +const { user } = await authenticate({ as: 'user' }) +``` + +You provide it an object as the argument with the `as` key equal to the _name_ of the persona you want to use. The values for the `as` key are type-safe and inferred from all the personas you provided to `combinePersonas()` function in your fixture definition. + +As a result, the fixture returns the _session object_. It's the same session object _you_ return from the `createSession()` method of your persona. + +## Persisting authentication state + +The authentication sessions created by `playwright-persona` are stored at the `./playwright/.auth` directory on your project's root. + +I highly recommend you **ignore** the `/playwright/.auth` directory and its contents in Git. Authentication sessions contain sensitive information by design and should not be committed. + +When it comes to CI, on the other hand, consider caching that directory (if the rest of your architecture accommodates for it) so the authentication state could persist across jobs, yielding faster test runs. diff --git a/exercises/02.authentication/FINISHED.mdx b/exercises/02.authentication/FINISHED.mdx index 66a783b..a9b771b 100644 --- a/exercises/02.authentication/FINISHED.mdx +++ b/exercises/02.authentication/FINISHED.mdx @@ -1 +1,5 @@ -# Authentication \ No newline at end of file +# Authentication + +Well done! :clap: Now you are equipped with both the knowledge and tools needed to test anything authentication-realted. But, most importantly, I hope you've discovered the importance of the test setup and how efficiently it can offload the complexity from your end-to-end tests, turning them into a beautiful sequence of actions followed by assertions. + +Let's have a quick break and continue! diff --git a/exercises/03.guides/01.problem.mock-database/README.mdx b/exercises/03.guides/01.problem.mock-database/README.mdx index 3f1bd8f..c59a97b 100644 --- a/exercises/03.guides/01.problem.mock-database/README.mdx +++ b/exercises/03.guides/01.problem.mock-database/README.mdx @@ -1,4 +1,25 @@ # Mock database -- File-based mock database in SQLite. -- Configuring environment to use a different `DATABASE_URL`. +Applications need data and data needs to be stored somewhere. Even while you've been writing the tests during this workshop, with all the users and verifications and passkeys you've created, those records were stored in a database. + +But which exact database? We've never set anything up specifically for testing, right? + +No, not yet. + +Epic Stack uses SQLite, and any `prisma` call produced records in the same database instance used for development at `./prisma/data.db` (configured as `DATABASE_URL` in `.env`). On its own, this is not a mistake. For once, SQLite databases are _file-based_ so anything we read or write from our `data.db` is happening only on our local machine. Additionally, sharing the same database instance across development and testing can be quite beneficial as you can observe and interact with the test resources during development. + +But keeping your development and test databases separate is also beneficial. Let's explore why. + +## Your task + +πŸ‘¨β€πŸ’Ό In this one, your task is to introduce a designated test database to be used in your end-to-end tests. As usual, it will take a couple of steps. + +🐨 First, start by introducing `.env.test`β€”an environment file specifically for Playwright tests. + +🐨 In `.env.test`, create an override for the `DATABASE_URL` variable and set its value to a test database (e.g. `file:./test.db`). + +🐨 Next, it's time to tell Playwright about these overrides. Head to `playwright.config.ts` and follow the instructions to apply `.env.test` file to your tests _and_ the tested application. + +🐨 And, finally, something has to initiate that test database before the tests run. You will tackle that in the newly created `playwright.setup.ts` file by using the Prisma CLI to generate the client and reset the database before the tests. + +That's quite a lot, but I know you can manage this. I'm excited to go through the solution with you once you're done. Good luck! diff --git a/exercises/03.guides/01.problem.mock-database/playwright.config.ts b/exercises/03.guides/01.problem.mock-database/playwright.config.ts index 931896d..e98b2be 100644 --- a/exercises/03.guides/01.problem.mock-database/playwright.config.ts +++ b/exercises/03.guides/01.problem.mock-database/playwright.config.ts @@ -1,6 +1,17 @@ import { defineConfig, devices } from '@playwright/test' +// πŸ’£ Remove this import to opt out from the default "dotenv" behavior. import 'dotenv/config' +// 🐨 Then, import the "dotenv" object from the "dotenv" package (notince the missing "/config"). +// πŸ’° import dotenv from 'dotenv' + +// 🐨 Call "dotenv.config()" to load the default environment file (".env"). + +// 🐨 Add another "dotenv.config()" call. This time, provide it with these options: +// - "path", a URL path to the ".env.test" file. +// - "override", set to true to allow variables in ".env.test" to override those in ".env". +// πŸ’° dotenv.config({ path: new URL('./path/here', import.meta.url), override: true }) + const PORT = process.env.PORT || '3000' export default defineConfig({ diff --git a/exercises/03.guides/01.problem.mock-database/playwright.setup.ts b/exercises/03.guides/01.problem.mock-database/playwright.setup.ts new file mode 100644 index 0000000..d822cdf --- /dev/null +++ b/exercises/03.guides/01.problem.mock-database/playwright.setup.ts @@ -0,0 +1,18 @@ +import { spawnSync } from 'node:child_process' + +// 🐨 Declare a function called "globalSetup" and export it as default. +// πŸ’° export default function name() {} + +// 🐨 In the "globalSetup" function, use "spawnSync" to spawn the +// "prisma" command with these arguments: "generate", "--sql". +// This will generate the Prisma client for our test database. +// πŸ’° spawnSync(command, [arg1, arg2]) + +// 🐨 Next, let's reset the database state before running the tests. +// Call "spawnSync" again with "prisma" as the command and these arguments: +// "migrate", "reset", "--force", "--skip-seed". + +// 🐨 For better observability, provide the "stdio" option to both "spawnSync" +// calls and set its value to "inherit". This will pipe any standard output +// and error from the spawned commands to the Playwright process for you to see. +// πŸ’° spawnSync(command, [arg1, arg2, ...], options) diff --git a/exercises/03.guides/01.solution.mock-database/README.mdx b/exercises/03.guides/01.solution.mock-database/README.mdx index f463961..482dbd9 100644 --- a/exercises/03.guides/01.solution.mock-database/README.mdx +++ b/exercises/03.guides/01.solution.mock-database/README.mdx @@ -1,11 +1,114 @@ # Mock database -## Summary - -1. Create the `.env.test` file and put a different `DATABASE_URL` there, pointing at `./prisma/test.db`. - - `?connection_limit=1` is useful since SQLite locks the entire database during writes. This prevents race conditions in tests (one test tries to write to a db while it's locked due to a write from another test). -1. Edit `playwright.config.ts` to add a new `dotenv.configure()` to load `.env.test` with `overrides: true`. This will point the test process to the correct env file. -1. In `playwright.config.ts`, also set `webServer.env` (`DATABASE_URL: process.env.DATABASE_URL`) to use the correct test DB URL. -1. Create `playwright.setup.ts` to (1) reset the test DB before the test run; (2) prepare the test database (generate client + apply the initial migrations). -1. Edit `playwright.config.ts` to use the `playwright.setup.ts` as the `globalSetup` so Playwright knows when to run it. -1. Run the existing tests (no test changes in this exercise). See the `prisma/test.db` written on disk. See how it _persists_ after the test run to help with debugging. Open and show what's been created as a part of this test run. +## Test environment + +While I want to set `DATABASE_URL` in `.env` to point to a test database file, I want for that logic to apply as an _override_ on top of the existing environment variables. + +I will start by creating `.env.test` at the root of the project and setting the `DATABASE_URL` variable to a different path: + +```dotenv filename=.env.test +# foo +DATABASE_URL="file:./test.db?connection_limit=1" +``` + +Here, the `file:` protocol instructs Prisma that my database is a file. `./test.db` describes a path to the database file relatiive to the `./prisma` directory. And, finally, I'm adding the `connection_limit` parameter with the value of `1` to prevent parallel database connections in SQLite so multiple tests don't attempt to read and write the same resources by accident. + +This completes the environment file and now I need to tell Playwright to use it. + +## Playwright configuration + +Right now, our tests are pulling the environment variables from `.env` because that's the default behavior of importing `dotenv/config`. I will opt out from it and describe which environment files are used in my tests, emphasizing where the overrides are. + +```ts filename=playwright.config.ts remove=2 add=3,5,7-10 +import { defineConfig, devices } from '@playwright/test' +import 'dotenv/config' +import dotenv from 'dotenv' + +dotenv.config() + +dotenv.config({ + path: new URL('./.env.test', import.meta.url), + override: true, +}) +``` + +You can notice I have _two_ `dotenv.config()` calls now. The first one (without the arguments) replicates the previous default behavior of `dotenv/config` and loads the `.env` file. The second one describes the `path` to the environment file and uses `override: true` so the variables from `.env.test` would override the same-named variables from `.env`. + +This will provision the right environment for the test process, but the Prisma client in the spawned application will still read `DATABASE_URL` from `.env` (default behavior). I will change that by forwarding the `DATABASE_URL` value onto the `env` option of the application under test: + +```ts filename=playwright.config.ts add=15 +// ... + +export default defineConfig({ + // ... + + webServer: { + command: process.env.CI ? 'npm run start:mocks' : 'npm run dev', + port: Number(PORT), + reuseExistingServer: true, + stdout: 'pipe', + stderr: 'pipe', + env: { + PORT, + NODE_ENV: 'test', + DATABASE_URL: process.env.DATABASE_URL, + }, + }, +}) +``` + +## Global setup + +Something has to prepare our test database before the test run. That something will be a global setup in Playwright. + +In the `playwright.setup.ts` file, I will add the default `globalSetup()` function and first generate the Prisma client in it: + +```ts filename=playwright.setup.ts add=3-7 +import { spawnSync } from 'node:child_process' + +export default function globalSetup() { + spawnSync('prisma', ['generate', '--sql'], { + stdio: 'inherit', + }) +} +``` + +Next, I will reset the test database so every test run starts from a clean slate. + +```ts filename=playwright.setup.ts add=8-10 +import { spawnSync } from 'node:child_process' + +export default function globalSetup() { + spawnSync('prisma', ['generate', '--sql'], { + stdio: 'inherit', + }) + + spawnSync('prisma', ['migrate', 'reset', '--force', '--skip-seed'], { + stdio: 'inherit', + }) +} +``` + +> With these steps, I'm mirroring how my database is used in `package.json`. Take a peek there, if you're curious! + +And now to hook this global setup file in `playwright.config.ts`: + +```ts filename=playwright.config.ts add=5 +// ... + +export default defineConfig({ + // ... + globalSetup: './playwright.setup.ts', + // ... +}) +``` + +## Benefits of separate databases + +It should come as no surprise to you that isolation is one of the fundamental principles of reliable automated testing. This is why we strive toward having no shared state between tests, why we reset our mocks and prepare designated test resources. + +Isolation on the database level is equally crucial. You want each test run to start from a clean, controlled state, and produce deterministic outcome. As a benefit, notice that in our setup, we are calling `prisma migrate reset` _before_ the test run and not after. This means that every test run ends with `test.db` you can observe and debug in case something went wrong. + +## Alternative approaches + +The approach we took to provision a test database today is undoubtedly specific to our architecture. It will differ if you using something else than Prisma, and it will _certainly_ differ if you are using a database that's not file-based. diff --git a/exercises/03.guides/02.problem.test-data/README.mdx b/exercises/03.guides/02.problem.test-data/README.mdx index 3766109..1534bdc 100644 --- a/exercises/03.guides/02.problem.test-data/README.mdx +++ b/exercises/03.guides/02.problem.test-data/README.mdx @@ -1,15 +1,13 @@ # Test data -- How to efficiently create and tear down test data. - ## Your task πŸ‘¨β€πŸ’Ό In this one, your goal is to test that your app displays the list of user's notes. For that, the authenticated user must have some notes to display! The `user` we are creating via the `authenticate` function right now has no notes and you have to change that fact for this test case to pass. -🐨 First, implement the `createNotes` utility you will use in your tests to create notes for the authenticated user. Go to the `tests/e2e/utils.ts` file and follow the instruction to have that utility ready. +🐨 First, implement the `createNotes` utility you will use in your tests to create notes for the authenticated user. Go to the `tests/db-utils.ts` file and follow the instruction to have that utility ready. The `createNotes` utility will return a [_disposable object_](https://www.epicweb.dev/better-test-setup-with-disposable-objects). By doing so, you will be able to remove the notes you've created so they don't persist on the authenticated user across test runs. Remember that different resources might have different persistence time. For example, the user session persists across tests, but the notes you create _must not_. If they do, the next time you run this test case, the user will have twice the number of notes, causing the test to fail. Cleaning up after yourself is essential to maintaining reproducible tests. 🐨 Next, head to the test case in `tests/e2e/notes-list.test.ts` and complete it, using the newly-created `createNotes` utility. -🐨 Finally, run the `npm test` command to make sure that the test is passing. Trigger the test run multiple times to verify that your test setup cleans up after itself and the test results are idempotent. +🐨 Finally, run the `npm run test:e2e` command to make sure that the test is passing. Trigger the test run multiple times to verify that your test setup cleans up after itself and the test results are idempotent. diff --git a/exercises/03.guides/02.solution.test-data/README.mdx b/exercises/03.guides/02.solution.test-data/README.mdx index 9794bd8..9df84a8 100644 --- a/exercises/03.guides/02.solution.test-data/README.mdx +++ b/exercises/03.guides/02.solution.test-data/README.mdx @@ -1,9 +1,146 @@ # Test data -## Summary +## `createNotes()` utility -1. In `tests/db-utils.ts`, create a new utility called `createNotes`. Why. -1. Complete the test at `tests/e2e/notes-list.test.ts`. Use the new `createNotes` utility to seed some notes for the tested user as a part of the test setup. -1. Visit the page that lists all the user's notes. -1. Assert that all the notes are visible. Accessibility! -1. `npm run test:e2e`. +I will start from implementing the `createNotes()` utility that will help me seed any notes for any user in tests. + +```ts filename=tests/db-utils.ts add=3-24 +// ... + +export async function createNotes(args: { + ownerId: string + notes: Array> +}) { + const notes = await prisma.note.createManyAndReturn({ + data: args.notes.map((note) => { + return { + ownerId: args.ownerId, + ...note, + } + }), + }) + + return { + async [Symbol.asyncDispose]() { + await prisma.note.deleteMany({ + where: { ownerId: args.ownerId }, + }) + }, + values: notes, + } +} +``` + +Here, I'm implementing a utility not unlike `createUser()` or `createPasskey()` we've created before: a standalone function that uses the Prisma client to provision the server-side resources I need and returns a _disposable object_ that describes the cleanup. + +## Completing the test + +Now that I have the way to prepare the notes for any given user, I will head to the test file and use it to arrange my test: + +```ts filename=tests/e2e/notes-list.test.ts add=6-18 +import { createNotes } from '#tests/db-utils.ts' +import { test, expect } from '#tests/test-extend.ts' + +test('displays all user notes', async ({ navigate, authenticate, page }) => { + const { user } = await authenticate({ as: 'user' }) + await using _ = await createNotes({ + ownerId: user.id, + notes: [ + { + title: 'First Note', + content: 'Hello world', + }, + { + title: 'Second Note', + content: 'Goodbye cosmos', + }, + ], + }) +}) +``` + +This way, the authenticated user has two distinct note records in the database. Enough for me to assert them in the UI! + +Let's visit the notes page and make sure that those two notes are visible in the list of all user's notes: + +```ts filename=tests/e2e/notes-list.test.ts add=20,22-27 +import { createNotes } from '#tests/db-utils.ts' +import { test, expect } from '#tests/test-extend.ts' + +test('displays all user notes', async ({ navigate, authenticate, page }) => { + const { user } = await authenticate({ as: 'user' }) + await using _ = await createNotes({ + ownerId: user.id, + notes: [ + { + title: 'First Note', + content: 'Hello world', + }, + { + title: 'Second Note', + content: 'Goodbye cosmos', + }, + ], + }) + + await navigate('/users/:username/notes', { username: user.username }) + + const notes = page + .getByRole('list', { name: 'Notes' }) + .getByRole('listitem') + .getByRole('link') + + await expect(notes).toHaveText(['First Note', 'Second Note']) +}) +``` + +This is a good start. Now, I will visit each note and make sure that its contents is correct: + +```ts filename=tests/e2e/notes-list.test.ts add=29-33,35-39 +import { createNotes } from '#tests/db-utils.ts' +import { test, expect } from '#tests/test-extend.ts' + +test('displays all user notes', async ({ navigate, authenticate, page }) => { + const { user } = await authenticate({ as: 'user' }) + await using _ = await createNotes({ + ownerId: user.id, + notes: [ + { + title: 'First Note', + content: 'Hello world', + }, + { + title: 'Second Note', + content: 'Goodbye cosmos', + }, + ], + }) + + await navigate('/users/:username/notes', { username: user.username }) + + const notes = page + .getByRole('list', { name: 'Notes' }) + .getByRole('listitem') + .getByRole('link') + + await expect(notes).toHaveText(['First Note', 'Second Note']) + + await notes.getByText('First Note').click() + await expect(page.getByRole('heading', { name: 'First Note' })).toBeVisible() + await expect( + page.getByLabel('First Note').getByText('Hello world'), + ).toBeVisible() + + await notes.getByText('Second Note').click() + await expect(page.getByRole('heading', { name: 'Second Note' })).toBeVisible() + await expect( + page.getByLabel('Second Note').getByText('Goodbye cosmos'), + ).toBeVisible() +}) +``` + +And, of course, let's run the tests to see them passing. + +```sh +npm run test:e2e +``` diff --git a/exercises/03.guides/03.solution.recording-tests/README.mdx b/exercises/03.guides/03.solution.recording-tests/README.mdx index 78e086f..97b4868 100644 --- a/exercises/03.guides/03.solution.recording-tests/README.mdx +++ b/exercises/03.guides/03.solution.recording-tests/README.mdx @@ -1,32 +1,114 @@ # Recording tests -## Summary +## Playwright extension -1. Install the Playwright extension in VS Code. Show where and how. -2. Showcase the extension in the sidebar once it's installed. See how it merges with the same extension from Vitest. Neat! +The ability to record end-to-end tests is a part of the [Playwright's extension for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright). Make sure to have it installed! ---- +## Recording tests -1. Before recording the tests, check the "Show browser" option in the "Settings" of the extension. -2. Run the test. You can see that PW spawned in a headful mode, but the browser window doesn't close once the test is done. I will use that to record! -3. Right-click on the test case in the list of tests and "Go to Test". The test case opens. Put the cursor at the blank space before the test's end. -4. Finally, in the extension sidebar, click "Record at cursor". +There are a couple of thing I need to prepare before I record anything. ---- +First, is my test case. In `tests/e2e/profile-edit.test.ts`, I already have a test authenticated and waiting for me on the right page. -1. In the browser window, notice how an UI overlay appeared. It has the controls to help with recording my interactions with the app. -2. I scroll to the "Name" field on the page and change the user's name to "John Doe". -3. I click "Save changes" the same way the user would. -4. Now, let's verify that the change has been applied. I click on the user profile at the top right corner, then go to the "Profile" page. -5. I can see the updated user name, but now I need to help Playwright see it also. In the overlay panel, I choose "Assert visibility" and click on the heading element with the updated user name. +```ts filename=tests/e2e/profile-edit.test.ts +import { test, expect } from '#tests/test-extend.ts' ---- +test('saves changes to the name of the user', async ({ + navigate, + authenticate, + page, +}) => { + await authenticate({ as: 'user' }) + await navigate('/settings/profile') +}) +``` -1. Let's take a look at the recorded test. See how all my actions have been recorded, and the visibility assertion has been translated to the `expect` call. -2. I'm done with the recording, so all I have to do is close the browser window. -3. Now, let's run this test. Here's the result. +I will complete the rest of it by recording my interactions with the app in the actual browser. ---- +In the "Testing" tab added by the extension, I will check the "Show browser" option under "Settings". This will run my tests in a headful mode, showing me the browser window I will be interacting with. -1. The role of the recording. It's a tool to make the process faster not to _replace_ the process. Playwright won't always output the best locators, and sometimes your app will be to blame. If you lack proper accessibility attributes, Playwright will have no choice but to resort to locating the elements by their DOM order, for example, which is less than ideal. -2. Use the recording as a draft of the test. Polish it (or the app), introduce additional behaviors, like assertion messages or retryable assertions, and have yourself a great test done much faster. +Let's make sure my cursor is placed where I want the recording output to be written in my test case: + +```ts filename=tests/e2e/profile-edit.test.ts +test('saves changes to the name of the user', async ({ + navigate, + authenticate, + page, +}) => { + await authenticate({ as: 'user' }) + await navigate('/settings/profile') + + // <- Over here. +}) +``` + +Now, I will run my test. Immediately, I will see Playwright spawning the browser window with my application inside. It follows the steps I have outlined in my test case (authentication + navigation) and stops on the user profile page. + +![A screenshot of the automated browser window with the application open on the user profile page.](/images/03-03-headful-mode.png) + +I will head back to the extension panel and click "Record at cursor". + +Notice that once I do that, the browser window changes. There's a red overlay now, highlighting the elements under my cursor, and a control panel at the top that I can use to stop the recording, locate elements, assert visibility and text, and so on. + +![A screenshot of the automated browser window with the "Name" input highlighted in red, indicating a recording mode.](/images/03-03-recording-mode.png) + +With the recording running, I will enter a new name into the respective input in the UI and click the "Save changes" button to apply those changes. If I switch back to my test, I can see those interactions recorded by Playwright! + +```ts filename=tests/e2e/profile-edit.test.ts add=11-18 +import { test, expect } from '#tests/test-extend.ts' + +test('saves changes to the name of the user', async ({ + navigate, + authenticate, + page, +}) => { + await authenticate({ as: 'user' }) + await navigate('/settings/profile') + + await page.getByRole('textbox', { name: 'Username', exact: true }).click() + await page + .getByRole('textbox', { name: 'Username', exact: true }) + .press('ControlOrMeta+a') + await page + .getByRole('textbox', { name: 'Username', exact: true }) + .fill('John Doe') + await page.getByRole('button', { name: 'Save changes' }).click() +}) +``` + +This is neat! :tada: + +But the test doesn't end here. There's one important element missingβ€”assertions. And I can record those, too. + +With my recording still running, I will head to the user menu and click on the "Profile" link. Now, on the profile page, I will click on the "Assert visibility" button in the control panel (the one with the eye icon) and click on the user name heading on the page. This will result in the following assertion being automatically added in my test: + +```ts filename=tests/e2e/profile-edit.test.ts add=19 +import { test, expect } from '#tests/test-extend.ts' + +test('saves changes to the name of the user', async ({ + navigate, + authenticate, + page, +}) => { + await authenticate({ as: 'user' }) + await navigate('/settings/profile') + + await page.getByRole('textbox', { name: 'Username', exact: true }).click() + await page + .getByRole('textbox', { name: 'Username', exact: true }) + .press('ControlOrMeta+a') + await page + .getByRole('textbox', { name: 'Username', exact: true }) + .fill('John Doe') + await page.getByRole('button', { name: 'Save changes' }).click() + await expect(page.getByRole('heading', { name: 'John Doe' })).toBeVisible() +}) +``` + +I can stop the recording now by clicking the "Stop recording" button in the control panel. + +## Practical usage + +Recording your tests can be really handy. Just keep in mind that it's meant to _suppliment_ your process, not replace it. Your involvement is still needed, and I encourage you to always review the recording results and _fine-tune them_ to match your quality criteria. Recordings may fail or be imprecise, and it is your responsibility to bring them to top shape. + +**Recorded tests are still your tests**. diff --git a/exercises/03.guides/03.solution.recording-tests/tests/e2e/profile-edit.test.ts b/exercises/03.guides/03.solution.recording-tests/tests/e2e/profile-edit.test.ts index 422823e..5c2c238 100644 --- a/exercises/03.guides/03.solution.recording-tests/tests/e2e/profile-edit.test.ts +++ b/exercises/03.guides/03.solution.recording-tests/tests/e2e/profile-edit.test.ts @@ -8,10 +8,17 @@ test('saves changes to the name of the user', async ({ await authenticate({ as: 'user' }) await navigate('/settings/profile') - await page.getByRole('textbox', { name: 'Username' }).click() - await page.getByRole('textbox', { name: 'Username' }).press('ControlOrMeta+a') - await page.getByRole('textbox', { name: 'Username' }).fill('new_username') + await page.getByRole('textbox', { name: 'Name', exact: true }).click() + await page + .getByRole('textbox', { name: 'Name', exact: true }) + .press('ControlOrMeta+a') + await page + .getByRole('textbox', { name: 'Name', exact: true }) + .fill('John Doe') await page.getByRole('button', { name: 'Save changes' }).click() - await expect(page.getByRole('textbox', { name: 'Username' })).toBeVisible() + await page.getByRole('link', { name: 'John Doe' }).click() + await page.getByRole('menuitem', { name: 'Profile' }).click() + + await expect(page.getByRole('heading', { name: 'John Doe' })).toBeVisible() }) diff --git a/exercises/03.guides/04.solution.api-mocking/README.mdx b/exercises/03.guides/04.solution.api-mocking/README.mdx index b18d54a..826187f 100644 --- a/exercises/03.guides/04.solution.api-mocking/README.mdx +++ b/exercises/03.guides/04.solution.api-mocking/README.mdx @@ -1,16 +1,224 @@ # API mocking -1. Install `msw` and `@msw/playwright` dependencies. -2. Head to `tests/text-extend.ts` and in the `Fixtures` interface, describe a new fixture called `network`. Use the type from the `@msw/playwright` package to annotate it. -3. Implement the `network` fixture. Use `defineNetworkFixture()` from the package, provide it with the handlers and the browser `context`. Start, use, and stop, awaited. Then, set `{ auto: true }` because I want for API mocking to always be applied, even if this fixture isn't explicitly used in a test. -4. To make this fixture more flexible, I will create an option fixture called `handlers`. Annotate it as `Array`, then implement it as `[[], { option: true }]`. This way, individual tests and even entire test projects can define their own list of request handlers to affect the network. +First, I will install both `msw` and `@msw/playwright` as dependencies: ---- +```sh +npm i msw @msw/playwright --save-dev +``` -1. Now, let's use `network` in the test. Reference it and use `network.use()` to apply request handlers for the Google API requests I want to intercept. -2. Respond with a mocked response (use the `HttpResponse` helper from `msw`). +## Creating the `network` fixture ---- +In `tests/text-extend.ts`, I will add a new fixture to the `Fixtures` interface called `network`. As its type, I will use the `NetworkFixture` type exported from `@msw/playwright`. + +```ts filename=tests/text-extend.ts add=1,8 +import { type NetworkFixture } from '@msw/playwright' + +interface Fixtures { + navigate: ( + ...args: Parameters> + ) => Promise + authenticate: AuthenticateFunction<[typeof user]> + network: NetworkFixture +} +``` + +Down below, in the `testBase.extend()` call, I will implement the `network` fixture like this: + +```ts filename=tests/text-extend.ts add=1,7-18 +import { type NetworkFixture } from '@msw/playwright' + +// ... + +export const test = testBase.extend({ + // ... + network: [ + async ({ context }, use) => { + const network = defineNetworkFixture({ + context, + handlers: [], + }) + await network.enable() + await use(network) + await network.disable() + }, + { auto: true }, + ], +}) +``` + +The `defineNetworkFixture()` function from `@msw/playwright` configures the network object by accepting the browser `context` and a list of initial `handlers`. + +Notice the `auto: true` set for this fixture, which makes it automatic (gets applied to all tests regardless if they use it explicitly). + +## Using the `network` fixture + +Now, I can use the `network` fixture in my tests to describe what network behaviors I want. In my case, I want to intercept the request to the Nominatim suggestions service and respond to it with a mocked response. + +```ts filename=tests/e2e/notes-create.test.ts add=1,2,8,11-29 +import { http, HttpResponse } from 'msw' +import { type NominatimSearchResponse } from '#app/routes/users+/$username_+/__note-editor.tsx' +import { test, expect } from '#tests/test-extend.ts' + +test('displays location suggestions when creating a new note', async ({ + authenticate, + navigate, + network, + page, +}) => { + network.use( + http.get( + 'https://nominatim.openstreetmap.org/search', + () => { + return HttpResponse.json([ + { + place_id: 1, + addresstype: 'city', + display_name: 'San Francisco', + }, + { + place_id: 2, + addresstype: 'city', + display_name: 'San Jose', + }, + ]) + }, + ), + ) +}) +``` + +With the `network.use()` call, I'm adding a per-test override with the list of request handlers describing my network. You can see a matching `http.get()` to the `https://nominatim.openstreetmap.org/search` endpoint, as well as the mocked `HttpResponse.json()` that will be used to respond to that request. + +## Completing the test + +With a reproduction network, I can complete the rest of the test, which will consist of me creating a new note and choosing one of the mocked location suggestions in the UI. + +```ts filename=tests/e2e/notes-create.test.ts add=27-49 +test('displays location suggestions when creating a new note', async ({ + authenticate, + navigate, + network, + page, +}) => { + network.use( + http.get( + 'https://nominatim.openstreetmap.org/search', + () => { + return HttpResponse.json([ + { + place_id: 1, + addresstype: 'city', + display_name: 'San Francisco', + }, + { + place_id: 2, + addresstype: 'city', + display_name: 'San Jose', + }, + ]) + }, + ), + ) + + const { user } = await authenticate({ as: 'user' }) + await navigate('/users/:username/notes/new', { username: user.username }) + await page.waitForLoadState('networkidle') + + await page.getByLabel('Title').fill('My note') + await page.getByLabel('Content').fill('Hello world') + + const locationInput = page.getByLabel('Location') + await locationInput.fill('San') + await expect( + page + .getByRole('listbox', { name: 'Location suggestions' }) + .getByRole('option'), + ).toHaveText(['San Francisco', 'San Jose']) + + await page.getByRole('option', { name: 'San Francisco' }).click() + await expect(locationInput).toHaveValue('San Francisco') + + await page.getByRole('button', { name: 'Submit' }).click() + await expect(page.getByRole('heading', { name: 'My note' })).toBeVisible() + await expect(page.getByRole('note', { name: 'Location' })).toHaveText( + 'San Francisco', + ) +}) +``` + +Notice I'm using `await page.waitForLoadState('networkidle')` after the navigation. This prevents Playwright from proceeding with my test actions faster than the browser has a chance to load the client-side code responsible for the location suggestions. + +## Creating a `handlers` option + +Providing request handlers in your test cases isn't the only way to control the network. In fact, you can benefit from Playwright's "option fixtures" that can be configured both on the test suite and a test project levels. + +To do that, let's add a new fixture called `handlers` to the `Fixtures` interface: + +```ts filename=tests/text-extend.ts add=1,9 +import { type AnyHandler } from 'msw' +import { type NetworkFixture } from '@msw/playwright' + +interface Fixtures { + navigate: ( + ...args: Parameters> + ) => Promise + authenticate: AuthenticateFunction<[typeof user]> + handlers: Array + network: NetworkFixture +} +``` + +And implement this fixture as an option by setting its `option` property to `true` during the fixture declaration: + +```ts filename=tests/text-extend.ts add=6 +// ... + +export const test = testBase.extend({ + // .. + // πŸ‘‡ Default value. + handlers: [[], { option: true }], + // πŸ‘† Fixture options. + // .. +}) +``` + +Now, you can provide the value for `handlers` on the test suite level via `test.use()`: + +```ts filename=some.test.ts +test.use({ + handlers: [http.get('/resource', resolver)], +}) + +test('...', () => {}) +``` + +As well as on the test project level in your `playwright.config.ts`: + +```ts filename=tests/test-extend.ts add=3-5 +// ... + +export interface TestOptions { + handlers: Array +} + +// ... +``` + +```ts filename=playwright.config.ts add=1,9 +import { type TestOptions } from './tests/test-extend' + +export default defineConfig({ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + handlers: [http.get('/resource', resolver)], + }, + }, + ], +}) +``` ## Related materials diff --git a/exercises/03.guides/README.mdx b/exercises/03.guides/README.mdx index 50a8673..5236c5e 100644 --- a/exercises/03.guides/README.mdx +++ b/exercises/03.guides/README.mdx @@ -1 +1,9 @@ # Guides + +Once you put your setup blocks in place, your end-to-end test becomes not taht different from a component test. And that's great because it means that all the best practices of component testing that you already know still apply in the end-to-end tests! From writing accessibility-first locators to acting and asserting from the user's perspective. + +This is a great reminder that all of these testing levels aren't separate, disconnected universes, but instead are _different edges_ of the same thingβ€”automated testing. And the only thing that changes between them is how much you are "zoomed in" or "zoomed out" on your tested system. + +When talking about end-to-end testing, you are pretty much zoomed out as you would expect. But despite that, youi still want to focus on testing isolated behaviorsβ€”or user journeys. There are a lot of things that may come in the way of that, just due to the sheer amount of the moving parts involved in an end-to-end test. + +This is why the setup is extremely important. So in this exercise block, I wanted us to go through the things that can help you elevate your test setup. diff --git a/exercises/04.debugging/01.solution.ui-mode/README.mdx b/exercises/04.debugging/01.solution.ui-mode/README.mdx index 0d82515..4c05090 100644 --- a/exercises/04.debugging/01.solution.ui-mode/README.mdx +++ b/exercises/04.debugging/01.solution.ui-mode/README.mdx @@ -1,15 +1,64 @@ # UI mode -1. Run the tests in the UI mode via `npm run test:e2e -- --ui`. -2. See that it fails on the `getByRole('heading', { name: 'hv_nestor_windler70\'s notes' })` locator. Spot that in the UI, the heading element _is_ there, but it has a different text. -3. Fix the issue with the locator in the test (`user.username` -> `user.name`). -4. Rerun the test in the UI mode and see it passing. +## Using UI mode ---- +Playwright comes with a UI mode built in. To run your tests in the UI mode, provide the `--ui` option to the `playwright test` command (or your custom script using it, like our `npm run test:e2e`): -1. Practical use and application of the UI mode. When do you use it? How? -2. UI mode is a debugging tool for you. It has no effect on CI, where tests run headless. -3. Recommend creating a designated command for the `--ui` shortcut. +```sh +npm run test:e2e -- --ui +``` + +Running this will launch the UI mode window that's split into several panels: + +- The list of tests and test controls on the left; +- The timeline at the top; +- The "Actions" panel in the middle; +- The browser preview on the right; +- And a whole array of panels at the bottom ("Locator", "Source", "Call", etc). + +![A screenshot of the UI mode open, listing a single test](/images/04-01-ui-mode.png) + +All of those panels come into play when dealing with failing tests, just like the one we have on our hands right now. + +## Debugging the test + +If I run the test from the UI mode, I can see it failing at the following step: + +``` +Displays the user notes page +getByRole("heading', { name: 'cp_cortez_ferry43\'s notes' }) +``` + +But I can clearly see this element in the browser preview! For a quick sanity check, I grab the "Locator" tool from the browser preview panel and click on the problematic element. Playwright kindly prints its actual locator and the ARIA snapshot for me to explore: + +``` +getByRole('link', { name: 'Cortes Ferry\'s Notes' }) + +- link "Cortez Ferry's Notes": + - /url: link:///users/cp_cortez_ferry43 + - heading "Cortez Ferry's Notes" [level=1] +``` + +Aha! So it looks like I'm using `username` for this element's accessible name instead of `user.name` 🀦. A quick mistake that I can quickly fix in the test: + +```ts filename=tests/e2e/notes-search.test.ts remove=5 add=6 +// ... + +await page.getByRole('link', { name: 'My notes' }).click() +await expect( + page.getByRole('heading', { name: `${user.username}'s notes` }), + page.getByRole('heading', { name: `${user.name}'s notes` }), + 'Displays the user notes page', +).toBeVisible() + +// ... +``` + +From within the same UI mode, I can rerun the test to confirm my fix and see it passing now! + +## UI mode in practice + +I highly recommend incorporating the UI mode into your testing workflow from day one. Not only is it a great debugging instrument, giving you an overview to, well, _everything_ that's happening in your tests, but it's a fantastic, visual way to get feedback as you're writing your tests as well. ## Related materials diff --git a/exercises/04.debugging/03.solution.live-debugging/.env b/exercises/04.debugging/03.solution.live-debugging/.env deleted file mode 100644 index e45c443..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/.env +++ /dev/null @@ -1,29 +0,0 @@ -LITEFS_DIR="/litefs/data" -DATABASE_PATH="./prisma/data.db" -DATABASE_URL="file:./data.db?connection_limit=1" -CACHE_DATABASE_PATH="./other/cache.db" -SESSION_SECRET="dcfaa48e7e20dce686b8636a68fa26f0" -HONEYPOT_SECRET="super-duper-s3cret" -RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh" -SENTRY_DSN="your-dsn" - -# this is set to a random value in the Dockerfile -INTERNAL_COMMAND_TOKEN="some-made-up-token" - -# the mocks and some code rely on these two being prefixed with "MOCK_" -# if they aren't then the real github api will be attempted -GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID" -GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET" -GITHUB_TOKEN="MOCK_GITHUB_TOKEN" -GITHUB_REDIRECT_URI="https://example.com/auth/github/callback" - -# set this to false to prevent search engines from indexing the website -# default to allow indexing for seo safety -ALLOW_INDEXING="true" - -# Tigris Object Storage (S3-compatible) Configuration -AWS_ACCESS_KEY_ID="mock-access-key" -AWS_SECRET_ACCESS_KEY="mock-secret-key" -AWS_REGION="auto" -AWS_ENDPOINT_URL_S3="https://fly.storage.tigris.dev" -BUCKET_NAME="mock-bucket" diff --git a/exercises/04.debugging/03.solution.live-debugging/.env.example b/exercises/04.debugging/03.solution.live-debugging/.env.example deleted file mode 100644 index c5daeb2..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/.env.example +++ /dev/null @@ -1,29 +0,0 @@ -LITEFS_DIR="/litefs/data" -DATABASE_PATH="./prisma/data.db" -DATABASE_URL="file:./data.db?connection_limit=1" -CACHE_DATABASE_PATH="./other/cache.db" -SESSION_SECRET="super-duper-s3cret" -HONEYPOT_SECRET="super-duper-s3cret" -RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh" -SENTRY_DSN="your-dsn" - -# this is set to a random value in the Dockerfile -INTERNAL_COMMAND_TOKEN="some-made-up-token" - -# the mocks and some code rely on these two being prefixed with "MOCK_" -# if they aren't then the real github api will be attempted -GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID" -GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET" -GITHUB_TOKEN="MOCK_GITHUB_TOKEN" -GITHUB_REDIRECT_URI="https://example.com/auth/github/callback" - -# set this to false to prevent search engines from indexing the website -# default to allow indexing for seo safety -ALLOW_INDEXING="true" - -# Tigris Object Storage (S3-compatible) Configuration -AWS_ACCESS_KEY_ID="mock-access-key" -AWS_SECRET_ACCESS_KEY="mock-secret-key" -AWS_REGION="auto" -AWS_ENDPOINT_URL_S3="https://fly.storage.tigris.dev" -BUCKET_NAME="mock-bucket" diff --git a/exercises/04.debugging/03.solution.live-debugging/.env.test b/exercises/04.debugging/03.solution.live-debugging/.env.test deleted file mode 100644 index 0b66cfe..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/.env.test +++ /dev/null @@ -1 +0,0 @@ -DATABASE_URL="file:./test.db?connection_limit=1" \ No newline at end of file diff --git a/exercises/04.debugging/03.solution.live-debugging/.github/PULL_REQUEST_TEMPLATE.md b/exercises/04.debugging/03.solution.live-debugging/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 84a2084..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,15 +0,0 @@ - - -## Test Plan - - - -## Checklist - -- [ ] Tests updated -- [ ] Docs updated - -## Screenshots - - diff --git a/exercises/04.debugging/03.solution.live-debugging/.github/workflows/deploy.yml b/exercises/04.debugging/03.solution.live-debugging/.github/workflows/deploy.yml deleted file mode 100644 index 49cbbad..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/.github/workflows/deploy.yml +++ /dev/null @@ -1,229 +0,0 @@ -name: πŸš€ Deploy -on: - push: - branches: - - main - - dev - pull_request: {} - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - actions: write - contents: read - -jobs: - lint: - name: ⬣ ESLint - runs-on: ubuntu-22.04 - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 - - - name: βŽ” Setup node - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: πŸ“₯ Download deps - uses: bahmutov/npm-install@v1 - - - name: πŸ„ Copy test env vars - run: cp .env.example .env - - - name: πŸ›  Setup Database - run: npx prisma migrate deploy && npx prisma generate --sql - - - name: πŸ”¬ Lint - run: npm run lint - - typecheck: - name: Κ¦ TypeScript - runs-on: ubuntu-22.04 - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 - - - name: βŽ” Setup node - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: πŸ“₯ Download deps - uses: bahmutov/npm-install@v1 - - - name: πŸ— Build - run: npm run build - - - name: πŸ„ Copy test env vars - run: cp .env.example .env - - - name: πŸ›  Setup Database - run: npx prisma migrate deploy && npx prisma generate --sql - - - name: πŸ”Ž Type check - run: npm run typecheck --if-present - - vitest: - name: ⚑ Vitest - runs-on: ubuntu-22.04 - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 - - - name: βŽ” Setup node - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: πŸ“₯ Download deps - uses: bahmutov/npm-install@v1 - - - name: πŸ„ Copy test env vars - run: cp .env.example .env - - - name: πŸ›  Setup Database - run: npx prisma migrate deploy && npx prisma generate --sql - - - name: ⚑ Run vitest - run: npm run test -- --coverage - - playwright: - name: 🎭 Playwright - runs-on: ubuntu-22.04 - timeout-minutes: 60 - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 - - - name: πŸ„ Copy test env vars - run: cp .env.example .env - - - name: βŽ” Setup node - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: πŸ“₯ Download deps - uses: bahmutov/npm-install@v1 - - - name: πŸ“₯ Install Playwright Browsers - run: npm run test:e2e:install - - - name: πŸ›  Setup Database - run: npx prisma migrate deploy && npx prisma generate --sql - - - name: 🏦 Cache Database - id: db-cache - uses: actions/cache@v4 - with: - path: prisma/data.db - key: - db-cache-schema_${{ hashFiles('./prisma/schema.prisma') - }}-migrations_${{ hashFiles('./prisma/migrations/*/migration.sql') - }} - - - name: 🌱 Seed Database - if: steps.db-cache.outputs.cache-hit != 'true' - run: npx prisma migrate reset --force - - - name: πŸ— Build - run: npm run build - - - name: 🎭 Playwright tests - run: npx playwright test - - - name: πŸ“Š Upload report - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 - - container: - name: πŸ“¦ Prepare Container - runs-on: ubuntu-24.04 - # only prepare container on pushes - if: ${{ github.event_name == 'push' }} - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 - with: - fetch-depth: 50 - - - name: πŸ‘€ Read app name - uses: SebRollen/toml-action@v1.2.0 - id: app_name - with: - file: 'fly.toml' - field: 'app' - - - name: 🎈 Setup Fly - uses: superfly/flyctl-actions/setup-flyctl@1.5 - - - name: πŸ“¦ Build Staging Container - if: ${{ github.ref == 'refs/heads/dev' }} - run: | - flyctl deploy \ - --build-only \ - --push \ - --image-label ${{ github.sha }} \ - --build-arg COMMIT_SHA=${{ github.sha }} \ - --app ${{ steps.app_name.outputs.value }}-staging - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - - - name: πŸ“¦ Build Production Container - if: ${{ github.ref == 'refs/heads/main' }} - run: | - flyctl deploy \ - --build-only \ - --push \ - --image-label ${{ github.sha }} \ - --build-arg COMMIT_SHA=${{ github.sha }} \ - --build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \ - --app ${{ steps.app_name.outputs.value }} - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - - deploy: - name: πŸš€ Deploy - runs-on: ubuntu-24.04 - needs: [lint, typecheck, vitest, playwright, container] - # only deploy on pushes - if: ${{ github.event_name == 'push' }} - steps: - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 - with: - fetch-depth: '50' - - - name: πŸ‘€ Read app name - uses: SebRollen/toml-action@v1.2.0 - id: app_name - with: - file: 'fly.toml' - field: 'app' - - - name: 🎈 Setup Fly - uses: superfly/flyctl-actions/setup-flyctl@1.5 - - - name: πŸš€ Deploy Staging - if: ${{ github.ref == 'refs/heads/dev' }} - run: | - flyctl deploy \ - --image "registry.fly.io/${{ steps.app_name.outputs.value }}-staging:${{ github.sha }}" \ - --app ${{ steps.app_name.outputs.value }}-staging - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - - - name: πŸš€ Deploy Production - if: ${{ github.ref == 'refs/heads/main' }} - run: | - flyctl deploy \ - --image "registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.sha }}" - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/exercises/04.debugging/03.solution.live-debugging/.gitignore b/exercises/04.debugging/03.solution.live-debugging/.gitignore deleted file mode 100644 index e39d8e3..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/.gitignore +++ /dev/null @@ -1,28 +0,0 @@ -node_modules -.DS_store - -/build -/server-build - -.cache - -/prisma/data.db -/prisma/data.db-journal -/tests/prisma - -/test-results/ -/playwright-report/ -/playwright/.cache/ -/tests/fixtures/email/ -/tests/fixtures/uploaded/ -/tests/fixtures/openimg/ -/coverage - -/other/cache.db - -# Easy way to create temporary files/folders that won't accidentally be added to git -*.local.* - -# generated files -/app/components/ui/icons -.react-router/ diff --git a/exercises/04.debugging/03.solution.live-debugging/.npmrc b/exercises/04.debugging/03.solution.live-debugging/.npmrc deleted file mode 100644 index 4c606f8..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/.npmrc +++ /dev/null @@ -1,4 +0,0 @@ -legacy-peer-deps=true -registry=https://registry.npmjs.org/ -audit=false -fund=false diff --git a/exercises/04.debugging/03.solution.live-debugging/.prettierignore b/exercises/04.debugging/03.solution.live-debugging/.prettierignore deleted file mode 100644 index f022d02..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/.prettierignore +++ /dev/null @@ -1,15 +0,0 @@ -node_modules - -/build -/public/build -/server-build -.env - -/test-results/ -/playwright-report/ -/playwright/.cache/ -/tests/fixtures/email/*.json -/coverage -/prisma/migrations - -package-lock.json diff --git a/exercises/04.debugging/03.solution.live-debugging/.vscode/extensions.json b/exercises/04.debugging/03.solution.live-debugging/.vscode/extensions.json deleted file mode 100644 index f724eea..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/.vscode/extensions.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "recommendations": [ - "bradlc.vscode-tailwindcss", - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", - "prisma.prisma", - "qwtel.sqlite-viewer", - "yoavbls.pretty-ts-errors", - "github.vscode-github-actions", - "ms-playwright.playwright" - ] -} diff --git a/exercises/04.debugging/03.solution.live-debugging/.vscode/remix.code-snippets b/exercises/04.debugging/03.solution.live-debugging/.vscode/remix.code-snippets deleted file mode 100644 index 079bee5..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/.vscode/remix.code-snippets +++ /dev/null @@ -1,80 +0,0 @@ -{ - "loader": { - "prefix": "/loader", - "scope": "typescriptreact,javascriptreact,typescript,javascript", - "body": [ - "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", - "", - "export async function loader({ request }: Route.LoaderArgs) {", - " return {}", - "}", - ], - }, - "action": { - "prefix": "/action", - "scope": "typescriptreact,javascriptreact,typescript,javascript", - "body": [ - "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", - "", - "export async function action({ request }: Route.ActionArgs) {", - " return {}", - "}", - ], - }, - "default": { - "prefix": "/default", - "scope": "typescriptreact,javascriptreact,typescript,javascript", - "body": [ - "export default function ${TM_FILENAME_BASE/[^a-zA-Z0-9]*([a-zA-Z0-9])([a-zA-Z0-9]*)/${1:/capitalize}${2}/g}() {", - " return (", - "
", - "

Unknown Route

", - "
", - " )", - "}", - ], - }, - "headers": { - "prefix": "/headers", - "scope": "typescriptreact,javascriptreact,typescript,javascript", - "body": [ - "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", - "export const headers: Route.HeadersFunction = ({ loaderHeaders }) => ({", - " 'Cache-Control': loaderHeaders.get('Cache-Control') ?? '',", - "})", - ], - }, - "links": { - "prefix": "/links", - "scope": "typescriptreact,javascriptreact,typescript,javascript", - "body": [ - "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", - "", - "export const links: Route.LinksFunction = () => {", - " return []", - "}", - ], - }, - "meta": { - "prefix": "/meta", - "scope": "typescriptreact,javascriptreact,typescript,javascript", - "body": [ - "import { type Route } from \"./+types/${TM_FILENAME_BASE}.ts\"", - "", - "export const meta: Route.MetaFunction = ({ data }) => [{", - " title: 'Title',", - "}]", - ], - }, - "shouldRevalidate": { - "prefix": "/shouldRevalidate", - "scope": "typescriptreact,javascriptreact,typescript,javascript", - "body": [ - "import { type ShouldRevalidateFunctionArgs } from 'react-router'", - "", - "export function shouldRevalidate({ defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {", - " return defaultShouldRevalidate", - "}", - ], - }, -} diff --git a/exercises/04.debugging/03.solution.live-debugging/.vscode/settings.json b/exercises/04.debugging/03.solution.live-debugging/.vscode/settings.json deleted file mode 100644 index 9ec5cad..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/.vscode/settings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "typescript.preferences.autoImportFileExcludePatterns": [ - "@remix-run/server-runtime", - "express", - "@radix-ui/**", - "@react-email/**", - "node:stream/consumers", - "node:test", - "node:console" - ], - "workbench.editorAssociations": { - "*.db": "sqlite-viewer.view" - } -} diff --git a/exercises/04.debugging/03.solution.live-debugging/README.mdx b/exercises/04.debugging/03.solution.live-debugging/README.mdx deleted file mode 100644 index aadc8bf..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/README.mdx +++ /dev/null @@ -1,16 +0,0 @@ -# Live debugging - -## Root cause - -- The login button locator was wrong: - -```ts remove=1 add=2 -await expect(page.getByRole('link', { name: 'Login' })).toBeVisible() -await expect(page.getByRole('link', { name: 'Log in' })).toBeVisible() -``` - -- Sometimes elements aren't where we think they are. Sometimes they lack accessibility or we use wrong locators. But sometimes we just make mistakes! No matter what happens, using a debugger is so helpful. - -## Related materials - -- [Debugging tests](https://playwright.dev/docs/debug) diff --git a/exercises/04.debugging/03.solution.live-debugging/app/assets/favicons/apple-touch-icon.png b/exercises/04.debugging/03.solution.live-debugging/app/assets/favicons/apple-touch-icon.png deleted file mode 100644 index 8bf4632..0000000 Binary files a/exercises/04.debugging/03.solution.live-debugging/app/assets/favicons/apple-touch-icon.png and /dev/null differ diff --git a/exercises/04.debugging/03.solution.live-debugging/app/assets/favicons/favicon.svg b/exercises/04.debugging/03.solution.live-debugging/app/assets/favicons/favicon.svg deleted file mode 100644 index 72be6f0..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/app/assets/favicons/favicon.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/exercises/04.debugging/03.solution.live-debugging/app/components/error-boundary.tsx b/exercises/04.debugging/03.solution.live-debugging/app/components/error-boundary.tsx deleted file mode 100644 index b881ed9..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/app/components/error-boundary.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { captureException } from '@sentry/react-router' -import { useEffect, type ReactElement } from 'react' -import { - type ErrorResponse, - isRouteErrorResponse, - useParams, - useRouteError, -} from 'react-router' -import { getErrorMessage } from '#app/utils/misc' - -type StatusHandler = (info: { - error: ErrorResponse - params: Record -}) => ReactElement | null - -export function GeneralErrorBoundary({ - defaultStatusHandler = ({ error }) => ( -

- {error.status} {error.data} -

- ), - statusHandlers, - unexpectedErrorHandler = (error) =>

{getErrorMessage(error)}

, -}: { - defaultStatusHandler?: StatusHandler - statusHandlers?: Record - unexpectedErrorHandler?: (error: unknown) => ReactElement | null -}) { - const error = useRouteError() - const params = useParams() - const isResponse = isRouteErrorResponse(error) - - if (typeof document !== 'undefined') { - console.error(error) - } - - useEffect(() => { - if (isResponse) return - - captureException(error) - }, [error, isResponse]) - - return ( -
- {isResponse - ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({ - error, - params, - }) - : unexpectedErrorHandler(error)} -
- ) -} diff --git a/exercises/04.debugging/03.solution.live-debugging/app/components/floating-toolbar.tsx b/exercises/04.debugging/03.solution.live-debugging/app/components/floating-toolbar.tsx deleted file mode 100644 index 5e0e823..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/app/components/floating-toolbar.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export const floatingToolbarClassName = - 'absolute bottom-3 inset-x-3 flex items-center gap-2 rounded-lg bg-muted/80 p-4 pl-5 shadow-xl shadow-accent backdrop-blur-xs md:gap-4 md:pl-7 justify-end' diff --git a/exercises/04.debugging/03.solution.live-debugging/app/components/forms.tsx b/exercises/04.debugging/03.solution.live-debugging/app/components/forms.tsx deleted file mode 100644 index abaabf7..0000000 --- a/exercises/04.debugging/03.solution.live-debugging/app/components/forms.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { useInputControl } from '@conform-to/react' -import { REGEXP_ONLY_DIGITS_AND_CHARS, type OTPInputProps } from 'input-otp' -import React, { useId } from 'react' -import { Checkbox, type CheckboxProps } from './ui/checkbox.tsx' -import { - InputOTP, - InputOTPGroup, - InputOTPSeparator, - InputOTPSlot, -} from './ui/input-otp.tsx' -import { Input } from './ui/input.tsx' -import { Label } from './ui/label.tsx' -import { Textarea } from './ui/textarea.tsx' - -export type ListOfErrors = Array | null | undefined - -export function ErrorList({ - id, - errors, -}: { - errors?: ListOfErrors - id?: string -}) { - const errorsToRender = errors?.filter(Boolean) - if (!errorsToRender?.length) return null - return ( -
    - {errorsToRender.map((e) => ( -
  • - {e} -
  • - ))} -
- ) -} - -export function Field({ - labelProps, - inputProps, - errors, - className, -}: { - labelProps: React.LabelHTMLAttributes - inputProps: React.InputHTMLAttributes - errors?: ListOfErrors - className?: string -}) { - const fallbackId = useId() - const id = inputProps.id ?? fallbackId - const errorId = errors?.length ? `${id}-error` : undefined - return ( -
-
- ) -} - -export function OTPField({ - labelProps, - inputProps, - errors, - className, -}: { - labelProps: React.LabelHTMLAttributes - inputProps: Partial - errors?: ListOfErrors - className?: string -}) { - const fallbackId = useId() - const id = inputProps.id ?? fallbackId - const errorId = errors?.length ? `${id}-error` : undefined - return ( -
-
- ) -} - -export function TextareaField({ - labelProps, - textareaProps, - errors, - className, -}: { - labelProps: React.LabelHTMLAttributes - textareaProps: React.TextareaHTMLAttributes - errors?: ListOfErrors - className?: string -}) { - const fallbackId = useId() - const id = textareaProps.id ?? textareaProps.name ?? fallbackId - const errorId = errors?.length ? `${id}-error` : undefined - return ( -
-