From e33f273d7cbc31205ad1a14fbcca9a344c78eac9 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 24 Sep 2025 16:14:00 -0500 Subject: [PATCH 1/9] feat: initial fapi mocks --- packages/clerk-js/package.json | 13 +- packages/clerk-js/public/mockServiceWorker.js | 335 ++++++++++++++++++ packages/clerk-js/sandbox/app.ts | 23 +- packages/clerk-js/sandbox/mocking.ts | 128 +++++++ packages/clerk-js/sandbox/template.html | 140 +++++++- packages/clerk-js/src/mocking/controller.ts | 169 +++++++++ .../clerk-js/src/mocking/dataGenerator.ts | 264 ++++++++++++++ packages/clerk-js/src/mocking/hooks.ts | 97 +++++ packages/clerk-js/src/mocking/index.ts | 10 + packages/clerk-js/src/mocking/scenarios.ts | 263 ++++++++++++++ pnpm-lock.yaml | 7 +- 11 files changed, 1426 insertions(+), 23 deletions(-) create mode 100644 packages/clerk-js/public/mockServiceWorker.js create mode 100644 packages/clerk-js/sandbox/mocking.ts create mode 100644 packages/clerk-js/src/mocking/controller.ts create mode 100644 packages/clerk-js/src/mocking/dataGenerator.ts create mode 100644 packages/clerk-js/src/mocking/hooks.ts create mode 100644 packages/clerk-js/src/mocking/index.ts create mode 100644 packages/clerk-js/src/mocking/scenarios.ts diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 3155f1e1333..ef6265a3aea 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -104,17 +104,28 @@ "bundlewatch": "^0.4.1", "jsdom": "^24.1.3", "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/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 15d45afc722..37094d761fd 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -1,5 +1,6 @@ -import type { Clerk as ClerkType } from '../'; 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)[]; @@ -194,7 +195,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]) => { @@ -269,6 +269,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 => { @@ -359,11 +367,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..50621be75d4 --- /dev/null +++ b/packages/clerk-js/sandbox/mocking.ts @@ -0,0 +1,128 @@ +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({ + 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 d1cc06fadf6..4a9cfdd20b6 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 @@ -323,20 +430,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..7ab521a3a7b --- /dev/null +++ b/packages/clerk-js/src/mocking/controller.ts @@ -0,0 +1,169 @@ +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 { + // Initialize MSW (browser-only) + 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); + await this.worker.start({ + onUnhandledRequest: this.config.debug ? 'warn' : 'bypass', + serviceWorker: { + url: '/mockServiceWorker.js', + }, + }); + + console.log('🔧 MSW: Worker started successfully'); + } + + /** + * 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..594ff8c52f5 --- /dev/null +++ b/packages/clerk-js/src/mocking/scenarios.ts @@ -0,0 +1,263 @@ +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 user = ClerkMockDataGenerator.createUser({ + id: 'user_profile_barebones', + username: 'alexandra.chen', + firstName: 'Alexandra', + lastName: 'Chen', + fullName: 'Alexandra Chen', + 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', + initialState: { user, session }, + handlers: [ + http.get('*/v1/environment*', () => { + return HttpResponse.json({ + auth: { + authConfig: { + singleSessionMode: false, + urlBasedSessionSyncing: true, + }, + displayConfig: { + branded: true, + captchaPublicKey: 'captcha_key_123', + homeUrl: 'https://techcorp.com', + instanceEnvironmentType: 'production', + faviconImageUrl: 'https://techcorp.com/favicon.ico', + logoImageUrl: 'https://techcorp.com/logo.png', + preferredSignInStrategy: 'password', + signInUrl: 'https://techcorp.com/sign-in', + signUpUrl: 'https://techcorp.com/sign-up', + userProfileUrl: 'https://techcorp.com/user-profile', + afterSignInUrl: 'https://techcorp.com/dashboard', + afterSignUpUrl: 'https://techcorp.com/onboarding', + }, + }, + user: user, + organization: null, + }); + }), + + http.patch('*/v1/environment*', () => { + return HttpResponse.json({ + auth: { + authConfig: { + singleSessionMode: false, + urlBasedSessionSyncing: true, + }, + displayConfig: { + branded: true, + captchaPublicKey: 'captcha_key_123', + homeUrl: 'https://techcorp.com', + instanceEnvironmentType: 'production', + faviconImageUrl: 'https://techcorp.com/favicon.ico', + logoImageUrl: 'https://techcorp.com/logo.png', + preferredSignInStrategy: 'password', + signInUrl: 'https://techcorp.com/sign-in', + signUpUrl: 'https://techcorp.com/sign-up', + userProfileUrl: 'https://techcorp.com/user-profile', + afterSignInUrl: 'https://techcorp.com/dashboard', + afterSignUpUrl: 'https://techcorp.com/onboarding', + }, + }, + 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-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/pnpm-lock.yaml b/pnpm-lock.yaml index 4e5b8c86e82..1218fa6dc66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -555,6 +555,9 @@ importers: minimatch: specifier: ^10.0.3 version: 10.0.3 + msw: + specifier: ^2.0.0 + version: 2.11.2(@types/node@22.18.6)(typescript@5.8.3) webpack-merge: specifier: ^5.10.0 version: 5.10.0 @@ -17503,7 +17506,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: @@ -17524,7 +17526,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: @@ -17544,7 +17545,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: @@ -27628,7 +27628,6 @@ snapshots: typescript: 5.8.3 transitivePeerDependencies: - '@types/node' - optional: true msw@2.11.2(@types/node@24.3.1)(typescript@5.8.3): dependencies: From f408fb62a80c4cce0f013f2d451fce465edd3e65 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 24 Sep 2025 16:31:12 -0500 Subject: [PATCH 2/9] fix response mocking format --- packages/clerk-js/src/mocking/scenarios.ts | 447 +++++++++++++++++++-- 1 file changed, 404 insertions(+), 43 deletions(-) diff --git a/packages/clerk-js/src/mocking/scenarios.ts b/packages/clerk-js/src/mocking/scenarios.ts index 594ff8c52f5..67aedc34eac 100644 --- a/packages/clerk-js/src/mocking/scenarios.ts +++ b/packages/clerk-js/src/mocking/scenarios.ts @@ -137,12 +137,28 @@ export class ClerkMockScenarios { } 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: { @@ -157,66 +173,298 @@ export class ClerkMockScenarios { return { name: 'user-profile-barebones', description: 'Barebones user profile with basic data points', - initialState: { user, session }, handlers: [ http.get('*/v1/environment*', () => { return HttpResponse.json({ - auth: { - authConfig: { - singleSessionMode: false, - urlBasedSessionSyncing: true, - }, - displayConfig: { - branded: true, - captchaPublicKey: 'captcha_key_123', - homeUrl: 'https://techcorp.com', - instanceEnvironmentType: 'production', - faviconImageUrl: 'https://techcorp.com/favicon.ico', - logoImageUrl: 'https://techcorp.com/logo.png', - preferredSignInStrategy: 'password', - signInUrl: 'https://techcorp.com/sign-in', - signUpUrl: 'https://techcorp.com/sign-up', - userProfileUrl: 'https://techcorp.com/user-profile', - afterSignInUrl: 'https://techcorp.com/dashboard', - afterSignUpUrl: 'https://techcorp.com/onboarding', + 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', + }, }, }, - user: user, - organization: null, + 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({ - auth: { - authConfig: { - singleSessionMode: false, - urlBasedSessionSyncing: true, - }, - displayConfig: { - branded: true, - captchaPublicKey: 'captcha_key_123', - homeUrl: 'https://techcorp.com', - instanceEnvironmentType: 'production', - faviconImageUrl: 'https://techcorp.com/favicon.ico', - logoImageUrl: 'https://techcorp.com/logo.png', - preferredSignInStrategy: 'password', - signInUrl: 'https://techcorp.com/sign-in', - signUpUrl: 'https://techcorp.com/sign-up', - userProfileUrl: 'https://techcorp.com/user-profile', - afterSignInUrl: 'https://techcorp.com/dashboard', - afterSignUpUrl: 'https://techcorp.com/onboarding', + 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', + }, }, }, - user: user, - organization: null, + 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: [session], + sessions: [sessionWithProperUserData], signIn: null, signUp: null, lastActiveSessionId: session.id, @@ -225,7 +473,120 @@ export class ClerkMockScenarios { }), http.get('/v1/client/users/:userId', () => { - return HttpResponse.json({ response: user }); + 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*', () => { From 591dcfc19c4e92f9cc58fe5547c1a36e6eff9023 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 24 Sep 2025 16:37:38 -0500 Subject: [PATCH 3/9] test deployed msw --- packages/clerk-js/src/mocking/controller.ts | 49 +++++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/clerk-js/src/mocking/controller.ts b/packages/clerk-js/src/mocking/controller.ts index 7ab521a3a7b..235ba8309c8 100644 --- a/packages/clerk-js/src/mocking/controller.ts +++ b/packages/clerk-js/src/mocking/controller.ts @@ -43,21 +43,52 @@ export class ClerkMockController { * Start the mock service worker */ async start(scenarioName?: string): Promise { - // Initialize MSW (browser-only) 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); - await this.worker.start({ - onUnhandledRequest: this.config.debug ? 'warn' : 'bypass', - serviceWorker: { - url: '/mockServiceWorker.js', - }, - }); - - console.log('🔧 MSW: Worker started successfully'); + + 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}`); + } + } } /** From e38c127f2c767ad561a1094081b5dabee3aa3ed9 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 24 Sep 2025 16:40:41 -0500 Subject: [PATCH 4/9] adjust mime type --- packages/clerk-js/vercel.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/vercel.json b/packages/clerk-js/vercel.json index 3a48e56ba52..c0575801d63 100644 --- a/packages/clerk-js/vercel.json +++ b/packages/clerk-js/vercel.json @@ -1,3 +1,14 @@ { - "rewrites": [{ "source": "/(.*)", "destination": "/" }] + "rewrites": [{ "source": "/(.*)", "destination": "/" }], + "headers": [ + { + "source": "/mockServiceWorker.js", + "headers": [ + { + "key": "Content-Type", + "value": "application/javascript" + } + ] + } + ] } From 95d5d1ec456570aa309e667b03b085b491f35648 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 24 Sep 2025 16:43:18 -0500 Subject: [PATCH 5/9] wip --- packages/clerk-js/vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/vercel.json b/packages/clerk-js/vercel.json index c0575801d63..d5fb895797f 100644 --- a/packages/clerk-js/vercel.json +++ b/packages/clerk-js/vercel.json @@ -1,5 +1,5 @@ { - "rewrites": [{ "source": "/(.*)", "destination": "/" }], + "rewrites": [{ "source": "/((?!mockServiceWorker\\.js).*)", "destination": "/" }], "headers": [ { "source": "/mockServiceWorker.js", From 95648a320052facf804ba27ade0b6da0446dd3fc Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 24 Sep 2025 16:49:40 -0500 Subject: [PATCH 6/9] wip --- packages/clerk-js/package.json | 1 + packages/clerk-js/rspack.config.js | 18 ++++++++++++++++++ pnpm-lock.yaml | 21 ++++++++++++++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index ef6265a3aea..8a61d3a8c0b 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -102,6 +102,7 @@ "@types/node": "^22.18.6", "@types/webpack-env": "^1.18.8", "bundlewatch": "^0.4.1", + "copy-webpack-plugin": "^12.0.2", "jsdom": "^24.1.3", "minimatch": "^10.0.3", "msw": "^2.0.0", diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index 78467f67a79..e6ff88a4674 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -5,6 +5,7 @@ const path = require('path'); const { merge } = require('webpack-merge'); const ReactRefreshPlugin = require('@rspack/plugin-react-refresh'); const { RsdoctorRspackPlugin } = require('@rsdoctor/rspack-plugin'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); const isProduction = mode => mode === 'production'; const isDevelopment = mode => !isProduction(mode); @@ -375,6 +376,14 @@ const prodConfig = ({ mode, env, analysis }) => { inject: false, hash: true, }), + new CopyWebpackPlugin({ + patterns: [ + { + from: 'public/mockServiceWorker.js', + to: 'mockServiceWorker.js', + }, + ], + }), ], } : {}, @@ -574,6 +583,15 @@ const devConfig = ({ mode, env }) => { template: './sandbox/template.html', inject: false, }), + isSandbox && + new CopyWebpackPlugin({ + patterns: [ + { + from: 'public/mockServiceWorker.js', + to: 'mockServiceWorker.js', + }, + ], + }), ].filter(Boolean), devtool: 'eval-cheap-source-map', output: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1218fa6dc66..9dfc26fb8c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -549,6 +549,9 @@ importers: bundlewatch: specifier: ^0.4.1 version: 0.4.1 + copy-webpack-plugin: + specifier: ^12.0.2 + version: 12.0.2(webpack@5.94.0(@swc/core@1.11.29(@swc/helpers@0.5.17))) jsdom: specifier: ^24.1.3 version: 24.1.3 @@ -2758,7 +2761,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -7185,6 +7188,12 @@ packages: copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + copy-webpack-plugin@12.0.2: + resolution: {integrity: sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + webpack: ^5.1.0 + core-js-compat@3.39.0: resolution: {integrity: sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==} @@ -22708,6 +22717,16 @@ snapshots: dependencies: toggle-selection: 1.0.6 + copy-webpack-plugin@12.0.2(webpack@5.94.0(@swc/core@1.11.29(@swc/helpers@0.5.17))): + dependencies: + fast-glob: 3.3.3 + glob-parent: 6.0.2 + globby: 14.1.0 + normalize-path: 3.0.0 + schema-utils: 4.3.2 + serialize-javascript: 6.0.2 + webpack: 5.94.0(@swc/core@1.11.29(@swc/helpers@0.5.17)) + core-js-compat@3.39.0: dependencies: browserslist: 4.26.0 From 18afb45bf5d7acd33477ec427d4c95a67ed3342d Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 24 Sep 2025 16:54:23 -0500 Subject: [PATCH 7/9] wip --- packages/clerk-js/package.json | 1 - packages/clerk-js/rspack.config.js | 28 ++++++++-------------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 8a61d3a8c0b..ef6265a3aea 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -102,7 +102,6 @@ "@types/node": "^22.18.6", "@types/webpack-env": "^1.18.8", "bundlewatch": "^0.4.1", - "copy-webpack-plugin": "^12.0.2", "jsdom": "^24.1.3", "minimatch": "^10.0.3", "msw": "^2.0.0", diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index e6ff88a4674..5d73d9fa76b 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -5,7 +5,6 @@ const path = require('path'); const { merge } = require('webpack-merge'); const ReactRefreshPlugin = require('@rspack/plugin-react-refresh'); const { RsdoctorRspackPlugin } = require('@rsdoctor/rspack-plugin'); -const CopyWebpackPlugin = require('copy-webpack-plugin'); const isProduction = mode => mode === 'production'; const isDevelopment = mode => !isProduction(mode); @@ -368,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, @@ -376,14 +378,6 @@ const prodConfig = ({ mode, env, analysis }) => { inject: false, hash: true, }), - new CopyWebpackPlugin({ - patterns: [ - { - from: 'public/mockServiceWorker.js', - to: 'mockServiceWorker.js', - }, - ], - }), ], } : {}, @@ -583,15 +577,6 @@ const devConfig = ({ mode, env }) => { template: './sandbox/template.html', inject: false, }), - isSandbox && - new CopyWebpackPlugin({ - patterns: [ - { - from: 'public/mockServiceWorker.js', - to: 'mockServiceWorker.js', - }, - ], - }), ].filter(Boolean), devtool: 'eval-cheap-source-map', output: { @@ -636,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(), ), From 6bb953722fd675d404444da4801e1ea9f1240f3a Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 24 Sep 2025 16:56:11 -0500 Subject: [PATCH 8/9] wip --- pnpm-lock.yaml | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9dfc26fb8c0..1218fa6dc66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -549,9 +549,6 @@ importers: bundlewatch: specifier: ^0.4.1 version: 0.4.1 - copy-webpack-plugin: - specifier: ^12.0.2 - version: 12.0.2(webpack@5.94.0(@swc/core@1.11.29(@swc/helpers@0.5.17))) jsdom: specifier: ^24.1.3 version: 24.1.3 @@ -2761,7 +2758,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -7188,12 +7185,6 @@ packages: copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} - copy-webpack-plugin@12.0.2: - resolution: {integrity: sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==} - engines: {node: '>= 18.12.0'} - peerDependencies: - webpack: ^5.1.0 - core-js-compat@3.39.0: resolution: {integrity: sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==} @@ -22717,16 +22708,6 @@ snapshots: dependencies: toggle-selection: 1.0.6 - copy-webpack-plugin@12.0.2(webpack@5.94.0(@swc/core@1.11.29(@swc/helpers@0.5.17))): - dependencies: - fast-glob: 3.3.3 - glob-parent: 6.0.2 - globby: 14.1.0 - normalize-path: 3.0.0 - schema-utils: 4.3.2 - serialize-javascript: 6.0.2 - webpack: 5.94.0(@swc/core@1.11.29(@swc/helpers@0.5.17)) - core-js-compat@3.39.0: dependencies: browserslist: 4.26.0 From f99af58eaa88cc4f73672f77d47d6a05a85adbda Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 24 Sep 2025 17:03:31 -0500 Subject: [PATCH 9/9] wip --- packages/clerk-js/sandbox/mocking.ts | 1 + packages/clerk-js/src/mocking/controller.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/sandbox/mocking.ts b/packages/clerk-js/sandbox/mocking.ts index 50621be75d4..30d0a9e1ad3 100644 --- a/packages/clerk-js/sandbox/mocking.ts +++ b/packages/clerk-js/sandbox/mocking.ts @@ -50,6 +50,7 @@ export async function setupMockingControls(): Promise { try { mockController = new ClerkMockController({ + debug: true, scenario: scenarioSelect.value || undefined, }); diff --git a/packages/clerk-js/src/mocking/controller.ts b/packages/clerk-js/src/mocking/controller.ts index 235ba8309c8..92e8a1a8706 100644 --- a/packages/clerk-js/src/mocking/controller.ts +++ b/packages/clerk-js/src/mocking/controller.ts @@ -117,7 +117,7 @@ export class ClerkMockController { } if (this.config.debug) { - console.log(`🔄 Switched to scenario: ${scenarioName}`); + console.log(`Switched to scenario: ${scenarioName}`); } }