diff --git a/.pnp.cjs b/.pnp.cjs index 0d52ef1..e24a9ea 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -2612,6 +2612,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ + ["npm:18.11.9", {\ + "packageLocation": "./.yarn/cache/@types-node-npm-18.11.9-d21dd6ec05-cc0aae109e.zip/node_modules/@types/node/",\ + "packageDependencies": [\ + ["@types/node", "npm:18.11.9"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:18.8.2", {\ "packageLocation": "./.yarn/cache/@types-node-npm-18.8.2-3df86443f1-b7c74dea0e.zip/node_modules/@types/node/",\ "packageDependencies": [\ @@ -4321,6 +4328,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["ieee754", "npm:1.2.1"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:6.0.3", {\ + "packageLocation": "./.yarn/cache/buffer-npm-6.0.3-cd90dfedfe-5ad23293d9.zip/node_modules/buffer/",\ + "packageDependencies": [\ + ["buffer", "npm:6.0.3"],\ + ["base64-js", "npm:1.5.1"],\ + ["ieee754", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["buffer-from", [\ @@ -5840,9 +5856,11 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "packageDependencies": [\ ["example", "workspace:packages/example"],\ ["@playwright/test", "npm:1.27.1"],\ + ["@types/node", "npm:18.11.9"],\ ["@types/react", "npm:18.0.21"],\ ["@types/react-dom", "npm:18.0.6"],\ ["@vitejs/plugin-react", "virtual:0f582dce106adea8a36c1b47cdf139a9f3b829e6f9f0e7abe55d280edb02ebeb0346b97ace0f022e744e8d1d612628f6a3678aa1f9693c8d73c63fcb9908fd80#npm:2.1.0"],\ + ["buffer", "npm:6.0.3"],\ ["msw", "virtual:0f582dce106adea8a36c1b47cdf139a9f3b829e6f9f0e7abe55d280edb02ebeb0346b97ace0f022e744e8d1d612628f6a3678aa1f9693c8d73c63fcb9908fd80#npm:0.47.4"],\ ["playwright-msw", "virtual:0f582dce106adea8a36c1b47cdf139a9f3b829e6f9f0e7abe55d280edb02ebeb0346b97ace0f022e744e8d1d612628f6a3678aa1f9693c8d73c63fcb9908fd80#workspace:packages/playwright-msw"],\ ["react", "npm:18.2.0"],\ diff --git a/.yarn/cache/@types-node-npm-18.11.9-d21dd6ec05-cc0aae109e.zip b/.yarn/cache/@types-node-npm-18.11.9-d21dd6ec05-cc0aae109e.zip new file mode 100644 index 0000000..341a77a Binary files /dev/null and b/.yarn/cache/@types-node-npm-18.11.9-d21dd6ec05-cc0aae109e.zip differ diff --git a/.yarn/cache/buffer-npm-6.0.3-cd90dfedfe-5ad23293d9.zip b/.yarn/cache/buffer-npm-6.0.3-cd90dfedfe-5ad23293d9.zip new file mode 100644 index 0000000..dbf2748 Binary files /dev/null and b/.yarn/cache/buffer-npm-6.0.3-cd90dfedfe-5ad23293d9.zip differ diff --git a/packages/example/package.json b/packages/example/package.json index 5b5acb8..96ed599 100755 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -10,6 +10,7 @@ "test:report": "yarn playwright show-report tests/playwright/report" }, "dependencies": { + "buffer": "^6.0.3", "msw": "0.47.4", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -18,6 +19,7 @@ }, "devDependencies": { "@playwright/test": "^1.27.1", + "@types/node": "^18.11.9", "@types/react": "^18.0.21", "@types/react-dom": "^18.0.6", "@vitejs/plugin-react": "^2.1.0", diff --git a/packages/example/src/components/login-form.tsx b/packages/example/src/components/login-form.tsx index 63babcb..b3d741f 100644 --- a/packages/example/src/components/login-form.tsx +++ b/packages/example/src/components/login-form.tsx @@ -1,6 +1,10 @@ import { FC, FormEvent, useCallback } from "react"; -import { useMutation } from "react-query"; -import { LoginApiResponse, LoginApiRequestBody } from "../types/api"; +import { useMutation, useQuery } from "react-query"; +import { + GetSessionResponse, + PostSessionResponse, + PostSessionRequestBody, +} from "../types/session"; const getLoginFormValues = ({ elements }: HTMLFormElement) => { const usernameElement = elements.namedItem("username") as HTMLInputElement; @@ -20,15 +24,29 @@ const getLoginErrorMessage = ({ status }: Response) => { } }; +const useSessionQuery = () => { + return useQuery<{ status: number; session: GetSessionResponse | null }>( + ["session"], + async () => { + const response = await fetch("/api/session"); + return { + status: response.status, + session: response.status === 200 ? await response.json() : null, + }; + }, + { retry: false, refetchOnWindowFocus: false, refetchOnMount: false } + ); +}; + const useLoginMutation = () => { return useMutation< - LoginApiResponse, + PostSessionResponse, { message: string }, - LoginApiRequestBody + PostSessionRequestBody >( ["login"], async (credentials: { username: string; password: string }) => { - const response = await fetch("/api/login", { + const response = await fetch("/api/session", { method: "POST", body: JSON.stringify(credentials), }); @@ -43,46 +61,94 @@ const useLoginMutation = () => { ); }; +const useLogoutMutation = () => { + return useMutation( + ["login"], + async () => { + const response = await fetch("/api/session", { + method: "DELETE", + }); + + if (response.status !== 200) { + throw new Error("Failed to logout"); + } + }, + { retry: false } + ); +}; + export const LoginForm: FC = () => { + const sessionQuery = useSessionQuery(); const loginMutation = useLoginMutation(); - + const logoutMutation = useLogoutMutation(); const handleFormSubmit = useCallback( (event: FormEvent) => { event.preventDefault(); - loginMutation.mutate(getLoginFormValues(event.target as HTMLFormElement)); + loginMutation.mutate( + getLoginFormValues(event.target as HTMLFormElement), + { + onSuccess: () => { + sessionQuery.refetch(); + }, + } + ); }, [loginMutation.mutate] ); - if (loginMutation.isSuccess) { - return
Successfully signed in!
; + const handleLogoutButtonPress = useCallback(() => { + if (loginMutation.isIdle) { + logoutMutation.mutate(undefined, { + onSuccess: () => { + sessionQuery.refetch(); + }, + }); + } + }, [logoutMutation.mutate]); + + if (sessionQuery.isLoading) { + return
Loading...
; } return (
-
- {loginMutation.isError && ( -
- Failed to login -
{loginMutation.error?.message}
-
- )} -
- -
- -
+ {sessionQuery.data?.session ? ( +
+
Successfully signed in!
+
-
- -
- + ) : ( + + {loginMutation.isError && ( +
+ Failed to login +
{loginMutation.error?.message}
+
+ )} +
+ +
+ +
-
- - +
+ +
+ +
+
+ + + )} +
+
+ Session status:{" "} + + {sessionQuery.isLoading ? "loading" : sessionQuery.data?.status} + +
); }; diff --git a/packages/example/src/components/users-list.tsx b/packages/example/src/components/users-list.tsx index 4230ff5..4e87f52 100644 --- a/packages/example/src/components/users-list.tsx +++ b/packages/example/src/components/users-list.tsx @@ -1,11 +1,11 @@ import { FC } from "react"; import { useQuery } from "react-query"; -import { UsersApiResponse } from "../types/api"; +import { GetUsersResponse } from "../types/users"; export type UsersListProps = unknown; export const UsersList: FC = () => { - const { data: users, isError } = useQuery( + const { data: users, isError } = useQuery( "users", async () => { const response = await fetch("/api/users"); diff --git a/packages/example/src/main.tsx b/packages/example/src/main.tsx index 90a8871..8dbd5a6 100644 --- a/packages/example/src/main.tsx +++ b/packages/example/src/main.tsx @@ -1,9 +1,13 @@ +import { Buffer as BufferPolyfill } from "buffer"; import React from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; import { setupWorker } from "msw"; import handlers from "./mocks/handlers"; +// Polyfill buffer so we can base64 encode/decode within mocks in browser (via dev server) & node +globalThis.Buffer = BufferPolyfill; + const worker = setupWorker(...handlers); async function prepare() { diff --git a/packages/example/src/mocks/handlers.ts b/packages/example/src/mocks/handlers.ts index 7b3c0b9..a017982 100644 --- a/packages/example/src/mocks/handlers.ts +++ b/packages/example/src/mocks/handlers.ts @@ -1,53 +1,4 @@ -import { rest } from "msw"; -import { - UsersApiParams, - UsersApiResponse, - LoginApiRequestBody, - LoginApiParams, - LoginApiResponse, -} from "../types/api"; +import sessionHandlers from "./handlers/session"; +import usersHandlers from "./handlers/users"; -export default [ - rest.get( - "/api/users", - (_, response, context) => - response( - context.delay(500), - context.status(200), - context.json([ - { - id: "bcff5c0e-10b6-407b-94d1-90d741363885", - firstName: "Rhydian", - lastName: "Greig", - }, - { - id: "b44e89e4-3254-415e-b14a-441166616b20", - firstName: "Alessandro", - lastName: "Metcalfe", - }, - { - id: "6e369942-6b5d-4159-9b39-729646549183", - firstName: "Erika", - lastName: "Richards", - }, - ]) - ) - ), - rest.post( - "/api/login", - async (request, response, context) => { - const { username, password } = await request.json(); - if (username === "peter" && password === "secret") { - return response( - context.delay(500), - context.status(200), - context.json({ - userId: "9138123", - }) - ); - } - - return response(context.delay(500), context.status(401)); - } - ), -]; +export default [...sessionHandlers, ...usersHandlers]; diff --git a/packages/example/src/mocks/handlers/session.ts b/packages/example/src/mocks/handlers/session.ts new file mode 100644 index 0000000..0a59702 --- /dev/null +++ b/packages/example/src/mocks/handlers/session.ts @@ -0,0 +1,83 @@ +import { + SessionData, + GetSessionParams, + GetSessionResponse, + PostSessionRequestBody, + PostSessionParams, + PostSessionResponse, +} from "../../types/session"; +import { rest } from "msw"; + +const VALID_USERNAME = "peter"; +const VALID_PASSWORD = "secret"; +const SESSION_COOKIE_KEY = "x-session"; + +const sessionData: SessionData = { + userId: "9138123", +}; + +const encodeSessionCookie = (username: string, password: string) => + // This isn't secure, please don't do this in production code 😇 + Buffer.from(`${username}:${password}`).toString("base64"); + +const decodeSessionCookie = ( + cookie: string +): { username: string; password: string } => { + const [username, password] = Buffer.from(cookie ?? "", "base64") + .toString() + .split(":"); + return { + username: username ?? null, + password: password ?? null, + }; +}; + +const isValidCredentials = (username: string, password: string): boolean => + username === VALID_USERNAME && password === VALID_PASSWORD; + +const isValidSession = (cookie: string): boolean => { + const { username, password } = decodeSessionCookie(cookie); + return isValidCredentials(username, password); +}; + +export default [ + rest.get( + "/api/session", + async (request, response, context) => { + const sessionCookie = request.cookies[SESSION_COOKIE_KEY]; + return isValidSession(sessionCookie) + ? response( + context.delay(150), + context.status(200), + context.json(sessionData) + ) + : response(context.delay(150), context.status(401)); + } + ), + rest.post( + "/api/session", + async (request, response, context) => { + const { username, password } = + await request.json(); + if (isValidCredentials(username, password)) { + return response( + context.delay(500), + context.status(200), + context.cookie( + SESSION_COOKIE_KEY, + encodeSessionCookie(username, password) + ), + context.json(sessionData) + ); + } + + return response(context.delay(500), context.status(401)); + } + ), + rest.delete("/api/session", async (request, response, context) => { + const sessionCookie = request.cookies[SESSION_COOKIE_KEY]; + return isValidSession(sessionCookie) + ? response(context.status(200), context.cookie(SESSION_COOKIE_KEY, "")) + : response(context.status(401)); + }), +]; diff --git a/packages/example/src/mocks/handlers/users.ts b/packages/example/src/mocks/handlers/users.ts new file mode 100644 index 0000000..9156f3a --- /dev/null +++ b/packages/example/src/mocks/handlers/users.ts @@ -0,0 +1,30 @@ +import { rest } from "msw"; +import { GetUsersParams, GetUsersResponse } from "../../types/users"; + +export default [ + rest.get( + "/api/users", + (_, response, context) => + response( + context.delay(500), + context.status(200), + context.json([ + { + id: "bcff5c0e-10b6-407b-94d1-90d741363885", + firstName: "Rhydian", + lastName: "Greig", + }, + { + id: "b44e89e4-3254-415e-b14a-441166616b20", + firstName: "Alessandro", + lastName: "Metcalfe", + }, + { + id: "6e369942-6b5d-4159-9b39-729646549183", + firstName: "Erika", + lastName: "Richards", + }, + ]) + ) + ), +]; diff --git a/packages/example/src/types/api.ts b/packages/example/src/types/api.ts deleted file mode 100644 index 1eb922e..0000000 --- a/packages/example/src/types/api.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type UsersApiParams = Record; -export type UsersApiResponse = Array<{ - id: string; - firstName: string; - lastName: string; -}>; - -export type LoginApiRequestBody = { - username: string; - password: string; -}; -export type LoginApiParams = Record; -export type LoginApiResponse = { - userId: string; -}; diff --git a/packages/example/src/types/session.ts b/packages/example/src/types/session.ts new file mode 100644 index 0000000..0ea004c --- /dev/null +++ b/packages/example/src/types/session.ts @@ -0,0 +1,13 @@ +export type SessionData = { + userId: string; +}; + +export type GetSessionParams = Record; +export type GetSessionResponse = SessionData; + +export type PostSessionRequestBody = { + username: string; + password: string; +}; +export type PostSessionParams = Record; +export type PostSessionResponse = SessionData; diff --git a/packages/example/src/types/users.ts b/packages/example/src/types/users.ts new file mode 100644 index 0000000..308e78a --- /dev/null +++ b/packages/example/src/types/users.ts @@ -0,0 +1,6 @@ +export type GetUsersParams = Record; +export type GetUsersResponse = Array<{ + id: string; + firstName: string; + lastName: string; +}>; diff --git a/packages/example/tests/playwright/models/login-form.ts b/packages/example/tests/playwright/models/login-form.ts index 1495580..32fcae2 100644 --- a/packages/example/tests/playwright/models/login-form.ts +++ b/packages/example/tests/playwright/models/login-form.ts @@ -14,18 +14,31 @@ export class LoginForm { } async setUsername(username: string) { - // Type into an input by its label - await this.page.locator('label:text("Username")').type(username); + await this.page.getByLabel("Username").fill(username); } async setPassword(password: string) { - // Type into an input by its label - await this.page.locator('label:text("Password")').type(password); + await this.page.getByLabel("Password").fill(password); } async submit() { - // Click a button by its accessibility role - await this.page.locator('role=button[name="Sign in"]').click(); + await this.page.getByRole("button", { name: "Sign in" }).click(); + } + + async loginWithValidCredentials() { + await this.setUsername("peter"); + await this.setPassword("secret"); + await this.submit(); + } + + async loginWithInvalidCredentials() { + await this.setUsername("peter"); + await this.setPassword("incorrect"); + await this.submit(); + } + + async logout() { + await this.page.getByRole("button", { name: "Logout" }).click(); } async assertError(error: LoginFormError) { @@ -39,4 +52,10 @@ export class LoginForm { this.page.locator("text=Successfully signed in!") ).toBeVisible(); } + + async assertSessionStatus(status: number) { + await expect( + this.page.locator(`text=Session status: ${status}`) + ).toBeVisible(); + } } diff --git a/packages/example/tests/playwright/specs/cookies.spec.ts b/packages/example/tests/playwright/specs/cookies.spec.ts new file mode 100644 index 0000000..cc7729c --- /dev/null +++ b/packages/example/tests/playwright/specs/cookies.spec.ts @@ -0,0 +1,60 @@ +import { LoginForm } from "../models/login-form"; +import { test } from "../test"; + +test.describe.parallel("cookies", () => { + test("should not have a session cookie if the user has not logged in yet", async ({ + page, + }) => { + await page.goto("/login"); + + const loginForm = new LoginForm(page); + await loginForm.assertSessionStatus(401); + }); + + test("should still have a valid session if the user refreshes the page after logging in", async ({ + page, + }) => { + await page.goto("/login"); + + const loginForm = new LoginForm(page); + await loginForm.loginWithValidCredentials(); + + // Reload the page + await page.reload(); + + await loginForm.assertSessionStatus(200); + }); + + test("should allow the user to clear their session by logging out", async ({ + page, + }) => { + await page.goto("/login"); + + const loginForm = new LoginForm(page); + await loginForm.loginWithValidCredentials(); + await loginForm.assertSessionStatus(200); + await loginForm.logout(); + await loginForm.assertSessionStatus(401); + }); + + test.describe.serial("when running sequentially", () => { + test("should have a valid session once the user logs in", async ({ + page, + }) => { + await page.goto("/login"); + + const loginForm = new LoginForm(page); + await loginForm.loginWithValidCredentials(); + await loginForm.assertSessionStatus(200); + }); + + test("should not have a session from the previous test run", async ({ + page, + }) => { + await page.goto("/login"); + + const loginForm = new LoginForm(page); + await loginForm.assertSessionStatus(401); + }); + }); +}); diff --git a/packages/example/tests/playwright/specs/login.spec.ts b/packages/example/tests/playwright/specs/login.spec.ts index b42f607..b31e453 100644 --- a/packages/example/tests/playwright/specs/login.spec.ts +++ b/packages/example/tests/playwright/specs/login.spec.ts @@ -1,23 +1,14 @@ import { LoginForm } from "../models/login-form"; import { test } from "../test"; -import { rest } from "msw"; test.describe.parallel("login form", () => { test("should display an invalid credentials message if the user enters incorrect credentials", async ({ page, - worker, }) => { - await worker.use( - rest.post("/api/login", (_, response, context) => - response(context.delay(250), context.status(401)) - ) - ); await page.goto("/login"); const loginForm = new LoginForm(page); - await loginForm.setUsername("peter"); - await loginForm.setPassword("secret"); - await loginForm.submit(); + await loginForm.loginWithInvalidCredentials(); await loginForm.assertError(LoginForm.Error.InvalidCredentials); }); @@ -27,9 +18,7 @@ test.describe.parallel("login form", () => { await page.goto("/login"); const loginForm = new LoginForm(page); - await loginForm.setUsername("peter"); - await loginForm.setPassword("secret"); - await loginForm.submit(); + await loginForm.loginWithValidCredentials(); await loginForm.assertSuccessful(); }); }); diff --git a/yarn.lock b/yarn.lock index bef2ed0..0580950 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1766,6 +1766,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^18.11.9": + version: 18.11.9 + resolution: "@types/node@npm:18.11.9" + checksum: cc0aae109e9b7adefc32eecb838d6fad931663bb06484b5e9cbbbf74865c721b03d16fd8d74ad90e31dbe093d956a7c2c306ba5429ba0c00f3f7505103d7a496 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.1 resolution: "@types/normalize-package-data@npm:2.4.1" @@ -2945,6 +2952,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: ^1.3.1 + ieee754: ^1.2.1 + checksum: 5ad23293d9a731e4318e420025800b42bf0d264004c0286c8cc010af7a270c7a0f6522e84f54b9ad65cbd6db20b8badbfd8d2ebf4f80fa03dab093b89e68c3f9 + languageName: node + linkType: hard + "cacache@npm:^16.1.0": version: 16.1.3 resolution: "cacache@npm:16.1.3" @@ -4235,9 +4252,11 @@ __metadata: resolution: "example@workspace:packages/example" dependencies: "@playwright/test": ^1.27.1 + "@types/node": ^18.11.9 "@types/react": ^18.0.21 "@types/react-dom": ^18.0.6 "@vitejs/plugin-react": ^2.1.0 + buffer: ^6.0.3 msw: 0.47.4 playwright-msw: "workspace:*" react: ^18.2.0 @@ -4969,7 +4988,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.1.13": +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e