diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index f3f65275dc6..cdcbd0211ee 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -97,17 +97,28 @@ "bundlewatch": "^0.4.1", "jsdom": "26.1.0", "minimatch": "^10.0.3", + "msw": "^2.0.0", "webpack-merge": "^5.10.0" }, "peerDependencies": { "react": "catalog:peer-react", "react-dom": "catalog:peer-react" }, + "peerDependenciesMeta": { + "msw": { + "optional": true + } + }, "engines": { "node": ">=18.17.0" }, "publishConfig": { "access": "public" }, - "browserslistLegacy": "Chrome > 73, Firefox > 66, Safari > 12, iOS > 12, Edge > 18, Opera > 58" + "browserslistLegacy": "Chrome > 73, Firefox > 66, Safari > 12, iOS > 12, Edge > 18, Opera > 58", + "msw": { + "workerDirectory": [ + "public" + ] + } } diff --git a/packages/clerk-js/public/mockServiceWorker.js b/packages/clerk-js/public/mockServiceWorker.js new file mode 100644 index 00000000000..0369b7cec52 --- /dev/null +++ b/packages/clerk-js/public/mockServiceWorker.js @@ -0,0 +1,335 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.11.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 + * @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/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index 78467f67a79..5d73d9fa76b 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -367,7 +367,10 @@ const prodConfig = ({ mode, env, analysis }) => { entryForVariant(variants.clerkBrowser), isSandbox ? { - entry: { sandbox: './sandbox/app.ts' }, + entry: { + sandbox: './sandbox/app.ts', + mockServiceWorker: './public/mockServiceWorker.js', + }, plugins: [ new rspack.HtmlRspackPlugin({ minify: false, @@ -618,7 +621,10 @@ const devConfig = ({ mode, env }) => { // prettier-ignore [variants.clerkBrowser]: merge( entryForVariant(variants.clerkBrowser), - isSandbox ? { entry: { sandbox: './sandbox/app.ts' } } : {}, + isSandbox ? { entry: { + sandbox: './sandbox/app.ts', + mockServiceWorker: './public/mockServiceWorker.js' + } } : {}, common({ mode, variant: variants.clerkBrowser }), commonForDev(), ), diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 6132a5f5025..882882df5b4 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -1,5 +1,6 @@ import * as l from '../../localizations'; import type { Clerk as ClerkType } from '../'; +import { getMockController, isMockingActive, setupMockingControls } from './mocking'; const AVAILABLE_LOCALES = Object.keys(l) as (keyof typeof l)[]; @@ -196,7 +197,6 @@ function appearanceVariableOptions() { const updateVariables = () => { void Clerk.__unstable__updateProps({ appearance: { - // Preserve existing appearance properties like baseTheme ...Clerk.__internal_getOption('appearance'), variables: Object.fromEntries( Object.entries(variableInputs).map(([key, input]) => { @@ -271,6 +271,14 @@ void (async () => { fillLocalizationSelect(); const { updateVariables } = appearanceVariableOptions(); const { updateOtherOptions } = otherOptions(); + await setupMockingControls(); + + // Wait for MSW to initialize before loading Clerk + if (isMockingActive() && getMockController()) { + console.log('🔧 MSW is ready, proceeding with Clerk load...'); + } else { + console.log('🔧 No mocking enabled, proceeding with Clerk load...'); + } const sidebars = document.querySelectorAll('[data-sidebar]'); document.addEventListener('keydown', e => { @@ -364,11 +372,18 @@ void (async () => { if (route in routes) { const renderCurrentRoute = routes[route]; addCurrentRouteIndicator(route); - await Clerk.load({ + + const clerkConfig = { ...(componentControls.clerk.getProps() ?? {}), signInUrl: '/sign-in', signUpUrl: '/sign-up', - }); + }; + + if (isMockingActive()) { + console.log('🔧 Loading Clerk with mocking enabled - MSW will intercept API calls'); + } + + await Clerk.load(clerkConfig); renderCurrentRoute(); updateVariables(); updateOtherOptions(); diff --git a/packages/clerk-js/sandbox/mocking.ts b/packages/clerk-js/sandbox/mocking.ts new file mode 100644 index 00000000000..30d0a9e1ad3 --- /dev/null +++ b/packages/clerk-js/sandbox/mocking.ts @@ -0,0 +1,129 @@ +import { ClerkMockController } from '../src/mocking/controller'; +import { ClerkMockScenarios } from '../src/mocking/scenarios'; + +let mockController: ClerkMockController | null = null; +let isMockingEnabled = false; + +export interface MockingControls { + initializeMocking: () => Promise; + updateMockingStatus: () => void; +} + +export async function setupMockingControls(): Promise { + const enableMockingCheckbox = document.getElementById('enableMocking') as HTMLInputElement; + const scenarioSelect = document.getElementById('mockScenarioSelect') as HTMLSelectElement; + const resetMockingBtn = document.getElementById('resetMockingBtn'); + + const savedMockingEnabled = sessionStorage.getItem('mockingEnabled') === 'true'; + const savedScenario = sessionStorage.getItem('mockScenario') || 'user-button-signed-in'; + + enableMockingCheckbox.checked = savedMockingEnabled; + scenarioSelect.value = savedScenario; + + const updateMockingStatus = () => { + const mockStatusIndicator = document.getElementById('mockStatusIndicator'); + const mockStatusText = document.getElementById('mockStatusText'); + + if (mockStatusIndicator && mockStatusText) { + if (isMockingEnabled) { + mockStatusIndicator.className = 'h-2 w-2 rounded-full bg-green-500'; + mockStatusText.textContent = 'Mocking enabled'; + mockStatusText.className = 'text-sm font-medium text-green-600'; + } else { + mockStatusIndicator.className = 'h-2 w-2 rounded-full bg-gray-400'; + mockStatusText.textContent = 'Mocking disabled'; + mockStatusText.className = 'text-sm font-medium text-gray-600'; + } + } + }; + + const initializeMocking = async () => { + if (!enableMockingCheckbox.checked) { + if (mockController) { + mockController.stop(); + mockController = null; + isMockingEnabled = false; + } + updateMockingStatus(); + return; + } + + try { + mockController = new ClerkMockController({ + debug: true, + scenario: scenarioSelect.value || undefined, + }); + + mockController.registerScenario(ClerkMockScenarios.userButtonSignedIn()); + mockController.registerScenario(ClerkMockScenarios.userProfileBarebones()); + + await mockController.start(scenarioSelect.value || undefined); + isMockingEnabled = true; + updateMockingStatus(); + } catch (error) { + console.error('Failed to initialize mocking:', error); + const mockStatusIndicator = document.getElementById('mockStatusIndicator'); + const mockStatusText = document.getElementById('mockStatusText'); + + if (mockStatusIndicator && mockStatusText) { + mockStatusIndicator.className = 'h-2 w-2 rounded-full bg-red-500'; + mockStatusText.textContent = 'Mocking failed to initialize'; + mockStatusText.className = 'text-sm font-medium text-red-600'; + } + } + }; + + enableMockingCheckbox.addEventListener('change', () => { + sessionStorage.setItem('mockingEnabled', enableMockingCheckbox.checked.toString()); + void initializeMocking(); + }); + + scenarioSelect.addEventListener('change', () => { + console.log(`🔧 Scenario changed to: ${scenarioSelect.value}, Mocking enabled: ${isMockingEnabled}`); + sessionStorage.setItem('mockScenario', scenarioSelect.value); + if (isMockingEnabled) { + try { + mockController?.switchScenario(scenarioSelect.value); + updateMockingStatus(); + console.log(`🔧 Switched to scenario: ${scenarioSelect.value}`); + + window.location.reload(); + } catch (error) { + console.error('Failed to switch scenario:', error); + } + } else { + console.log('🔧 Mocking is disabled, scenario change ignored'); + } + }); + + resetMockingBtn?.addEventListener('click', () => { + enableMockingCheckbox.checked = false; + scenarioSelect.value = ''; + + sessionStorage.removeItem('mockingEnabled'); + sessionStorage.removeItem('mockScenario'); + + if (mockController) { + mockController.stop(); + mockController = null; + isMockingEnabled = false; + } + updateMockingStatus(); + }); + + if (savedMockingEnabled) { + await initializeMocking(); + } else { + updateMockingStatus(); + } + + return { initializeMocking, updateMockingStatus }; +} + +export function isMockingActive(): boolean { + return isMockingEnabled; +} + +export function getMockController(): ClerkMockController | null { + return mockController; +} diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index 557eb0a7b3e..2d27f5eff78 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -8,8 +8,59 @@ content="width=device-width,initial-scale=1" /> + + - +
-
-
- Variables +
+
+
+ Mocking +
+ + +
+
+ +
+ +
+
+ Mocking disabled +
+ + +
+ +
+
+ Variables @@ -330,20 +437,25 @@ />
-
-
- Other options +
+
+ Other Options
- +
+ +
diff --git a/packages/clerk-js/src/mocking/controller.ts b/packages/clerk-js/src/mocking/controller.ts new file mode 100644 index 00000000000..92e8a1a8706 --- /dev/null +++ b/packages/clerk-js/src/mocking/controller.ts @@ -0,0 +1,200 @@ +import { http, HttpResponse } from 'msw'; +import { setupWorker } from 'msw/browser'; + +import type { MockScenario } from './scenarios'; + +/** + * Configuration options for the mock controller + */ +export interface ClerkMockConfig { + debug?: boolean; + delay?: number | { min: number; max: number }; + persist?: boolean; + scenario?: string; +} + +/** + * Controller for managing Clerk API mocking using MSW + * Browser-only implementation for sandbox and documentation sites + */ +export class ClerkMockController { + private worker: ReturnType | null = null; + private activeScenario: MockScenario | null = null; + private config: ClerkMockConfig; + private scenarios: Map = new Map(); + + constructor(config: ClerkMockConfig = {}) { + this.config = { + delay: { min: 100, max: 500 }, + persist: false, + debug: false, + ...config, + }; + } + + /** + * Register a new mock scenario + */ + registerScenario(scenario: MockScenario): void { + this.scenarios.set(scenario.name, scenario); + } + + /** + * Start the mock service worker + */ + async start(scenarioName?: string): Promise { + const handlers = this.getHandlers(scenarioName); + console.log( + `🔧 MSW: Loaded ${this.scenarios.size} scenarios, starting with scenario: ${scenarioName || 'default'} (${handlers.length} handlers)`, + ); + + this.worker = setupWorker(...handlers); + + const isDeployed = + window.location.hostname !== 'localhost' && + !window.location.hostname.includes('127.0.0.1') && + !window.location.hostname.includes('192.168.'); + + const workerConfig = { + onUnhandledRequest: this.config.debug ? ('warn' as const) : ('bypass' as const), + ...(isDeployed + ? { + serviceWorker: { + url: '/mockServiceWorker.js', + options: { + scope: '/', + }, + }, + } + : { + serviceWorker: { + url: '/mockServiceWorker.js', + }, + }), + }; + + try { + await this.worker.start(workerConfig); + console.log(`🔧 MSW: Worker started successfully ${isDeployed ? '(deployed mode)' : '(local mode)'}`); + } catch (error) { + console.warn('🔧 MSW: Failed to start service worker, falling back to development mode:', error); + + try { + await this.worker.start({ + onUnhandledRequest: this.config.debug ? ('warn' as const) : ('bypass' as const), + }); + console.log('🔧 MSW: Worker started in fallback mode'); + } catch (fallbackError) { + console.error('🔧 MSW: Failed to start worker in fallback mode:', fallbackError); + throw new Error(`Failed to initialize mocking: ${fallbackError.message}`); + } + } + } + + /** + * Stop the mock service worker + */ + stop(): void { + if (this.worker) { + this.worker.stop(); + this.worker = null; + } + } + + /** + * Switch to a different scenario + */ + switchScenario(scenarioName: string): void { + const scenario = this.scenarios.get(scenarioName); + if (!scenario) { + throw new Error(`Scenario "${scenarioName}" not found`); + } + + this.activeScenario = scenario; + + if (this.worker) { + this.worker.use(...scenario.handlers); + } + + if (this.config.debug) { + console.log(`Switched to scenario: ${scenarioName}`); + } + } + + /** + * Get the current active scenario + */ + getActiveScenario(): MockScenario | null { + return this.activeScenario; + } + + /** + * Get all registered scenarios + */ + getScenarios(): MockScenario[] { + return Array.from(this.scenarios.values()); + } + + /** + * Check if a scenario is registered + */ + hasScenario(scenarioName: string): boolean { + return this.scenarios.has(scenarioName); + } + + private getHandlers(scenarioName?: string): any[] { + if (scenarioName) { + const scenario = this.scenarios.get(scenarioName); + if (!scenario) { + console.error(`🔧 MSW: Scenario "${scenarioName}" not found!`); + throw new Error(`Scenario "${scenarioName}" not found`); + } + this.activeScenario = scenario; + return scenario.handlers; + } + + return [ + http.get('/v1/environment', () => { + return HttpResponse.json({ + auth: { + authConfig: { + singleSessionMode: false, + urlBasedSessionSyncing: true, + }, + displayConfig: { + branded: false, + captchaPublicKey: null, + homeUrl: 'https://example.com', + instanceEnvironmentType: 'production', + faviconImageUrl: '', + logoImageUrl: '', + preferredSignInStrategy: 'password', + signInUrl: '', + signUpUrl: '', + userProfileUrl: '', + afterSignInUrl: '', + afterSignUpUrl: '', + }, + }, + user: null, + organization: null, + }); + }), + + http.get('/v1/client', () => { + return HttpResponse.json({ + response: { + sessions: [], + signIn: null, + signUp: null, + lastActiveSessionId: null, + }, + }); + }), + + http.all('*', () => { + return HttpResponse.json({ error: 'Not found' }, { status: 404 }); + }), + ]; + } +} diff --git a/packages/clerk-js/src/mocking/dataGenerator.ts b/packages/clerk-js/src/mocking/dataGenerator.ts new file mode 100644 index 00000000000..ac9f2c31393 --- /dev/null +++ b/packages/clerk-js/src/mocking/dataGenerator.ts @@ -0,0 +1,264 @@ +import type { + EmailAddressResource, + PhoneNumberResource, + SessionResource, + SignInResource, + SignUpResource, + UserResource, +} from '@clerk/types'; + +/** + * Generates mock data for Clerk resources using realistic defaults + * and allowing for easy customization through overrides. + */ +export class ClerkMockDataGenerator { + static createUser(overrides?: Partial): UserResource { + return { + id: 'user_2NNEqL3jKm1lQxVKZ5gXyZ', + object: 'user', + externalId: null, + primaryEmailAddressId: 'email_2NNEqL3jKm1lQxVKZ5gXyZ', + primaryEmailAddress: this.createEmailAddress(), + primaryPhoneNumberId: null, + primaryPhoneNumber: null, + primaryWeb3WalletId: null, + primaryWeb3Wallet: null, + username: 'testuser', + fullName: 'John Doe', + firstName: 'John', + lastName: 'Doe', + imageUrl: 'https://img.clerk.com/profile.jpg', + hasImage: true, + emailAddresses: [this.createEmailAddress()], + phoneNumbers: [], + web3Wallets: [], + externalAccounts: [], + enterpriseAccounts: [], + passkeys: [], + samlAccounts: [], + organizationMemberships: [], + passwordEnabled: true, + totpEnabled: false, + backupCodeEnabled: false, + twoFactorEnabled: false, + publicMetadata: {}, + unsafeMetadata: {}, + lastSignInAt: new Date(), + legalAcceptedAt: null, + createOrganizationEnabled: true, + createOrganizationsLimit: null, + deleteSelfEnabled: true, + updatedAt: new Date(), + createdAt: new Date(), + // Mock methods - these would be properly implemented in real usage + update: async () => this.createUser(), + delete: async () => {}, + updatePassword: async () => this.createUser(), + removePassword: async () => this.createUser(), + createEmailAddress: async () => this.createEmailAddress(), + createPasskey: async () => ({}) as any, + createPhoneNumber: async () => this.createPhoneNumber(), + createWeb3Wallet: async () => ({}) as any, + isPrimaryIdentification: () => true, + getSessions: async () => [], + setProfileImage: async () => ({}) as any, + createExternalAccount: async () => ({}) as any, + getOrganizationMemberships: async () => ({}) as any, + getOrganizationInvitations: async () => ({}) as any, + getOrganizationSuggestions: async () => ({}) as any, + leaveOrganization: async () => ({}) as any, + createTOTP: async () => ({}) as any, + verifyTOTP: async () => ({}) as any, + disableTOTP: async () => ({}) as any, + createBackupCode: async () => ({}) as any, + get verifiedExternalAccounts() { + return []; + }, + get unverifiedExternalAccounts() { + return []; + }, + get verifiedWeb3Wallets() { + return []; + }, + get hasVerifiedEmailAddress() { + return true; + }, + get hasVerifiedPhoneNumber() { + return false; + }, + __internal_toSnapshot: () => ({}) as any, + ...overrides, + } as UserResource; + } + + static createEmailAddress(overrides?: Partial): EmailAddressResource { + return { + id: 'email_2NNEqL3jKm1lQxVKZ5gXyZ', + object: 'email_address', + emailAddress: 'john.doe@example.com', + verification: { + status: 'verified', + strategy: 'email_code', + attempts: null, + expireAt: null, + }, + linkedTo: [], + ...overrides, + } as EmailAddressResource; + } + + static createPhoneNumber(overrides?: Partial): PhoneNumberResource { + return { + id: 'phone_2NNEqL3jKm1lQxVKZ5gXyZ', + object: 'phone_number', + phoneNumber: '+1234567890', + verification: { + status: 'verified', + strategy: 'phone_code', + attempts: null, + expireAt: null, + }, + linkedTo: [], + ...overrides, + } as PhoneNumberResource; + } + + static createSession(overrides?: Partial): SessionResource { + return { + id: 'sess_2NNEqL3jKm1lQxVKZ5gXyZ', + object: 'session', + status: 'active', + expireAt: new Date(Date.now() + 86400000), + abandonAt: new Date(Date.now() + 86400000), + factorVerificationAge: null, + lastActiveToken: null, + lastActiveOrganizationId: null, + lastActiveAt: new Date(), + actor: null, + tasks: null, + currentTask: undefined, + user: this.createUser(), + publicUserData: { + firstName: 'John', + lastName: 'Doe', + imageUrl: 'https://img.clerk.com/profile.jpg', + hasImage: true, + identifier: 'john.doe@example.com', + userId: 'user_2NNEqL3jKm1lQxVKZ5gXyZ', + }, + createdAt: new Date(), + updatedAt: new Date(), + // Mock methods + end: async () => this.createSession(), + remove: async () => this.createSession(), + touch: async () => this.createSession(), + getToken: async () => 'mock-token', + checkAuthorization: () => true, + clearCache: () => {}, + startVerification: async () => ({}) as any, + prepareFirstFactorVerification: async () => ({}) as any, + attemptFirstFactorVerification: async () => ({}) as any, + prepareSecondFactorVerification: async () => ({}) as any, + attemptSecondFactorVerification: async () => ({}) as any, + verifyWithPasskey: async () => ({}) as any, + __internal_toSnapshot: () => ({}) as any, + ...overrides, + } as SessionResource; + } + + static createSignInAttempt(overrides?: Partial): SignInResource { + return { + id: 'sign_in_2NNEqL3jKm1lQxVKZ5gXyZ', + object: 'sign_in', + status: 'needs_identifier', + supportedIdentifiers: ['email_address', 'phone_number', 'username'], + supportedFirstFactors: [ + { + strategy: 'password', + emailAddressId: null, + phoneNumberId: null, + web3WalletId: null, + safeIdentifier: null, + }, + { + strategy: 'oauth_google', + emailAddressId: null, + phoneNumberId: null, + web3WalletId: null, + safeIdentifier: null, + }, + ], + supportedSecondFactors: null, + firstFactorVerification: null, + secondFactorVerification: null, + identifier: null, + userData: null, + createdSessionId: null, + // Mock methods + create: async () => this.createSignInAttempt(), + resetPassword: async () => this.createSignInAttempt(), + prepareFirstFactor: async () => this.createSignInAttempt(), + attemptFirstFactor: async () => this.createSignInAttempt(), + prepareSecondFactor: async () => this.createSignInAttempt(), + attemptSecondFactor: async () => this.createSignInAttempt(), + authenticateWithRedirect: async () => {}, + authenticateWithPopup: async () => {}, + authenticateWithWeb3: async () => this.createSignInAttempt(), + authenticateWithMetamask: async () => this.createSignInAttempt(), + authenticateWithCoinbaseWallet: async () => this.createSignInAttempt(), + authenticateWithOKXWallet: async () => this.createSignInAttempt(), + authenticateWithBase: async () => this.createSignInAttempt(), + authenticateWithPasskey: async () => this.createSignInAttempt(), + createEmailLinkFlow: () => ({}) as any, + validatePassword: () => {}, + __internal_toSnapshot: () => ({}) as any, + __internal_future: {} as any, + ...overrides, + } as SignInResource; + } + + static createSignUpAttempt(overrides?: Partial): SignUpResource { + return { + id: 'sign_up_2NNEqL3jKm1lQxVKZ5gXyZ', + object: 'sign_up', + status: 'missing_requirements', + requiredFields: ['email_address', 'password'], + optionalFields: ['first_name', 'last_name'], + missingFields: ['email_address', 'password'], + unverifiedFields: [], + verifications: {}, + username: null, + firstName: null, + lastName: null, + emailAddress: null, + phoneNumber: null, + web3Wallet: null, + externalAccount: null, + hasPassword: false, + createdSessionId: null, + createdUserId: null, + // Mock methods + create: async () => this.createSignUpAttempt(), + update: async () => this.createSignUpAttempt(), + prepareEmailAddressVerification: async () => this.createSignUpAttempt(), + attemptEmailAddressVerification: async () => this.createSignUpAttempt(), + preparePhoneNumberVerification: async () => this.createSignUpAttempt(), + attemptPhoneNumberVerification: async () => this.createSignUpAttempt(), + prepareWeb3WalletVerification: async () => this.createSignUpAttempt(), + attemptWeb3WalletVerification: async () => this.createSignUpAttempt(), + createEmailLinkFlow: () => ({}) as any, + authenticateWithRedirect: async () => {}, + authenticateWithPopup: async () => {}, + authenticateWithWeb3: async () => this.createSignUpAttempt(), + authenticateWithMetamask: async () => this.createSignUpAttempt(), + authenticateWithCoinbaseWallet: async () => this.createSignUpAttempt(), + authenticateWithOKXWallet: async () => this.createSignUpAttempt(), + authenticateWithBase: async () => this.createSignUpAttempt(), + authenticateWithPasskey: async () => this.createSignUpAttempt(), + validatePassword: () => {}, + __internal_toSnapshot: () => ({}) as any, + __internal_future: {} as any, + ...overrides, + } as SignUpResource; + } +} diff --git a/packages/clerk-js/src/mocking/hooks.ts b/packages/clerk-js/src/mocking/hooks.ts new file mode 100644 index 00000000000..b676ec5b0ab --- /dev/null +++ b/packages/clerk-js/src/mocking/hooks.ts @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; + +import type { ClerkMockConfig } from './controller'; +import { ClerkMockController } from './controller'; + +export function useClerkMocking(config?: ClerkMockConfig) { + const [controller, setController] = useState(null); + const [isReady, setIsReady] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + const initializeMocking = async () => { + try { + const mockController = new ClerkMockController(config); + + await mockController.start(config?.scenario); + + if (mounted) { + setController(mockController); + setIsReady(true); + setError(null); + } + } catch (err) { + if (mounted) { + setError(err instanceof Error ? err : new Error('Failed to initialize mocking')); + setIsReady(false); + } + } + }; + + initializeMocking(); + + return () => { + mounted = false; + if (controller) { + controller.stop(); + } + }; + }, []); + + const switchScenario = (scenario: string) => { + if (controller) { + try { + controller.switchScenario(scenario); + setError(null); + } catch (err) { + setError(err instanceof Error ? err : new Error(`Failed to switch to scenario: ${scenario}`)); + } + } + }; + + const getActiveScenario = () => { + return controller?.getActiveScenario() || null; + }; + + const getScenarios = () => { + return controller?.getScenarios() || []; + }; + + return { + isReady, + error, + controller, + switchScenario, + getActiveScenario, + getScenarios, + }; +} + +/** + * Hook for managing multiple mock scenarios + * Useful for documentation sites with scenario switching + */ +export function useClerkMockScenarios(config?: ClerkMockConfig) { + const { isReady, error, controller, switchScenario, getActiveScenario, getScenarios } = useClerkMocking(config); + const [activeScenarioName, setActiveScenarioName] = useState(null); + + const handleScenarioSwitch = (scenarioName: string) => { + switchScenario(scenarioName); + setActiveScenarioName(scenarioName); + }; + + const scenarios = getScenarios(); + const activeScenario = getActiveScenario(); + + return { + isReady, + error, + scenarios, + activeScenario, + activeScenarioName, + switchScenario: handleScenarioSwitch, + controller, + }; +} diff --git a/packages/clerk-js/src/mocking/index.ts b/packages/clerk-js/src/mocking/index.ts new file mode 100644 index 00000000000..1f53f394a2a --- /dev/null +++ b/packages/clerk-js/src/mocking/index.ts @@ -0,0 +1,10 @@ +export { ClerkMockDataGenerator } from './dataGenerator'; +export { ClerkMockController } from './controller'; +export type { ClerkMockConfig } from './controller'; + +export { ClerkMockScenarios } from './scenarios'; +export type { MockScenario } from './scenarios'; + +export { useClerkMocking, useClerkMockScenarios } from './hooks'; + +export type { RequestHandler } from 'msw'; diff --git a/packages/clerk-js/src/mocking/scenarios.ts b/packages/clerk-js/src/mocking/scenarios.ts new file mode 100644 index 00000000000..67aedc34eac --- /dev/null +++ b/packages/clerk-js/src/mocking/scenarios.ts @@ -0,0 +1,624 @@ +import type { SessionResource, SignInResource, SignUpResource, UserResource } from '@clerk/types'; +import { http, HttpResponse } from 'msw'; + +import { ClerkMockDataGenerator } from './dataGenerator'; + +/** + * Defines a mock scenario with handlers and initial state + */ +export interface MockScenario { + name: string; + description: string; + handlers: any[]; + initialState?: { + user?: UserResource; + session?: SessionResource; + signIn?: SignInResource; + signUp?: SignUpResource; + }; +} + +/** + * Predefined mock scenarios for common Clerk flows + */ +export class ClerkMockScenarios { + /** + * UserButton scenario - user is signed in and can access profile + */ + static userButtonSignedIn(): MockScenario { + const user = ClerkMockDataGenerator.createUser(); + const session = ClerkMockDataGenerator.createSession({ user }); + + return { + name: 'user-button-signed-in', + description: 'UserButton component with signed-in user', + initialState: { user, session }, + handlers: [ + http.get('*/v1/environment*', () => { + return HttpResponse.json({ + auth: { + authConfig: { + singleSessionMode: false, + urlBasedSessionSyncing: true, + }, + displayConfig: { + branded: false, + captchaPublicKey: null, + homeUrl: 'https://example.com', + instanceEnvironmentType: 'production', + faviconImageUrl: '', + logoImageUrl: '', + preferredSignInStrategy: 'password', + signInUrl: '', + signUpUrl: '', + userProfileUrl: '', + afterSignInUrl: '', + afterSignUpUrl: '', + }, + }, + user: user, + organization: null, + }); + }), + + http.patch('*/v1/environment*', () => { + return HttpResponse.json({ + auth: { + authConfig: { + singleSessionMode: false, + urlBasedSessionSyncing: true, + }, + displayConfig: { + branded: false, + captchaPublicKey: null, + homeUrl: 'https://example.com', + instanceEnvironmentType: 'production', + faviconImageUrl: '', + logoImageUrl: '', + preferredSignInStrategy: 'password', + signInUrl: '', + signUpUrl: '', + userProfileUrl: '', + afterSignInUrl: '', + afterSignUpUrl: '', + }, + }, + user: user, + organization: null, + }); + }), + + http.get('*/v1/client*', () => { + return HttpResponse.json({ + response: { + sessions: [session], + signIn: null, + signUp: null, + lastActiveSessionId: session.id, + }, + }); + }), + + http.get('/v1/client/users/:userId', () => { + return HttpResponse.json({ response: user }); + }), + + http.post('*/v1/client/sessions/*/tokens*', () => { + return HttpResponse.json({ + response: { + jwt: 'mock-jwt-token', + session: session, + }, + }); + }), + + http.post('/v1/client/sessions/:sessionId/end', () => { + return HttpResponse.json({ + response: { + ...session, + status: 'ended', + }, + }); + }), + + http.post('https://clerk-telemetry.com/v1/event', () => { + return HttpResponse.json({ + success: true, + }); + }), + + http.all('https://*.clerk.com/v1/*', () => { + return HttpResponse.json({ + response: {}, + }); + }), + ], + }; + } + + static userProfileBarebones(): MockScenario { + const emailAddress = ClerkMockDataGenerator.createEmailAddress({ + id: 'email_alexandra_chen', + emailAddress: 'alexandra.chen@techcorp.com', + }); + + const phoneNumber = ClerkMockDataGenerator.createPhoneNumber({ + id: 'phone_alexandra_chen', + phoneNumber: '+1 (555) 123-4567', + }); + + const user = ClerkMockDataGenerator.createUser({ + id: 'user_profile_barebones', + username: 'alexandra.chen', + firstName: 'Alexandra', + lastName: 'Chen', + fullName: 'Alexandra Chen', + primaryEmailAddressId: 'email_alexandra_chen', + primaryEmailAddress: emailAddress, + emailAddresses: [emailAddress], + primaryPhoneNumberId: 'phone_alexandra_chen', + primaryPhoneNumber: phoneNumber, + phoneNumbers: [phoneNumber], + imageUrl: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=400&h=400&fit=crop&crop=face', + hasImage: true, + publicMetadata: { + bio: 'Senior Software Engineer passionate about building scalable web applications.', + location: 'San Francisco, CA', + website: 'https://alexchen.dev', + }, + }); + + const session = ClerkMockDataGenerator.createSession({ user }); + + return { + name: 'user-profile-barebones', + description: 'Barebones user profile with basic data points', + handlers: [ + http.get('*/v1/environment*', () => { + return HttpResponse.json({ + id: 'env_1', + object: 'environment', + auth_config: { + object: 'auth_config', + id: 'aac_1', + single_session_mode: false, + url_based_session_syncing: true, + }, + display_config: { + object: 'display_config', + id: 'display_config_1', + branded: true, + captcha_public_key: 'captcha_key_123', + home_url: 'https://techcorp.com', + instance_environment_type: 'production', + favicon_image_url: 'https://techcorp.com/favicon.ico', + logo_image_url: 'https://techcorp.com/logo.png', + preferred_sign_in_strategy: 'password', + sign_in_url: 'https://techcorp.com/sign-in', + sign_up_url: 'https://techcorp.com/sign-up', + user_profile_url: 'https://techcorp.com/user-profile', + after_sign_in_url: 'https://techcorp.com/dashboard', + after_sign_up_url: 'https://techcorp.com/onboarding', + }, + user_settings: { + attributes: { + email_address: { + enabled: true, + required: true, + used_for_first_factor: true, + first_factors: ['email_code'], + used_for_second_factor: false, + second_factors: [], + verifications: ['email_code'], + verify_at_sign_up: true, + name: 'email_address', + }, + phone_number: { + enabled: true, + required: false, + used_for_first_factor: true, + first_factors: ['phone_code'], + used_for_second_factor: false, + second_factors: [], + verifications: ['phone_code'], + verify_at_sign_up: false, + name: 'phone_number', + }, + username: { + enabled: true, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + name: 'username', + }, + }, + }, + organization_settings: { + object: 'organization_settings', + id: 'org_settings_1', + enabled: true, + }, + commerce_settings: { + object: 'commerce_settings', + id: 'commerce_settings_1', + }, + meta: { responseHeaders: { country: 'us' } }, + }); + }), + + http.patch('*/v1/environment*', () => { + return HttpResponse.json({ + id: 'env_1', + object: 'environment', + auth_config: { + object: 'auth_config', + id: 'aac_1', + single_session_mode: false, + url_based_session_syncing: true, + }, + display_config: { + object: 'display_config', + id: 'display_config_1', + branded: true, + captcha_public_key: 'captcha_key_123', + home_url: 'https://techcorp.com', + instance_environment_type: 'production', + favicon_image_url: 'https://techcorp.com/favicon.ico', + logo_image_url: 'https://techcorp.com/logo.png', + preferred_sign_in_strategy: 'password', + sign_in_url: 'https://techcorp.com/sign-in', + sign_up_url: 'https://techcorp.com/sign-up', + user_profile_url: 'https://techcorp.com/user-profile', + after_sign_in_url: 'https://techcorp.com/dashboard', + after_sign_up_url: 'https://techcorp.com/onboarding', + }, + user_settings: { + attributes: { + email_address: { + enabled: true, + required: true, + used_for_first_factor: true, + first_factors: ['email_code'], + used_for_second_factor: false, + second_factors: [], + verifications: ['email_code'], + verify_at_sign_up: true, + name: 'email_address', + }, + phone_number: { + enabled: true, + required: false, + used_for_first_factor: true, + first_factors: ['phone_code'], + used_for_second_factor: false, + second_factors: [], + verifications: ['phone_code'], + verify_at_sign_up: false, + name: 'phone_number', + }, + username: { + enabled: true, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + name: 'username', + }, + }, + }, + organization_settings: { + object: 'organization_settings', + id: 'org_settings_1', + enabled: true, + }, + commerce_settings: { + object: 'commerce_settings', + id: 'commerce_settings_1', + }, + meta: { responseHeaders: { country: 'us' } }, + }); + }), + + http.get('*/v1/client*', () => { + const sessionWithProperUserData = { + id: session.id, + object: 'session', + status: session.status, + expire_at: session.expireAt, + abandon_at: session.abandonAt, + factor_verification_age: session.factorVerificationAge, + last_active_token: session.lastActiveToken, + last_active_organization_id: session.lastActiveOrganizationId, + last_active_at: session.lastActiveAt, + actor: session.actor, + tasks: session.tasks, + current_task: session.currentTask, + user: { + id: user.id, + object: 'user', + external_id: user.externalId, + primary_email_address_id: user.primaryEmailAddressId, + primary_phone_number_id: user.primaryPhoneNumberId, + primary_web3_wallet_id: user.primaryWeb3WalletId, + username: user.username, + first_name: user.firstName, + last_name: user.lastName, + full_name: user.fullName, + image_url: user.imageUrl, + has_image: user.hasImage, + email_addresses: user.emailAddresses.map(email => ({ + id: email.id, + object: 'email_address', + email_address: email.emailAddress, + verification: email.verification, + linked_to: email.linkedTo, + })), + phone_numbers: user.phoneNumbers.map(phone => ({ + id: phone.id, + object: 'phone_number', + phone_number: phone.phoneNumber, + verification: phone.verification, + linked_to: phone.linkedTo, + })), + web3_wallets: user.web3Wallets.map(wallet => ({ + id: wallet.id, + object: 'web3_wallet', + web3_wallet: wallet.web3Wallet, + verification: wallet.verification, + linked_to: wallet.linkedTo, + })), + external_accounts: user.externalAccounts.map(account => ({ + id: account.id, + object: 'external_account', + provider: account.provider, + email_address: account.emailAddress, + first_name: account.firstName, + last_name: account.lastName, + image_url: account.imageUrl, + username: account.username, + public_metadata: account.publicMetadata, + label: account.label, + verification: account.verification, + linked_to: account.linkedTo, + })), + enterprise_accounts: user.enterpriseAccounts.map(account => ({ + id: account.id, + object: 'enterprise_account', + provider: account.provider, + email_address: account.emailAddress, + first_name: account.firstName, + last_name: account.lastName, + image_url: account.imageUrl, + username: account.username, + public_metadata: account.publicMetadata, + label: account.label, + verification: account.verification, + linked_to: account.linkedTo, + enterprise_connection: account.enterpriseConnection, + })), + passkeys: user.passkeys.map(passkey => ({ + id: passkey.id, + object: 'passkey', + name: passkey.name, + public_key: passkey.publicKey, + verification: passkey.verification, + linked_to: passkey.linkedTo, + })), + saml_accounts: user.samlAccounts.map(account => ({ + id: account.id, + object: 'saml_account', + provider: account.provider, + email_address: account.emailAddress, + first_name: account.firstName, + last_name: account.lastName, + image_url: account.imageUrl, + username: account.username, + public_metadata: account.publicMetadata, + label: account.label, + verification: account.verification, + linked_to: account.linkedTo, + })), + organization_memberships: user.organizationMemberships.map(membership => ({ + id: membership.id, + object: 'organization_membership', + organization: membership.organization, + public_metadata: membership.publicMetadata, + public_user_metadata: membership.publicUserMetadata, + role: membership.role, + permissions: membership.permissions, + created_at: membership.createdAt, + updated_at: membership.updatedAt, + })), + password_enabled: user.passwordEnabled, + totp_enabled: user.totpEnabled, + backup_code_enabled: user.backupCodeEnabled, + two_factor_enabled: user.twoFactorEnabled, + public_metadata: user.publicMetadata, + unsafe_metadata: user.unsafeMetadata, + create_organization_enabled: user.createOrganizationEnabled, + create_organizations_limit: user.createOrganizationsLimit, + delete_self_enabled: user.deleteSelfEnabled, + last_sign_in_at: user.lastSignInAt, + legal_accepted_at: user.legalAcceptedAt, + updated_at: user.updatedAt, + created_at: user.createdAt, + }, + public_user_data: { + first_name: user.firstName, + last_name: user.lastName, + image_url: user.imageUrl, + has_image: user.hasImage, + identifier: user.primaryEmailAddress?.emailAddress || user.username || '', + user_id: user.id, + }, + created_at: session.createdAt, + updated_at: session.updatedAt, + }; + + return HttpResponse.json({ + response: { + sessions: [sessionWithProperUserData], + signIn: null, + signUp: null, + lastActiveSessionId: session.id, + }, + }); + }), + + http.get('/v1/client/users/:userId', () => { + const responseData = { + response: { + id: user.id, + object: 'user', + external_id: user.externalId, + primary_email_address_id: user.primaryEmailAddressId, + primary_phone_number_id: user.primaryPhoneNumberId, + primary_web3_wallet_id: user.primaryWeb3WalletId, + username: user.username, + first_name: user.firstName, + last_name: user.lastName, + full_name: user.fullName, + image_url: user.imageUrl, + has_image: user.hasImage, + email_addresses: user.emailAddresses.map(email => ({ + id: email.id, + object: 'email_address', + email_address: email.emailAddress, + verification: email.verification, + linked_to: email.linkedTo, + })), + phone_numbers: user.phoneNumbers.map(phone => ({ + id: phone.id, + object: 'phone_number', + phone_number: phone.phoneNumber, + verification: phone.verification, + linked_to: phone.linkedTo, + })), + web3_wallets: user.web3Wallets.map(wallet => ({ + id: wallet.id, + object: 'web3_wallet', + web3_wallet: wallet.web3Wallet, + verification: wallet.verification, + linked_to: wallet.linkedTo, + })), + external_accounts: user.externalAccounts.map(account => ({ + id: account.id, + object: 'external_account', + provider: account.provider, + email_address: account.emailAddress, + first_name: account.firstName, + last_name: account.lastName, + image_url: account.imageUrl, + username: account.username, + public_metadata: account.publicMetadata, + label: account.label, + verification: account.verification, + linked_to: account.linkedTo, + })), + enterprise_accounts: user.enterpriseAccounts.map(account => ({ + id: account.id, + object: 'enterprise_account', + provider: account.provider, + email_address: account.emailAddress, + first_name: account.firstName, + last_name: account.lastName, + image_url: account.imageUrl, + username: account.username, + public_metadata: account.publicMetadata, + label: account.label, + verification: account.verification, + linked_to: account.linkedTo, + enterprise_connection: account.enterpriseConnection, + })), + passkeys: user.passkeys.map(passkey => ({ + id: passkey.id, + object: 'passkey', + name: passkey.name, + public_key: passkey.publicKey, + verification: passkey.verification, + linked_to: passkey.linkedTo, + })), + saml_accounts: user.samlAccounts.map(account => ({ + id: account.id, + object: 'saml_account', + provider: account.provider, + email_address: account.emailAddress, + first_name: account.firstName, + last_name: account.lastName, + image_url: account.imageUrl, + username: account.username, + public_metadata: account.publicMetadata, + label: account.label, + verification: account.verification, + linked_to: account.linkedTo, + })), + organization_memberships: user.organizationMemberships.map(membership => ({ + id: membership.id, + object: 'organization_membership', + organization: membership.organization, + public_metadata: membership.publicMetadata, + public_user_metadata: membership.publicUserMetadata, + role: membership.role, + permissions: membership.permissions, + created_at: membership.createdAt, + updated_at: membership.updatedAt, + })), + password_enabled: user.passwordEnabled, + totp_enabled: user.totpEnabled, + backup_code_enabled: user.backupCodeEnabled, + two_factor_enabled: user.twoFactorEnabled, + public_metadata: user.publicMetadata, + unsafe_metadata: user.unsafeMetadata, + create_organization_enabled: user.createOrganizationEnabled, + create_organizations_limit: user.createOrganizationsLimit, + delete_self_enabled: user.deleteSelfEnabled, + last_sign_in_at: user.lastSignInAt, + legal_accepted_at: user.legalAcceptedAt, + updated_at: user.updatedAt, + created_at: user.createdAt, + }, + }; + + return HttpResponse.json(responseData); + }), + + http.post('*/v1/client/sessions/*/tokens*', () => { + return HttpResponse.json({ + response: { + jwt: 'mock-jwt-token-comprehensive', + session: session, + }, + }); + }), + + http.post('/v1/client/sessions/:sessionId/end', () => { + return HttpResponse.json({ + response: { + ...session, + status: 'ended', + }, + }); + }), + + http.post('https://clerk-telemetry.com/v1/event', () => { + return HttpResponse.json({ + success: true, + }); + }), + + http.all('https://*.clerk.com/v1/*', () => { + return HttpResponse.json({ + response: {}, + }); + }), + ], + }; + } +} diff --git a/packages/clerk-js/vercel.json b/packages/clerk-js/vercel.json index 3a48e56ba52..d5fb895797f 100644 --- a/packages/clerk-js/vercel.json +++ b/packages/clerk-js/vercel.json @@ -1,3 +1,14 @@ { - "rewrites": [{ "source": "/(.*)", "destination": "/" }] + "rewrites": [{ "source": "/((?!mockServiceWorker\\.js).*)", "destination": "/" }], + "headers": [ + { + "source": "/mockServiceWorker.js", + "headers": [ + { + "key": "Content-Type", + "value": "application/javascript" + } + ] + } + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30cddd05e7b..bee1b67c9ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -549,6 +549,9 @@ importers: minimatch: specifier: ^10.0.3 version: 10.0.3 + msw: + specifier: ^2.0.0 + version: 2.11.3(@types/node@22.18.6)(typescript@5.8.3) webpack-merge: specifier: ^5.10.0 version: 5.10.0 @@ -17428,7 +17431,6 @@ snapshots: '@inquirer/type': 3.0.7(@types/node@22.18.6) optionalDependencies: '@types/node': 22.18.6 - optional: true '@inquirer/confirm@5.1.13(@types/node@24.3.1)': dependencies: @@ -17449,7 +17451,6 @@ snapshots: yoctocolors-cjs: 2.1.2 optionalDependencies: '@types/node': 22.18.6 - optional: true '@inquirer/core@10.1.14(@types/node@24.3.1)': dependencies: @@ -17469,7 +17470,6 @@ snapshots: '@inquirer/type@3.0.7(@types/node@22.18.6)': optionalDependencies: '@types/node': 22.18.6 - optional: true '@inquirer/type@3.0.7(@types/node@24.3.1)': optionalDependencies: @@ -27472,7 +27472,6 @@ snapshots: typescript: 5.8.3 transitivePeerDependencies: - '@types/node' - optional: true msw@2.11.3(@types/node@24.3.1)(typescript@5.8.3): dependencies: