From a7f6508f20cfec5d076e9209f6d34de1521153b6 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Mon, 13 Apr 2026 02:52:06 -0700 Subject: [PATCH] chore: fix tests on new FE code --- .github/workflows/ci.yml | 5 + apps/frontend/package.json | 8 +- apps/frontend/public/mockServiceWorker.js | 349 ++++++++++++++++++ .../src/utils/testing-utils/people-mocks.ts | 2 +- .../frontend/src/utils/testing-utils/setup.ts | 17 +- apps/frontend/vite.config.ts | 13 +- 6 files changed, 387 insertions(+), 7 deletions(-) create mode 100644 apps/frontend/public/mockServiceWorker.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f88600c..66c1aef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,11 @@ jobs: disk-cache: ${{ github.workflow }} cache-save: ${{ github.ref == 'refs/heads/main' }} + - name: Install playwright + run: | + cd apps/frontend && pnpm exec playwright install --with-deps chromium + continue-on-error: false + - name: Build run: bazel build //... diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 03b4823..f3f946b 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -8,7 +8,8 @@ "gen:openapi": "npx openapi-typescript ../backend/openapi.json -o ./src/services/schemas/people.d.ts", "build": "pnpm gen:openapi && tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest --watch=false --silent=passed-only" }, "dependencies": { "@tanstack/react-query": "^5.99.0", @@ -43,5 +44,10 @@ "vite": "^8.0.4", "vite-plugin-static-copy": "^4.0.1", "vitest": "^4.1.4" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/apps/frontend/public/mockServiceWorker.js b/apps/frontend/public/mockServiceWorker.js new file mode 100644 index 0000000..b0c5a15 --- /dev/null +++ b/apps/frontend/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.13.2' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/apps/frontend/src/utils/testing-utils/people-mocks.ts b/apps/frontend/src/utils/testing-utils/people-mocks.ts index 6cd8413..5051cc4 100644 --- a/apps/frontend/src/utils/testing-utils/people-mocks.ts +++ b/apps/frontend/src/utils/testing-utils/people-mocks.ts @@ -2,7 +2,7 @@ import type { paths } from "../../services/schemas/people"; import { createOpenApiHttp } from "openapi-msw"; // Should be replaced with an env var of some kind -const baseUrl = "http://localhost:3030"; +const baseUrl = 'http://localhost:8000' export const http = createOpenApiHttp({ baseUrl, diff --git a/apps/frontend/src/utils/testing-utils/setup.ts b/apps/frontend/src/utils/testing-utils/setup.ts index 2d9c32f..6e692bd 100644 --- a/apps/frontend/src/utils/testing-utils/setup.ts +++ b/apps/frontend/src/utils/testing-utils/setup.ts @@ -1,6 +1,15 @@ -import { afterEach, beforeAll } from "vitest"; -import { worker } from "./server"; +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { worker } from './server' -beforeAll(() => worker.start()); +beforeAll(async () => { + await worker.start(); +}); -afterEach(() => worker.resetHandlers()); +afterEach(() => { + worker.resetHandlers(); + cleanup(); +}); + +afterAll(() => worker.stop()); diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 96553dd..cae8cff 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -1,7 +1,8 @@ import path from "node:path"; -import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { viteStaticCopy } from "vite-plugin-static-copy"; +import { defineConfig } from "vitest/config"; +import { playwright } from "@vitest/browser-playwright"; // https://vite.dev/config/ export default defineConfig({ @@ -28,4 +29,14 @@ export default defineConfig({ ? path.resolve(process.cwd(), "dist") : undefined, }, + test: { + retry: 3, + setupFiles: ["./src/utils/testing-utils/setup.ts"], + fileParallelism: false, + browser: { + provider: playwright(), + enabled: true, + instances: [{ browser: "chromium" }], + }, + }, }); -- 2.51.2