diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index eeb90d4556a..cfc637c729b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -369,6 +369,7 @@ /package.json @grafana/frontend-ops /nx.json @grafana/frontend-ops /project.json @grafana/frontend-ops +/.nxignore @grafana/frontend-ops /tsconfig.json @grafana/frontend-ops /.editorconfig @grafana/frontend-ops /.eslintignore @grafana/frontend-ops @@ -501,6 +502,7 @@ playwright.config.ts @grafana/plugins-platform-frontend /public/lib/ @grafana/grafana-frontend-platform /public/lib/monaco-languages/kusto.ts @grafana/partner-datasources /public/maps/ @ryantxu +/public/mockServiceWorker.js @grafana/frontend-ops /public/robots.txt @grafana/frontend-ops /public/fonts/ @grafana/grafana-frontend-platform /public/sass/ @grafana/grafana-frontend-platform diff --git a/.nxignore b/.nxignore new file mode 100644 index 00000000000..edf9f018d25 --- /dev/null +++ b/.nxignore @@ -0,0 +1 @@ +!conf/custom.ini diff --git a/.prettierignore b/.prettierignore index 76bf2adf696..8bf9dc13276 100644 --- a/.prettierignore +++ b/.prettierignore @@ -28,5 +28,8 @@ public/api-merged.json public/api-enterprise-spec.json public/openapi3.json +# Generated mock service worker +public/mockServiceWorker.js + # Crowdin files public/locales/**/*.json diff --git a/conf/defaults.ini b/conf/defaults.ini index e164bf0fbd2..49209a9ea69 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1996,3 +1996,6 @@ frontend_poll_interval = 2s # Should UI tests fail when console log/warn/erroring? # Does not affect the result when running on CI - only for allowing devs to choose this behaviour locally fail_tests_on_console = true +# Whether or not to enable the MSW mock API, which intercepts requests and returns mock data +# Should only be used for local development or demo purposes +mock_api = false diff --git a/package.json b/package.json index 35d490ceb2f..f3ff5b5d176 100644 --- a/package.json +++ b/package.json @@ -445,5 +445,10 @@ "prettier@3.3.3": { "unplugged": true } + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 9f5ac6b79fd..6f1295aa48f 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -604,6 +604,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { hs.mapStatic(m, hs.Cfg.StaticRootPath, "build", "public/build") hs.mapStatic(m, hs.Cfg.StaticRootPath, "", "public", "/public/views/swagger.html") hs.mapStatic(m, hs.Cfg.StaticRootPath, "robots.txt", "robots.txt") + hs.mapStatic(m, hs.Cfg.StaticRootPath, "mockServiceWorker.js", "mockServiceWorker.js") if hs.Cfg.ImageUploadProvider == "local" { hs.mapStatic(m, hs.Cfg.ImagesDir, "", "/public/img/attachments") @@ -753,6 +754,12 @@ func (hs *HTTPServer) mapStatic(m *web.Mux, rootDir string, dir string, prefix s } } + if prefix == "mockServiceWorker.js" { + headers = func(c *web.Context) { + c.Resp.Header().Set("Content-Type", "application/javascript") + } + } + m.Use(httpstatic.Static( path.Join(rootDir, dir), httpstatic.StaticOptions{ diff --git a/public/app/index.ts b/public/app/index.ts index 6cbb37dfbec..cfbaee2e5cd 100644 --- a/public/app/index.ts +++ b/public/app/index.ts @@ -19,4 +19,16 @@ if (window.nonce) { window.__grafana_app_bundle_loaded = true; import app from './app'; -app.init(); + +const prepareInit = async () => { + if (process.env.frontend_dev_mock_api) { + return import('test/mock-api/worker').then((workerModule) => { + workerModule.default.start({ onUnhandledRequest: 'bypass' }); + }); + } + return Promise.resolve(); +}; + +prepareInit().then(() => { + app.init(); +}); diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 00000000000..15751fa1994 --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,284 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.3.5' +const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423' +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: { + packageVersion: PACKAGE_VERSION, + checksum: 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() + } + + // 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 'PASSTHROUGH': { + 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/public/test/mock-api/worker.ts b/public/test/mock-api/worker.ts new file mode 100644 index 00000000000..350c842e2bd --- /dev/null +++ b/public/test/mock-api/worker.ts @@ -0,0 +1,5 @@ +import { setupWorker } from 'msw/browser'; + +import allAlertingHandlers from 'app/features/alerting/unified/mocks/server/all-handlers'; + +export default setupWorker(...allAlertingHandlers);