diff --git a/.gitignore b/.gitignore index 034a5542..c1deeed0 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,8 @@ cypress/downloads/ # Sentry Config File .env.sentry-build-plugin + +# Certificates +*.pem +*.crt +*.key diff --git a/package.json b/package.json index 3de14002..b6c7351a 100644 --- a/package.json +++ b/package.json @@ -162,5 +162,10 @@ "src/**/*.test.ts" ] }, - "packageManager": "yarn@4.6.0" + "packageManager": "yarn@4.6.0", + "msw": { + "workerDirectory": [ + "public" + ] + } } diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 00000000..89bce291 --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,295 @@ +/* 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.6.5' +const INTEGRITY_CHECKSUM = 'ca7800994cc8bfb5eb961e037c877074' +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: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + 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 (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) + }) +} + +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() { + // 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. + headers.delete('accept', 'msw/passthrough') + + 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/src/hooks/utils/responses.test.ts b/src/hooks/utils/responses.test.ts index 2f085df5..201ff68e 100644 --- a/src/hooks/utils/responses.test.ts +++ b/src/hooks/utils/responses.test.ts @@ -2,8 +2,9 @@ import cloneDeep from 'lodash.clonedeep'; import { afterAll, beforeAll, expect, test, vi } from 'vitest'; import { ResponseAppData } from '@/config/appDataTypes'; +import { mockItem } from '@/mocks/mockItem'; +import { mockMembers } from '@/mocks/mockMembers'; -import { mockItem, mockMembers } from '../../mocks/db'; import { buildMockBotResponses, buildMockResponses, diff --git a/src/main.tsx b/src/main.tsx index 414250e6..082725de 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,7 +8,7 @@ import * as Sentry from '@sentry/react'; import { MOCK_API } from './config/env'; import { generateSentryConfig } from './config/sentry'; import './index.css'; -import buildDatabase, { defaultMockContext, mockMembers } from './mocks/db'; +import buildDatabase, { defaultMockContext } from './mocks/db'; import Root from './modules/Root'; Sentry.init({ @@ -25,9 +25,9 @@ if (MOCK_API) { mockApi( { externalUrls: [], - dbName: window.Cypress ? 'graasp-app-cypress' : undefined, + dbName: window.Cypress ? 'graasp-app-cypress' : 'msw-indexed-db', appContext: window.Cypress ? window.appContext : defaultMockContext, - database: window.Cypress ? window.database : buildDatabase(mockMembers), + database: window.Cypress ? window.database : buildDatabase(), }, window.Cypress ? MockSolution.MirageJS : MockSolution.ServiceWorker, ); diff --git a/src/mocks/db.ts b/src/mocks/db.ts index 7b069fc0..30e0dfb9 100644 --- a/src/mocks/db.ts +++ b/src/mocks/db.ts @@ -1,80 +1,22 @@ import type { Database } from '@graasp/apps-query-client'; import { - AccountType, AppData, - AppDataVisibility, - AppItemFactory, CompleteMember, Context, - ItemType, LocalContext, - MemberFactory, PermissionLevel, } from '@graasp/sdk'; -import { AppDataTypes } from '@/config/appDataTypes'; -import { API_HOST, PORT } from '@/config/env'; +import { API_HOST } from '@/config/env'; +import { mockItem } from './mockItem'; +import { mockMembers } from './mockMembers'; import { buildMockResponses } from './mockResponses'; - -export const mockMembers: CompleteMember[] = [ - MemberFactory({ - id: 'mock-account-id-1', - name: 'I (current account)', - email: 'i@graasp.org', - type: AccountType.Individual, - createdAt: new Date('1996-09-08T19:00:00').toISOString(), - updatedAt: new Date().toISOString(), - enableSaveActions: true, - }), - MemberFactory({ - id: 'mock-account-id-2', - name: 'You', - email: 'you@graasp.org', - type: AccountType.Individual, - createdAt: new Date('1995-02-02T15:00:00').toISOString(), - updatedAt: new Date().toISOString(), - enableSaveActions: true, - }), -]; - -export const mockItem = AppItemFactory({ - id: '1234-1234-1234-5678', - name: 'app-collaborative-ideation', - description: null, - path: '', - type: ItemType.APP, - settings: {}, - creator: mockMembers[0], - lang: 'en', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - extra: { - app: { - url: `http://localhost:${PORT}`, - }, - }, -}); +import { mockSettings } from './mockSettings'; const mockResponses = buildMockResponses(mockItem, mockMembers); -const mockAppData: AppData[] = [ - ...mockResponses, - { - id: '3', - item: mockItem, - creator: mockMembers[0], - type: AppDataTypes.ResponsesSet, - account: mockMembers[0], - visibility: AppDataVisibility.Member, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - data: { - ideas: ['0', '1', '2'], - round: 1, - }, - }, -]; +const mockAppData: AppData[] = [...mockResponses]; export const defaultMockContext: LocalContext = { apiHost: API_HOST, @@ -88,7 +30,7 @@ const buildDatabase = (members?: CompleteMember[]): Database => ({ appData: mockAppData, appActions: [], members: members ?? mockMembers, - appSettings: [], + appSettings: [...mockSettings], items: [mockItem], uploadedFiles: [], }); diff --git a/src/mocks/mockItem.ts b/src/mocks/mockItem.ts new file mode 100644 index 00000000..f8eb2808 --- /dev/null +++ b/src/mocks/mockItem.ts @@ -0,0 +1,23 @@ +import { AppItemFactory, ItemType } from '@graasp/sdk'; + +import { PORT } from '@/config/env'; + +import { mockMembers } from './mockMembers'; + +export const mockItem = AppItemFactory({ + id: '1234-1234-1234-5678', + name: 'app-collaborative-ideation', + description: null, + path: '', + type: ItemType.APP, + settings: {}, + creator: mockMembers[0], + lang: 'en', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + extra: { + app: { + url: `http://localhost:${PORT}`, + }, + }, +}); diff --git a/src/mocks/mockMembers.ts b/src/mocks/mockMembers.ts new file mode 100644 index 00000000..dcfe029f --- /dev/null +++ b/src/mocks/mockMembers.ts @@ -0,0 +1,22 @@ +import { AccountType, CompleteMember, MemberFactory } from '@graasp/sdk'; + +export const mockMembers: CompleteMember[] = [ + MemberFactory({ + id: 'mock-account-id-1', + name: 'I (current account)', + email: 'i@graasp.org', + type: AccountType.Individual, + createdAt: new Date('1996-09-08T19:00:00').toISOString(), + updatedAt: new Date().toISOString(), + enableSaveActions: true, + }), + MemberFactory({ + id: 'mock-account-id-2', + name: 'You', + email: 'you@graasp.org', + type: AccountType.Individual, + createdAt: new Date('1995-02-02T15:00:00').toISOString(), + updatedAt: new Date().toISOString(), + enableSaveActions: true, + }), +]; diff --git a/src/mocks/mockSettings.ts b/src/mocks/mockSettings.ts new file mode 100644 index 00000000..10473805 --- /dev/null +++ b/src/mocks/mockSettings.ts @@ -0,0 +1,96 @@ +import { AppSetting } from '@graasp/sdk'; + +import { AllSettingsType } from '@/config/appSettingsType'; +import { + ActivityType, + ResponseVisibilityMode, +} from '@/interfaces/interactionProcess'; + +import { mockItem } from './mockItem'; +import { mockMembers } from './mockMembers'; + +let settingCounter = 0; + +const newSettingFactory = ( + settingName: string, + data: AppSetting['data'], +): AppSetting => { + const id = settingCounter.toString(); + settingCounter += 1; + return { + id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + item: mockItem, + data, + name: settingName, + }; +}; + +const ALL_SETTINGS_OBJECT: AllSettingsType = { + instructions: { + title: { + content: 'What are your big ideas today?', + format: 'plain-text', + }, + details: { + content: + "Don't limitate yourself and express any idea that you may have.", + format: 'plain-text', + }, + collection: { + choose: { + content: 'Choose an idea to build upon', + format: 'plain-text', + }, + input: { + content: 'Submit your great idea', + format: 'plain-text', + }, + }, + evaluation: { + content: 'Evaluate the responses.', + format: 'plain-text', + }, + }, + orchestrator: { + id: mockMembers[0].id, + }, + activity: { + mode: ResponseVisibilityMode.Open, + numberOfResponsesPerSet: 3, + numberOfBotResponsesPerSet: 1, + exclusiveResponseDistribution: true, + steps: [ + { + type: ActivityType.Collection, + round: 0, + time: 60, + }, + { + type: ActivityType.Collection, + round: 1, + time: 60, + }, + { + type: ActivityType.Evaluation, + round: 2, + time: 120, + }, + ], + reformulateResponses: false, + numberOfParticipantsResponsesTriggeringResponsesGeneration: 0, + }, + notParticipating: { ids: [] }, + assistants: { + assistants: [], + }, + prompts: { + selectedSet: 'test', + maxNumberOfQueries: 5, + }, +}; + +export const mockSettings = Object.entries(ALL_SETTINGS_OBJECT).map( + ([key, value]) => newSettingFactory(key, value), +); diff --git a/src/modules/Root.tsx b/src/modules/Root.tsx index 8986df88..62da313d 100644 --- a/src/modules/Root.tsx +++ b/src/modules/Root.tsx @@ -15,7 +15,8 @@ import { useObjectState, } from '@graasp/apps-query-client'; -import { defaultMockContext, mockMembers } from '@/mocks/db'; +import { defaultMockContext } from '@/mocks/db'; +import { mockMembers } from '@/mocks/mockMembers'; import i18nConfig from '../config/i18n'; import { diff --git a/src/modules/responseCollection/MarkdownEditor.tsx b/src/modules/responseCollection/MarkdownEditor.tsx index 7bcf689b..5ab2042a 100644 --- a/src/modules/responseCollection/MarkdownEditor.tsx +++ b/src/modules/responseCollection/MarkdownEditor.tsx @@ -1,5 +1,7 @@ import { FC } from 'react'; +import Box from '@mui/material/Box'; + import { BoldItalicUnderlineToggles, InsertCodeBlock, @@ -28,34 +30,43 @@ const MarkdownEditor: FC = ({ initialValue, onChange, }) => ( - ( - <> - - - - - ), - }), - ]} - /> + + ( + <> + + + + + ), + }), + ]} + /> + ); export default MarkdownEditor; diff --git a/vite.config.ts b/vite.config.ts index 2c6f8e69..98503d06 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,26 @@ /// /// +import { sentryVitePlugin } from '@sentry/vite-plugin'; import react from '@vitejs/plugin-react'; +import fs from 'fs'; import { resolve } from 'path'; import { UserConfigExport, defineConfig, loadEnv } from 'vite'; import checker from 'vite-plugin-checker'; import istanbul from 'vite-plugin-istanbul'; -import { sentryVitePlugin } from "@sentry/vite-plugin"; + +const getHttpsOptions = (): { key: Buffer; cert: Buffer } | undefined => { + try { + const httpsOptions = { + key: fs.readFileSync('localhost.key'), + cert: fs.readFileSync('localhost.crt'), + }; + return httpsOptions; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error reading HTTPS certificate files:', error); + return undefined; // Return undefined if files are not found or an error occurs + } +}; // https://vitejs.dev/config/ export default ({ mode }: { mode: string }): UserConfigExport => { @@ -22,6 +37,7 @@ export default ({ mode }: { mode: string }): UserConfigExport => { watch: { ignored: ['**/coverage/**', '**/cypress/downloads/**'], }, + https: mode === 'test' ? undefined : getHttpsOptions(), }, preview: { port: parseInt(process.env.VITE_PORT || '3333', 10), @@ -51,11 +67,11 @@ export default ({ mode }: { mode: string }): UserConfigExport => { checkProd: true, }), // Put the Sentry vite plugin after all other plugins - sentryVitePlugin({ - authToken: process.env.SENTRY_AUTH_TOKEN, - org: "graasp", - project: "graasp-app-collaborative-ideation", - }), + sentryVitePlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: 'graasp', + project: 'graasp-app-collaborative-ideation', + }), ], resolve: { alias: {