diff --git a/frontend/.gitignore b/frontend/.gitignore index e21f8c76..9fc1bb9a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -23,3 +23,7 @@ dist-ssr *.sln *.sw? src/tests/coverage +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/frontend/src/.prettierrc b/frontend/.prettierrc similarity index 100% rename from frontend/src/.prettierrc rename to frontend/.prettierrc diff --git a/frontend/mockServiceWorker.js b/frontend/mockServiceWorker.js new file mode 100644 index 00000000..a37382fc --- /dev/null +++ b/frontend/mockServiceWorker.js @@ -0,0 +1,287 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (2.2.0). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '223d191a56023cd36aa88c802961b911' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = 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: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + 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 + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && 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 deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // 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)) { + ;(async function () { + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [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. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + 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) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()) + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention'] + + 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() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + const mswIntention = request.headers.get('x-msw-intention') + if (['bypass', 'passthrough'].includes(mswIntention)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + 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: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + } + + return passthrough() +} + +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].concat(transferrables.filter(Boolean)), + ) + }) +} + +async 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 +} diff --git a/frontend/package.json b/frontend/package.json index 2b654e61..01a6e558 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "lint:fix": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0 --fix", "preview": "vite preview", "test": "vitest run", + "test:browser": "playwright test", "test:coverage": "vitest run --coverage" }, "babelMacros": { @@ -45,6 +46,7 @@ "devDependencies": { "@babel/plugin-transform-react-jsx": "^7.23.4", "@emotion/babel-plugin-jsx-pragmatic": "^0.2.1", + "@playwright/test": "^1.41.2", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.2.1", "@types/node": "^20.11.20", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..197024c7 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,76 @@ +import { defineConfig, devices } from '@playwright/test'; +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:49768/', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'pnpm exec vite --host localhost --port 49768 --strictPort --clearScreen false', + url: 'http://localhost:49768/', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index f309b92c..356bc9a9 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -82,6 +82,9 @@ devDependencies: '@emotion/babel-plugin-jsx-pragmatic': specifier: ^0.2.1 version: 0.2.1(@babel/core@7.23.9) + '@playwright/test': + specifier: ^1.41.2 + version: 1.41.2 '@testing-library/jest-dom': specifier: ^6.4.2 version: 6.4.2(vitest@1.2.2) @@ -1045,6 +1048,14 @@ packages: engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} dev: true + /@playwright/test@1.41.2: + resolution: {integrity: sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==} + engines: {node: '>=16'} + hasBin: true + dependencies: + playwright: 1.41.2 + dev: true + /@remix-run/router@1.15.0: resolution: {integrity: sha512-HOil5aFtme37dVQTB6M34G95kPM3MMuqSmIRVCC52eKV+Y/tGSqw9P3rWhlAx6A+mz+MoX+XxsGsNJbaI5qCgQ==} engines: {node: '>=14.0.0'} @@ -3062,6 +3073,14 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4315,6 +4334,22 @@ packages: pathe: 1.1.2 dev: true + /playwright-core@1.41.2: + resolution: {integrity: sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==} + engines: {node: '>=16'} + hasBin: true + dev: true + + /playwright@1.41.2: + resolution: {integrity: sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==} + engines: {node: '>=16'} + hasBin: true + dependencies: + playwright-core: 1.41.2 + optionalDependencies: + fsevents: 2.3.2 + dev: true + /postcss-import@15.1.0(postcss@8.4.35): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} diff --git a/frontend/prettier.config.js b/frontend/prettier.config.js deleted file mode 100644 index 9126c67f..00000000 --- a/frontend/prettier.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - printWidth: 120, - trailingComma: "all", - arrowParens: "always", -}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d7c6a6da..e13bb10f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -39,12 +39,33 @@ const router = createBrowserRouter([ ]) const queryClient = new QueryClient() -createRoot(document.getElementById("root")!).render( - - - - - - - , -) +// Enable mocking in development using msw server set up for the browser +async function enableMocking() { + if (process.env.NODE_ENV !== "development") { + return + } + + const { worker, http, HttpResponse } = await import("./tests/mocks/browser") + + // @ts-ignore + // Propagate the worker and `http` references to be globally available. + // This would allow to modify request handlers on runtime. + window.msw = { + worker, + http, + HttpResponse, + } + return worker.start() +} + +enableMocking().then(() => { + createRoot(document.getElementById("root")!).render( + + + + + + + , + ) +}) diff --git a/frontend/src/tests/mocks/browser.ts b/frontend/src/tests/mocks/browser.ts new file mode 100644 index 00000000..0a0e80cd --- /dev/null +++ b/frontend/src/tests/mocks/browser.ts @@ -0,0 +1,7 @@ +import { setupWorker } from "msw/browser" +import { http, HttpResponse } from "msw" +import { handlers } from "./handlers" + +const worker = setupWorker(...handlers) + +export { worker, http, HttpResponse } diff --git a/frontend/tests/overviewPage.spec.ts b/frontend/tests/overviewPage.spec.ts new file mode 100644 index 00000000..890b131a --- /dev/null +++ b/frontend/tests/overviewPage.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from "@playwright/test" + +test("loads the Overview page", async ({ page }) => { + await page.goto("/") + const title = await page.title() + expect(title).toBe("Harp UI") +}) diff --git a/frontend/tests/systemDependenciesPage.spec.ts b/frontend/tests/systemDependenciesPage.spec.ts new file mode 100644 index 00000000..162c3024 --- /dev/null +++ b/frontend/tests/systemDependenciesPage.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test" +import { SetupWorkerApi } from "msw/browser" +import { http, HttpResponse } from "msw" + +declare namespace window { + export const msw: { + worker: SetupWorkerApi + http: typeof http + HttpResponse: typeof HttpResponse + } +} + +test("Override msw worker for system dependencies", async ({ page }) => { + await page.goto("/") + + await page.waitForFunction(() => document.body.innerText.includes("Overview")) + await page.evaluate(() => { + // Inside this function, you can access the window object and modify the worker + const { worker, http, HttpResponse } = window.msw + worker.use( + http.get("/api/system/dependencies", function override() { + return HttpResponse.json({ python: ["pydantic", "tensorflow"] }) + }), + ) + }) + + await page.click('a:text("System")') + + await page.click('span:text("Dependencies")') + + // Assert that the text "pydantic" is present on the page + const text = await page.innerText("body") + expect(text).toContain("pydantic") +}) diff --git a/frontend/tests/transactionsPage.spec.ts b/frontend/tests/transactionsPage.spec.ts new file mode 100644 index 00000000..3bb22584 --- /dev/null +++ b/frontend/tests/transactionsPage.spec.ts @@ -0,0 +1,30 @@ +import { test, expect, request } from "@playwright/test" + +test.beforeEach(async ({ page }) => { + await page.goto("/transactions") + await page.waitForFunction(() => document.body.innerText.includes("Endpoint")) +}) + +test.describe("Transactions Page", () => { + test("Interacting with the filter side bar", async ({ page }) => { + const requestMethodButton = await page.$('span:has-text("Request Method")') + const getLabel = await page.getByLabel("GET") + expect(getLabel).toBeVisible() + await requestMethodButton?.click() + expect(getLabel).not.toBeVisible() + + const endpointButton = await page.getByText("Endpoint", { exact: true }) + const endpoint1Label = await page.getByLabel("endpoint1") + expect(endpoint1Label).toBeVisible() + await endpointButton?.click() + expect(endpoint1Label).not.toBeVisible() + + const responseStatusButton = await page.getByText("Response Status", { exact: true }) + const status200Label = await page.getByLabel("2xx") + expect(status200Label).toBeVisible() + await responseStatusButton?.click() + expect(status200Label).not.toBeVisible() + + await endpointButton?.click() + }) +}) diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index 9ab1ed93..6a99bfd2 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -3,14 +3,15 @@ import { server } from "./src/tests/mocks/node" global.ResizeObserver = require("resize-observer-polyfill"); global.requestAnimationFrame = fn => window.setTimeout(fn, 0); import { beforeAll, afterEach, afterAll } from 'vitest' + beforeAll(() => { server.listen() }) - afterEach(() => { +afterEach(() => { server.resetHandlers() }) - afterAll(() => { +afterAll(() => { server.close() })