diff --git a/scripts/mintlify-post-processing/appended-articles.json b/scripts/mintlify-post-processing/appended-articles.json index 27fb69b8..d9eb3c3b 100644 --- a/scripts/mintlify-post-processing/appended-articles.json +++ b/scripts/mintlify-post-processing/appended-articles.json @@ -1,4 +1,7 @@ { + "interfaces/ExperimentsModule": [ + "interfaces/ExperimentsSnapshot" + ], "interfaces/ConnectorsModule": [ "type-aliases/ConnectorIntegrationType", "interfaces/ConnectorIntegrationTypeRegistry", diff --git a/scripts/mintlify-post-processing/types-to-expose.json b/scripts/mintlify-post-processing/types-to-expose.json index 5d7ef016..7a9f4571 100644 --- a/scripts/mintlify-post-processing/types-to-expose.json +++ b/scripts/mintlify-post-processing/types-to-expose.json @@ -22,6 +22,8 @@ "EntityHandler", "EntityRecord", "EntityTypeRegistry", + "ExperimentsModule", + "ExperimentsSnapshot", "FunctionName", "FunctionNameRegistry", "FunctionsModule", diff --git a/src/client.ts b/src/client.ts index fe0c9834..cfb9291e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -23,6 +23,9 @@ import type { CreateClientOptions, } from "./client.types.js"; import { createAnalyticsModule } from "./modules/analytics.js"; +import { createExperimentsModule } from "./modules/experiments.js"; +import { createExposureTracker } from "./modules/experiment-exposures.js"; +import { EXPERIMENTS_CONTEXT_HEADER, getBrowserExperimentsContext, readExperimentsContext } from "./modules/experiments-context.js"; import { createActorsModule, resolveActorsHost, @@ -90,6 +93,7 @@ export function createClient(config: CreateClientConfig): Base44Client { // Normalize appBaseUrl to always be a string (empty if not provided or invalid) const normalizedAppBaseUrl = typeof appBaseUrl === "string" ? appBaseUrl : ""; + const experimentsContext = config.experiments ?? getBrowserExperimentsContext(appId); const socketConfig: RoomsSocketConfig = { serverUrl, @@ -110,9 +114,14 @@ export function createClient(config: CreateClientConfig): Base44Client { return socket; }; + const { [EXPERIMENTS_CONTEXT_HEADER]: inheritedExperimentsContext, ...requestHeaders } = optionalHeaders ?? {}; const headers = { - ...optionalHeaders, + ...requestHeaders, "X-App-Id": String(appId), + ...(experimentsContext ? { + "Base44-Visitor-Id": experimentsContext.identity.visitorId, + "Base44-Experiment-Preview": JSON.stringify(experimentsContext.preview ?? {}), + } : {}), }; const functionHeaders = functionsVersion @@ -166,6 +175,20 @@ export function createClient(config: CreateClientConfig): Base44Client { headers, }); + const exposureTracker = createExposureTracker({ + axiosClient, + appId, + enabled: analytics?.enabled ?? true, + source: typeof window === "undefined" ? "backend" : "browser", + pageUrl: experimentsContext?.pageUrl, + }); + const experiments = createExperimentsModule({ + getAuth: () => userAuthModule, + trackExposure: exposureTracker.track, + flushExposures: exposureTracker.flush, + context: experimentsContext, + }); + const userAuthModule = createAuthModule( axiosClient, functionsAxiosClient, @@ -174,6 +197,7 @@ export function createClient(config: CreateClientConfig): Base44Client { appBaseUrl: normalizedAppBaseUrl, serverUrl, token, + onAuthStateChange: experiments.onAuthStateChange, } ); @@ -187,6 +211,14 @@ export function createClient(config: CreateClientConfig): Base44Client { userAuthModule.setToken(accessToken); } } + if (experimentsContext) { + const { userId, status } = experimentsContext.identity; + // The document's cookie identity may differ from this client's localStorage token. + const needsClientIdentity = typeof window !== "undefined" && userAuthModule.hasToken() && + experimentsContext.config.experiments.some((experiment) => experiment.assign_by === "user"); + experiments.onAuthStateChange(status === "pending" || needsClientIdentity ? { status: "pending" } : + userId ? { status: "authenticated", userId } : { status: "anonymous" }); + } const actorsModule = createActorsModule({ appId, @@ -228,6 +260,7 @@ export function createClient(config: CreateClientConfig): Base44Client { integrations: createIntegrationsModule(axiosClient, appId), connectors: createUserConnectorsModule(axiosClient, appId), auth: userAuthModule, + experiments: experiments.module, functions: createFunctionsModule(functionsAxiosClient, appId, { getAuthHeaders: () => { const headers: Record = {}; @@ -257,10 +290,13 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, userAuthModule, enabled: analytics?.enabled ?? true, + getVisitorId: experiments.visitorId, + experimentsContext, }), actors: actorsModule.module, cleanup: () => { userModules.analytics.cleanup(); + experiments.cleanup(); actorsModule.closeAll(); if (socket) { socket.disconnect(); @@ -331,7 +367,10 @@ export function createClient(config: CreateClientConfig): Base44Client { appId: String(appId), serverUrl, functionsVersion, - platformHeaders: optionalHeaders, + platformHeaders: { + ...headers, + ...(inheritedExperimentsContext ? { [EXPERIMENTS_CONTEXT_HEADER]: inheritedExperimentsContext } : {}), + }, }), /** @@ -507,6 +546,9 @@ export function createClientFromRequest(request: Request): Base44Client { // Prepare additional headers to propagate const additionalHeaders: Record = {}; + const encodedExperiments = request.headers.get(EXPERIMENTS_CONTEXT_HEADER); + const experimentsContext = readExperimentsContext(encodedExperiments, appId); + if (experimentsContext && encodedExperiments) additionalHeaders[EXPERIMENTS_CONTEXT_HEADER] = encodedExperiments; if (stateHeader) { additionalHeaders["Base44-State"] = stateHeader; } @@ -528,5 +570,6 @@ export function createClientFromRequest(request: Request): Base44Client { serviceToken: serviceRoleToken, functionsVersion: functionsVersion ?? undefined, headers: additionalHeaders, + experiments: experimentsContext ? { ...experimentsContext, pageUrl: request.url ? new URL(request.url).pathname : "/" } : undefined, }); } diff --git a/src/client.types.ts b/src/client.types.ts index 31fa838a..635552db 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -12,6 +12,8 @@ import type { AiGatewayModule } from "./modules/ai-gateway.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AppModule } from "./modules/app.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; +import type { ExperimentsModule } from "./modules/experiments.types.js"; +import type { ExperimentsContext } from "./modules/experiments-config.types.js"; import type { ActorsModule } from "./modules/actors.types.js"; import type { FetchWithAuthInit } from "./utils/fetch-with-auth.js"; @@ -44,9 +46,9 @@ export interface CreateClientAnalyticsConfig { /** * Whether app analytics is enabled for this client. * - * When disabled, automatic analytics and calls to `analytics.track()` are - * no-ops. The SDK does not create an analytics session identifier, start - * heartbeat timers, or send analytics requests. + * When disabled, automatic analytics, experiment exposures and calls to + * `analytics.track()` are no-ops. The SDK does not create an analytics session + * identifier, start heartbeat timers, or send analytics requests. * * @defaultValue `true` */ @@ -85,6 +87,12 @@ export interface CreateClientConfig { * Omit this option to preserve the default analytics behavior. */ analytics?: CreateClientAnalyticsConfig; + /** + * Platform-validated context for local flag evaluation. Request-scoped on servers. + * Automatically read from the platform bootstrap in browsers and trusted headers + * by createClientFromRequest(). Not an authorization credential. + */ + experiments?: ExperimentsContext; /** * User authentication token. Used to authenticate as a specific user. * @@ -141,6 +149,8 @@ export interface Base44Client { connectors: UserConnectorsModule; /** {@link EntitiesModule | Entities module} for CRUD operations on your data models. */ entities: EntitiesModule; + /** {@link ExperimentsModule | Experiments module} for local feature flags and exposures. */ + experiments: ExperimentsModule; /** {@link FunctionsModule | Functions module} for invoking custom backend functions. */ functions: FunctionsModule; /** {@link IntegrationsModule | Integrations module} for calling pre-built integration endpoints. */ diff --git a/src/index.ts b/src/index.ts index 8842b8fe..de326da4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,8 +33,15 @@ export type { }; export * from "./types.js"; +export { evaluateExperiments } from "./modules/experiments-evaluator.js"; +export type { ExperimentsConfig, ExperimentsContext, ExperimentsIdentity } from "./modules/experiments-config.types.js"; // Module types +export type { + ExperimentsModule, + ExperimentsSnapshot, +} from "./modules/experiments.types.js"; + export type { DeleteManyResult, DeleteResult, diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index e9aebfc5..47356d72 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -11,6 +11,8 @@ import { import { getSharedInstance } from "../utils/sharedInstance.js"; import type { InternalAuthModule } from "./auth.types"; import { generateUuid, isReactNative } from "../utils/common.js"; +import { getExperimentsRuntime } from "./experiments-runtime.types.js"; +import type { ExperimentsContext } from "./experiments-config.types.js"; export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__"; export const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__"; @@ -35,15 +37,14 @@ const defaultConfiguration: AnalyticsModuleOptions = { /////////////////////////////////////////////// const ANALYTICS_SHARED_STATE_NAME = "analytics"; -// shared state// -const analyticsSharedState = getSharedInstance( - ANALYTICS_SHARED_STATE_NAME, - () => ({ +function createAnalyticsState() { + return { requestsQueue: [] as TrackEventData[], isProcessing: false, isHeartBeatProcessing: false, wasInitializationTracked: false, sessionContext: null as SessionContext | null, + sessionContextPromise: null as Promise | null, sessionStartTime: null as string | null, // Memoized session id for when `localStorage` can't persist one — see // getAnalyticsSessionId. @@ -52,8 +53,11 @@ const analyticsSharedState = getSharedInstance( ...defaultConfiguration, ...getAnalyticsConfigFromUrlParams(), } as Required, - }) -); + }; +} +type AnalyticsState = ReturnType; +const analyticsSharedState = getSharedInstance(ANALYTICS_SHARED_STATE_NAME, createAnalyticsState); +const serverAnalyticsStates = new WeakMap(); /////////////////////////////////////////////// @@ -63,6 +67,13 @@ export interface AnalyticsModuleArgs { appId: string; userAuthModule: InternalAuthModule; enabled: boolean; + getVisitorId?: () => string | undefined; + experimentsContext?: ExperimentsContext; +} + +/** @internal */ +export function isAnalyticsEnabled(enabled: boolean, state = analyticsSharedState): boolean { + return enabled && state.config.enabled && !isReactNative; } export const createAnalyticsModule = ({ @@ -71,15 +82,19 @@ export const createAnalyticsModule = ({ appId, userAuthModule, enabled, + getVisitorId, + experimentsContext, }: AnalyticsModuleArgs) => { + const state = typeof window === "undefined" ? createAnalyticsState() : analyticsSharedState; + if (typeof window === "undefined") serverAnalyticsStates.set(axiosClient, state); // prevent overflow of events // - const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config; + const { maxQueueSize, throttleTime, batchSize } = state.config; // Disable analytics on React Native. It defines `window` but not `document`, // so the per-callsite `typeof window` guards below aren't enough to keep it // from touching `document` (e.g. `document.referrer` on init). Node/SSR is // still handled by those `window` guards, so this doesn't affect it. - if (!enabled || !analyticsSharedState.config?.enabled || isReactNative) { + if (!isAnalyticsEnabled(enabled, state)) { return { track: () => {}, cleanup: () => {}, @@ -118,9 +133,9 @@ export const createAnalyticsModule = ({ ) => { if (eventsData.length === 0) return; - const sessionContext_ = await getSessionContext(userAuthModule); + const sessionContext_ = await getSessionContext(userAuthModule, state); const events = eventsData.map( - transformEventDataToApiRequestData(sessionContext_) + transformEventDataToApiRequestData({ ...sessionContext_, session_id: getVisitorId?.() ?? sessionContext_.session_id }) ); try { @@ -136,17 +151,27 @@ export const createAnalyticsModule = ({ startAnalyticsProcessor(flush, { throttleTime, batchSize, - }); + }, state); }; const track = (params: TrackEventParams) => { - if (analyticsSharedState.requestsQueue.length >= maxQueueSize) { + if (state.requestsQueue.length >= maxQueueSize) { return; } const intrinsicData = getEventIntrinsicData(); - analyticsSharedState.requestsQueue.push({ + const preview = Object.fromEntries( + Object.entries(experimentsContext?.preview ?? {}).filter(([, value]) => typeof value === "boolean"), + ); + const properties = { ...params.properties }; + delete properties.__b44_experiment_preview; + if (Object.keys(preview).length) { + // Capture now: a queued event must retain its occurrence-time preview. + properties.__b44_experiment_preview = JSON.stringify(preview); + } + state.requestsQueue.push({ ...params, ...intrinsicData, + properties: params.properties || Object.keys(properties).length ? properties : undefined, }); startProcessing(); }; @@ -155,18 +180,18 @@ export const createAnalyticsModule = ({ startAnalyticsProcessor(flush, { throttleTime, batchSize, - }); - clearHeartBeatProcessor = startHeartBeatProcessor(track); - setSessionDurationTimerStart(); + }, state); + clearHeartBeatProcessor = startHeartBeatProcessor(track, state); + setSessionDurationTimerStart(state); }; const onDocHidden = () => { - stopAnalyticsProcessor(); + stopAnalyticsProcessor(state); clearHeartBeatProcessor?.(); - trackSessionDurationEvent(track); + trackSessionDurationEvent(track, state); // flush entire queue on visibility change and hope for the best // - const eventsData = analyticsSharedState.requestsQueue.splice(0); + const eventsData = state.requestsQueue.splice(0); flush(eventsData, { isBeacon: true }); }; @@ -180,7 +205,7 @@ export const createAnalyticsModule = ({ }; const cleanup = () => { - stopAnalyticsProcessor(); + stopAnalyticsProcessor(state); clearHeartBeatProcessor?.(); if (typeof window !== "undefined") { window.removeEventListener("visibilitychange", onVisibilityChange); @@ -190,9 +215,9 @@ export const createAnalyticsModule = ({ // start the flusing process /// startProcessing(); // start the heart beat processor // - clearHeartBeatProcessor = startHeartBeatProcessor(track); + clearHeartBeatProcessor = startHeartBeatProcessor(track, state); // track the referrer event // - trackInitializationEvent(track); + trackInitializationEvent(track, state); // start the visibility change listener // if (typeof window !== "undefined") { window.addEventListener("visibilitychange", onVisibilityChange); @@ -204,68 +229,69 @@ export const createAnalyticsModule = ({ }; }; -function stopAnalyticsProcessor() { - analyticsSharedState.isProcessing = false; +function stopAnalyticsProcessor(state: AnalyticsState) { + state.isProcessing = false; } async function startAnalyticsProcessor( handleTrack: (eventsData: TrackEventData[]) => Promise, - options?: { + options: { throttleTime: number; batchSize: number; - } + }, + state: AnalyticsState, ) { - if (analyticsSharedState.isProcessing) { + if (state.isProcessing) { // only one instance of the analytics processor can be running at a time // return; } - analyticsSharedState.isProcessing = true; + state.isProcessing = true; const { throttleTime = 1000, batchSize = 30 } = options ?? {}; while ( - analyticsSharedState.isProcessing && - analyticsSharedState.requestsQueue.length > 0 + state.isProcessing && + state.requestsQueue.length > 0 ) { - const requests = analyticsSharedState.requestsQueue.splice(0, batchSize); + const requests = state.requestsQueue.splice(0, batchSize); requests.length && (await handleTrack(requests)); await new Promise((resolve) => setTimeout(resolve, throttleTime)); } - analyticsSharedState.isProcessing = false; + state.isProcessing = false; } -function startHeartBeatProcessor(track: (params: TrackEventParams) => void) { +function startHeartBeatProcessor(track: (params: TrackEventParams) => void, state: AnalyticsState) { // Browser-only, like the other automatic events here (initialization, session // duration, visibility). Outside a browser this timer fired a `me()` every // interval for the lifetime of a long-lived server-side client, and kept the // Node event loop alive. Explicit `analytics.track()` calls still work. if ( typeof window === "undefined" || - analyticsSharedState.isHeartBeatProcessing || - (analyticsSharedState.config.heartBeatInterval ?? 0) < 10 + state.isHeartBeatProcessing || + (state.config.heartBeatInterval ?? 0) < 10 ) { return () => {}; } - analyticsSharedState.isHeartBeatProcessing = true; + state.isHeartBeatProcessing = true; const interval = setInterval(() => { track({ eventName: USER_HEARTBEAT_EVENT_NAME }); - }, analyticsSharedState.config.heartBeatInterval); + }, state.config.heartBeatInterval); return () => { clearInterval(interval); - analyticsSharedState.isHeartBeatProcessing = false; + state.isHeartBeatProcessing = false; }; } -function trackInitializationEvent(track: (params: TrackEventParams) => void) { +function trackInitializationEvent(track: (params: TrackEventParams) => void, state: AnalyticsState) { if ( typeof window === "undefined" || - analyticsSharedState.wasInitializationTracked + state.wasInitializationTracked ) { return; } - analyticsSharedState.wasInitializationTracked = true; + state.wasInitializationTracked = true; track({ eventName: ANALYTICS_INITIALIZATION_EVENT_NAME, properties: { @@ -274,25 +300,25 @@ function trackInitializationEvent(track: (params: TrackEventParams) => void) { }); } -function setSessionDurationTimerStart() { +function setSessionDurationTimerStart(state: AnalyticsState) { if ( typeof window === "undefined" || - analyticsSharedState.sessionStartTime !== null + state.sessionStartTime !== null ) { return; } - analyticsSharedState.sessionStartTime = new Date().toISOString(); + state.sessionStartTime = new Date().toISOString(); } -function trackSessionDurationEvent(track: (params: TrackEventParams) => void) { +function trackSessionDurationEvent(track: (params: TrackEventParams) => void, state: AnalyticsState) { if ( typeof window === "undefined" || - analyticsSharedState.sessionStartTime === null + state.sessionStartTime === null ) return; const sessionDuration = new Date().getTime() - - new Date(analyticsSharedState.sessionStartTime).getTime(); - analyticsSharedState.sessionStartTime = null; + new Date(state.sessionStartTime).getTime(); + state.sessionStartTime = null; track({ eventName: ANALYTICS_SESSION_DURATION_EVENT_NAME, properties: { sessionDuration }, @@ -318,8 +344,6 @@ function transformEventDataToApiRequestData(sessionContext: SessionContext) { }); } -let sessionContextPromise: Promise | null = null; - /** * Clears the memoized analytics session context. * @@ -330,26 +354,28 @@ let sessionContextPromise: Promise | null = null; * * @internal */ -export function resetAnalyticsSessionContext() { - analyticsSharedState.sessionContext = null; - sessionContextPromise = null; +export function resetAnalyticsSessionContext(axiosClient?: AxiosInstance) { + const state = axiosClient ? serverAnalyticsStates.get(axiosClient) ?? analyticsSharedState : analyticsSharedState; + state.sessionContext = null; + state.sessionContextPromise = null; } async function getSessionContext( - userAuthModule: InternalAuthModule + userAuthModule: InternalAuthModule, + state: AnalyticsState, ): Promise { - if (!analyticsSharedState.sessionContext) { + if (!state.sessionContext) { // With no token there is no identity to resolve: `me()` can only answer 401, // which the browser logs to the console before any handler here sees it. On // a public page that request is the sole reason an error appears, so skip // it. This is not memoized — a visitor who logs in later must still resolve. if (!userAuthModule.hasToken()) { - return { user_id: null, session_id: getAnalyticsSessionId() }; + return { user_id: null, session_id: getAnalyticsSessionId(state) }; } - if (!sessionContextPromise) { - const sessionId = getAnalyticsSessionId(); - sessionContextPromise = userAuthModule + if (!state.sessionContextPromise) { + const sessionId = getAnalyticsSessionId(state); + state.sessionContextPromise = userAuthModule .me() .then((user) => ({ user_id: user.id, @@ -360,7 +386,7 @@ async function getSessionContext( session_id: sessionId, })); } - const pending = sessionContextPromise; + const pending = state.sessionContextPromise; const context = await pending; // Publish only if this lookup is still the current one. A reset that lands // while the request is in flight nulls `sessionContextPromise`, and an @@ -368,12 +394,12 @@ async function getSessionContext( // for the rest of the session. The awaited value is still returned: these // events were queued before the identity changed, so that is who they // belong to. - if (sessionContextPromise === pending) { - analyticsSharedState.sessionContext = context; + if (state.sessionContextPromise === pending) { + state.sessionContext = context; } return context; } - return analyticsSharedState.sessionContext; + return state.sessionContext; } export function getAnalyticsConfigFromUrlParams(): @@ -401,15 +427,16 @@ export function getAnalyticsConfigFromUrlParams(): return { enabled: analyticsEnable === "true" }; } -// When the id can't be persisted (React Native has no `localStorage`), keep -// it stable for the process instead of minting a fresh one per call. -function getFallbackSessionId(): string { - return (analyticsSharedState.fallbackSessionId ??= generateUuid()); +// Without persistent storage, keep the id stable within this analytics state. +function getFallbackSessionId(state: AnalyticsState): string { + return (state.fallbackSessionId ??= generateUuid()); } -export function getAnalyticsSessionId(): string { +export function getAnalyticsSessionId(state = analyticsSharedState): string { + const visitorId = getExperimentsRuntime()?.visitorId; + if (visitorId && visitorId !== "anon") return visitorId; if (typeof window === "undefined") { - return getFallbackSessionId(); + return getFallbackSessionId(state); } try { const sessionId = localStorage.getItem( @@ -425,6 +452,6 @@ export function getAnalyticsSessionId(): string { } return sessionId; } catch { - return getFallbackSessionId(); + return getFallbackSessionId(state); } } diff --git a/src/modules/auth.ts b/src/modules/auth.ts index b9e23747..3007c0a7 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -1,6 +1,7 @@ import { AxiosInstance } from "axios"; import { AuthModuleOptions, + AuthState, InternalAuthModule, User, VerifyOtpParams, @@ -104,7 +105,9 @@ export function createAuthModule( // requests would leave the app rendering a stale identity after logout or a // session swap. let pendingMe: Promise | null = null; + let identityGeneration = 0; const clearPendingMe = () => { + identityGeneration += 1; pendingMe = null; }; @@ -112,6 +115,13 @@ export function createAuthModule( // to the identity transitions below (`setToken`, `logout`) instead of to the // header a caller may have set on the instance directly. let hasAccessToken = Boolean(options.token); + const notifyAuthState = (state: AuthState) => { + try { + options.onAuthStateChange?.(state); + } catch { + // Optional observers must not interrupt authentication or logout redirects. + } + }; return { hasToken() { @@ -120,9 +130,27 @@ export function createAuthModule( // Get current user information async me() { + const generation = identityGeneration; const request: Promise = pendingMe ?? - axios.get(`/apps/${appId}/entities/User/me`).finally(() => { + axios.get(`/apps/${appId}/entities/User/me`).then( + (user) => { + if (generation === identityGeneration) { + notifyAuthState({ status: "authenticated", userId: user.id }); + } + return user; + }, + (error: unknown) => { + if (generation === identityGeneration) { + const authError = error as { status?: number; response?: { status?: number } }; + const status = authError?.status ?? authError?.response?.status; + notifyAuthState({ + status: status === 401 || status === 403 ? "anonymous" : "error", + }); + } + throw error; + } + ).finally(() => { // Only retire this request if it is still the shared one. An identity // change mid-flight clears `pendingMe` and the next caller starts a // fresh request; an unconditional clear here would retire that newer @@ -197,8 +225,9 @@ export function createAuthModule( // Drop identity resolved under the previous session: a `me()` already in // flight would otherwise resolve into callers that run after the logout. clearPendingMe(); - resetAnalyticsSessionContext(); + resetAnalyticsSessionContext(axios); hasAccessToken = false; + notifyAuthState({ status: "anonymous" }); // Only do the rest if in a browser environment if (typeof window !== "undefined") { @@ -229,7 +258,7 @@ export function createAuthModule( // Same reasoning as in `logout`: the identity changes here, so anything // resolved for the previous one must not be handed to later callers. clearPendingMe(); - resetAnalyticsSessionContext(); + resetAnalyticsSessionContext(axios); hasAccessToken = true; // handle token change for axios clients @@ -237,6 +266,7 @@ export function createAuthModule( functionsAxiosClient.defaults.headers.common[ "Authorization" ] = `Bearer ${token}`; + notifyAuthState({ status: "pending" }); // Save token to localStorage if requested if ( @@ -274,6 +304,7 @@ export function createAuthModule( if (access_token) { this.setToken(access_token); + if (typeof user?.id === "string") notifyAuthState({ status: "authenticated", userId: user.id }); } return { diff --git a/src/modules/auth.types.ts b/src/modules/auth.types.ts index 7c080efe..852f99eb 100644 --- a/src/modules/auth.types.ts +++ b/src/modules/auth.types.ts @@ -92,6 +92,13 @@ export interface ResetPasswordParams { newPassword: string; } +/** @internal */ +export type AuthState = + | { status: "pending" } + | { status: "anonymous" } + | { status: "authenticated"; userId: string } + | { status: "error" }; + /** * Configuration options for the auth module. */ @@ -106,6 +113,8 @@ export interface AuthModuleOptions { * which is how the server-side SDK reports a token it never sets explicitly. */ token?: string; + /** @internal */ + onAuthStateChange?: (state: AuthState) => void; } /** diff --git a/src/modules/experiment-exposures.ts b/src/modules/experiment-exposures.ts new file mode 100644 index 00000000..dfe73efb --- /dev/null +++ b/src/modules/experiment-exposures.ts @@ -0,0 +1,80 @@ +import type { AxiosInstance } from "axios"; +import { v4 as uuid } from "uuid"; +import { isAnalyticsEnabled } from "./analytics.js"; + +/** @internal */ +export function createExposureTracker({ + axiosClient, appId, enabled, source = "browser", pageUrl, +}: { + axiosClient: AxiosInstance; + appId: string; + enabled: boolean; + source?: "browser" | "backend"; + pageUrl?: string; +}) { + type Entry = { + data: { events: Record[] }; + authorization: string | null; + acknowledged: boolean; + pending?: Promise; + }; + const entries = new Map(); + + function send(entry: Entry): Promise { + if (entry.pending) return entry.pending; + const pending = (async () => { + for (let attempt = 0; ; attempt++) { + try { + const response = await axiosClient.request({ + method: "POST", + url: `/apps/${appId}/analytics/track/batch`, + headers: { Authorization: entry.authorization }, + data: entry.data, + }); + if (response.accepted !== 1) throw new Error("Experiment exposure was not accepted"); + entry.acknowledged = true; + return; + } catch (error) { + if (attempt === 2) throw error; + await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 100 : 500)); + } + } + })().finally(() => { entry.pending = undefined; }); + entry.pending = pending; + // Reads stay synchronous; flush() lets request handlers observe delivery failures. + void pending.catch(() => {}); + return pending; + } + + return { + track( + assignment: { experiment_id: string; run_version: number; variant_key: string }, + identity: { visitorId: string; userId: string | null }, + ): void { + if ((source === "browser" && typeof window === "undefined") || !isAnalyticsEnabled(enabled)) return; + const { experiment_id, run_version, variant_key } = assignment; + const key = JSON.stringify([experiment_id, run_version, variant_key, identity.userId, identity.visitorId]); + let entry = entries.get(key); + if (!entry) { + const authorization = identity.userId ? axiosClient.defaults.headers.common.Authorization : null; + entry = { + acknowledged: false, + authorization: typeof authorization === "string" ? authorization : null, + data: { events: [{ + event_id: uuid(), + event_name: "__experiment_exposure__", + timestamp: new Date().toISOString(), + session_id: identity.visitorId, + page_url: pageUrl ?? (typeof window === "undefined" ? "/" : window.location.pathname), + properties: { experiment_id, run_version, variant_key, source }, + }] }, + }; + entries.set(key, entry); + } + if (!entry.acknowledged) void send(entry); + }, + async flush(): Promise { + await Promise.all([...entries.values()].filter((entry) => !entry.acknowledged).map(send)); + }, + }; +} diff --git a/src/modules/experiments-config.types.ts b/src/modules/experiments-config.types.ts new file mode 100644 index 00000000..fc8e8499 --- /dev/null +++ b/src/modules/experiments-config.types.ts @@ -0,0 +1,35 @@ +import type { ExperimentsSnapshot } from "./experiments.types.js"; + +/** Shared versioned configuration published by the platform, never visitor-specific. */ +export interface ExperimentsConfig { + v: 1; + app_id: string; + revision?: number; + flags: { key: string; rollout_percentage: number }[]; + experiments: { + id: string; + flag_key: string; + run_version: number; + assign_by: "visitor" | "user"; + traffic_allocation: number; + variants: { key: string; value: boolean; weight: number }[]; + }[]; +} + +/** Identity supplied by the platform's normal authenticated request/bootstrap path. */ +export interface ExperimentsIdentity { + visitorId: string; + userId: string | null; + status?: "authenticated" | "anonymous" | "pending"; +} + +/** One request's or browser page's context. Never share it between server requests. */ +export interface ExperimentsContext { + config: ExperimentsConfig; + identity: ExperimentsIdentity; + preview?: Readonly>; + /** Request pathname used for server-side exposure events. */ + pageUrl?: string; + /** Exact server-rendered flags, retained for the browser's first hydration render. */ + serverSnapshot?: ExperimentsSnapshot; +} diff --git a/src/modules/experiments-context.ts b/src/modules/experiments-context.ts new file mode 100644 index 00000000..623ea092 --- /dev/null +++ b/src/modules/experiments-context.ts @@ -0,0 +1,49 @@ +import type { ExperimentsContext } from "./experiments-config.types.js"; +import { evaluateExperiments } from "./experiments-evaluator.js"; +import type { ExperimentsRuntime } from "./experiments-runtime.types.js"; + +/** @internal Platform ingress overwrites this header; it is not authentication. */ +export const EXPERIMENTS_CONTEXT_HEADER = "Base44-Experiments-Context"; + +/** @internal */ +export function readExperimentsContext(encoded: string | null, appId: string): ExperimentsContext | undefined { + if (!encoded || encoded.length > 96 * 1024) return; + try { + const bytes = Uint8Array.from(atob(encoded.replace(/-/g, "+").replace(/_/g, "/")), (character) => character.charCodeAt(0)); + return matchingContext(JSON.parse(new TextDecoder().decode(bytes)), appId); + } catch { + return; + } +} + +function matchingContext(value: ExperimentsContext | undefined, appId: string): ExperimentsContext | undefined { + return value?.config?.v === 1 && value.config.app_id === appId && + typeof value.identity?.visitorId === "string" && value.identity.visitorId && + (value.identity.userId === null || typeof value.identity.userId === "string") + ? value : undefined; +} + +/** @internal */ +export function getBrowserExperimentsContext(appId: string): ExperimentsContext | undefined { + if (typeof window === "undefined" || typeof document === "undefined") return; + return matchingContext((window as Window & { + __B44_EXPERIMENTS_BOOTSTRAP__?: ExperimentsContext; + }).__B44_EXPERIMENTS_BOOTSTRAP__, appId); +} + +/** One independent evaluator instance for one client/request. @internal */ +export function createExperimentsRuntime(context: ExperimentsContext): ExperimentsRuntime { + const identity = { ...context.identity }; + const evaluate = () => evaluateExperiments(context.config, identity, context.preview); + const runtime: ExperimentsRuntime = { + ...evaluate(), + visitorId: identity.visitorId, + userId: identity.userId, + pendingUser: identity.status === "pending", + setUser(userId) { + identity.userId = userId; + Object.assign(runtime, evaluate(), { userId, pendingUser: false }); + }, + }; + return runtime; +} diff --git a/src/modules/experiments-evaluator.ts b/src/modules/experiments-evaluator.ts new file mode 100644 index 00000000..f7a273b4 --- /dev/null +++ b/src/modules/experiments-evaluator.ts @@ -0,0 +1,53 @@ +import type { ExperimentAssignment } from "./experiments-runtime.types.js"; +import type { ExperimentsConfig, ExperimentsIdentity } from "./experiments-config.types.js"; + +function bucket(parts: (string | number)[]): number { + let hash = 0x811c9dc5; + for (const byte of new TextEncoder().encode(parts.join(":"))) { + hash = Math.imul(hash ^ byte, 0x01000193) >>> 0; + } + return hash % 100; +} + +/** + * Evaluates flags locally without storage, network, clock, or browser globals. + * The same config and identity always produce the same assignments. + * This controls presentation, never authorization or access to data. + */ +export function evaluateExperiments( + config: ExperimentsConfig, + identity: ExperimentsIdentity, + preview: Readonly> = {}, +): { flags: Record; assignments: ExperimentAssignment[] } { + const flags: Record = Object.fromEntries( + config.flags.map((flag) => [ + flag.key, + bucket(["rollout", config.app_id, flag.key, identity.visitorId]) < flag.rollout_percentage, + ]), + ); + const assignments: ExperimentAssignment[] = []; + for (const experiment of config.experiments) { + if (Object.prototype.hasOwnProperty.call(preview, experiment.flag_key)) continue; + const key = experiment.assign_by === "user" ? identity.userId : identity.visitorId; + if (!key || bucket(["enroll", config.app_id, experiment.id, experiment.run_version, key]) >= experiment.traffic_allocation) continue; + const value = bucket(["variant", config.app_id, experiment.id, experiment.run_version, key]); + let total = 0; + let variant = experiment.variants[experiment.variants.length - 1]; + for (const candidate of experiment.variants) { + total += candidate.weight; + if (value < total) { + variant = candidate; + break; + } + } + flags[experiment.flag_key] = variant.value; + assignments.push({ + experiment_id: experiment.id, + flag_key: experiment.flag_key, + run_version: experiment.run_version, + variant_key: variant.key, + preview: false, + }); + } + return { flags: { ...flags, ...preview }, assignments }; +} diff --git a/src/modules/experiments-runtime.types.ts b/src/modules/experiments-runtime.types.ts new file mode 100644 index 00000000..5ea035da --- /dev/null +++ b/src/modules/experiments-runtime.types.ts @@ -0,0 +1,25 @@ +/** @internal */ +export interface ExperimentAssignment { + experiment_id: string; + flag_key: string; + run_version: number; + variant_key: string; + preview: boolean; +} + +/** @internal */ +export interface ExperimentsRuntime { + flags: Record; + assignments: ExperimentAssignment[]; + visitorId: string; + userId: string | null; + pendingUser: boolean; + setUser(id: string | null): void; +} + +/** @internal */ +export function getExperimentsRuntime(): ExperimentsRuntime | undefined { + if (typeof window === "undefined" || typeof document === "undefined") return; + return (window as Window & { __B44_EXPERIMENTS__?: ExperimentsRuntime }) + .__B44_EXPERIMENTS__; +} diff --git a/src/modules/experiments.ts b/src/modules/experiments.ts new file mode 100644 index 00000000..53576ca0 --- /dev/null +++ b/src/modules/experiments.ts @@ -0,0 +1,162 @@ +import type { AuthState, InternalAuthModule } from "./auth.types.js"; +import type { + ExperimentsModule, + ExperimentsSnapshot, +} from "./experiments.types.js"; +import { + getExperimentsRuntime, + type ExperimentsRuntime, +} from "./experiments-runtime.types.js"; +import type { createExposureTracker } from "./experiment-exposures.js"; +import type { ExperimentsContext } from "./experiments-config.types.js"; +import { createExperimentsRuntime } from "./experiments-context.js"; + +const EMPTY: ExperimentsSnapshot = Object.freeze({ + flags: Object.freeze({}), + isLoading: false, +}); + +/** @internal */ +export function createExperimentsModule({ + getAuth, + trackExposure, + flushExposures = async () => {}, + context, +}: { + getAuth: () => InternalAuthModule; + trackExposure: ReturnType["track"]; + flushExposures?: () => Promise; + context?: ExperimentsContext; +}) { + let runtime: ExperimentsRuntime | undefined = context ? createExperimentsRuntime(context) : undefined; + let state: AuthState | undefined = context + ? context.identity.status === "pending" ? { status: "pending" } + : context.identity.userId ? { status: "authenticated", userId: context.identity.userId } + : { status: "anonymous" } + : undefined; + let snapshot = EMPTY; + let active = false; + let disposed = false; + const listeners = new Set<() => void>(); + const readyWaiters = new Set<(value: ExperimentsSnapshot) => void>(); + const initial = context?.serverSnapshot ?? (context ? { + flags: context.identity.status === "pending" ? {} : runtime!.flags, + isLoading: context.identity.status === "pending", + } : EMPTY); + const serverSnapshot: ExperimentsSnapshot = Object.freeze({ ...initial, flags: Object.freeze({ ...initial.flags }) }); + + function settleReady() { + if (snapshot.isLoading) return; + for (const resolve of readyWaiters) resolve(snapshot); + readyWaiters.clear(); + } + + function publish() { + const isLoading = !!runtime && state?.status === "pending"; + const flags = + runtime && + (state?.status === "authenticated" || state?.status === "anonymous") + ? runtime.flags + : EMPTY.flags; + if ( + snapshot.isLoading === isLoading && + Object.keys(snapshot.flags).length === Object.keys(flags).length && + Object.keys(flags).every( + (key) => + Object.prototype.hasOwnProperty.call(snapshot.flags, key) && + snapshot.flags[key] === flags[key], + ) + ) { + settleReady(); + return; + } + snapshot = Object.freeze({ flags: Object.freeze({ ...flags }), isLoading }); + settleReady(); + for (const listener of listeners) { + try { + listener(); + } catch { + /* Observers must not interrupt authentication. */ + } + } + } + + function applyIdentity() { + if (runtime) { + const userId = state?.status === "authenticated" ? state.userId : null; + if (runtime.userId !== userId || runtime.pendingUser) + runtime.setUser(userId); + } + publish(); + } + + function activate() { + if (disposed) return; + active = true; + if (!context) runtime = getExperimentsRuntime(); + if (!runtime) { + publish(); + return; + } + if (!state) + state = getAuth().hasToken() + ? { status: "pending" } + : { status: "anonymous" }; + applyIdentity(); + } + + function onAuthStateChange(next: AuthState) { + if (disposed) return; + state = next; + if (!active) return; + if (!context) runtime = getExperimentsRuntime(); + applyIdentity(); + } + + const module: ExperimentsModule = { + isEnabled(flagKey, fallback = false) { + activate(); + if (!Object.prototype.hasOwnProperty.call(snapshot.flags, flagKey)) + return fallback; + const assignment = runtime?.assignments.find( + (item) => item.flag_key === flagKey && !item.preview, + ); + if (runtime && assignment) trackExposure(assignment, runtime); + return snapshot.flags[flagKey]; + }, + getSnapshot() { + activate(); + return snapshot; + }, + getServerSnapshot: () => serverSnapshot, + subscribe(listener) { + activate(); + if (!disposed) listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + async ready() { + activate(); + if (snapshot.isLoading) + return new Promise((resolve) => + readyWaiters.add(resolve), + ); + return snapshot; + }, + flush: flushExposures, + }; + + return { + module, + onAuthStateChange, + visitorId: () => runtime?.visitorId, + cleanup() { + disposed = true; + runtime = undefined; + snapshot = EMPTY; + settleReady(); + listeners.clear(); + }, + }; +} diff --git a/src/modules/experiments.types.ts b/src/modules/experiments.types.ts new file mode 100644 index 00000000..c2456aa4 --- /dev/null +++ b/src/modules/experiments.types.ts @@ -0,0 +1,112 @@ +/** A stable, read-only view of the browser's current feature flags. */ +export interface ExperimentsSnapshot { + /** Resolved flags. Empty when the runtime is absent or identity is unresolved. */ + readonly flags: Readonly>; + /** Whether the SDK is resolving the signed-in user's identity. */ + readonly isLoading: boolean; +} + +/** + * Evaluates feature flags locally from platform-provided configuration and identity. + * + * - Reads flags and reports experiment exposures when a flag is used. + * - Synchronizes assignments with this client's SDK login, token changes, and logout. + * - Provides readiness and subscriptions without requiring React. + * + * Available as `base44.experiments` for anonymous and signed-in app visitors, + * not in service role mode. Use one client for the app whose runtime is on the page. + * Browsers read the platform bootstrap. Servers and Workers use the request-scoped + * context passed by createClientFromRequest(), or explicit createClient options. + * Missing context returns fallbacks. For authenticated first render, the platform's + * common auth bootstrap must supply a resolved identity before mounting the app. + * Goal conversions use the existing {@link AnalyticsModule | analytics module}. + * Visitor-keyed conversions share the injected runtime's visitor ID. When browser + * storage is blocked, the platform must supply a unique per-page ID; attribution + * then lasts for that page only, not across reloads or tabs. + */ +export interface ExperimentsModule { + /** + * Reads a flag and queues an acknowledged exposure for its current assignment. + * + * Never starts an authentication request. Reads return the fallback while the + * app's normal auth initialization is pending or failed. Supply trusted bootstrap + * identity or let the app's existing auth.me()/login flow resolve it. + * + * Call only where the feature is used: a read counts as exposure, not proof of + * visibility. Preview overrides and flags without an assignment are not tracked. + * Exposures respect the client's analytics setting, are deduplicated per client, + * experiment run, variant and identity. Failed sends retry up to three attempts + * with the same event ID, timestamp and credentials. Await flush() on servers. + * + * @param flagKey - Feature flag key defined in your app. + * @param fallback - Value for an unavailable flag or unresolved identity. Defaults to `false`. + * @returns The evaluated boolean, or the fallback when unavailable. + * @example + * ```typescript + * await base44.experiments.ready(); + * const showNewCheckout = base44.experiments.isEnabled('new_checkout'); + * ``` + */ + isEnabled(flagKey: string, fallback?: boolean): boolean; + + /** + * Returns the current flags and identity-loading state without tracking exposures. + * + * Observes identity resolution without starting it. The returned object retains its + * reference until its values change, for use with external-store subscriptions. + * Use {@link ExperimentsModule.isEnabled | isEnabled()} at the feature boundary + * to record exposure rather than displaying a variant directly from this snapshot. + * + * @returns A stable, read-only snapshot. + * @example + * ```typescript + * const { isLoading } = base44.experiments.getSnapshot(); + * ``` + */ + getSnapshot(): ExperimentsSnapshot; + + /** Immutable initial platform snapshot for matching server render and hydration. */ + getServerSnapshot(): ExperimentsSnapshot; + + /** + * Listens for flag or loading-state changes caused by this client's SDK auth flows. + * + * Does not poll for platform configuration changes or observe token writes outside + * the SDK. {@link Base44Client.cleanup | cleanup()} removes all listeners. + * + * @param listener - Callback invoked when the snapshot changes. + * @returns A function that removes the listener. + * @example + * ```typescript + * const unsubscribe = base44.experiments.subscribe(() => { + * renderCheckout(base44.experiments.isEnabled('new_checkout')); + * }); + * unsubscribe(); + * ``` + */ + subscribe(listener: () => void): () => void; + + /** + * Waits for the app's common auth initialization, including a token change. + * + * Resolves with empty flags after an identity lookup failure. Retrying authentication + * belongs to the normal auth flow. Missing runtimes resolve immediately. This does not wait for a future + * runtime injection or for exposure delivery, and never records an exposure itself. + * + * @returns A snapshot after the current identity lookup settles. + * @example + * ```typescript + * await base44.experiments.ready(); + * renderCheckout(base44.experiments.isEnabled('new_checkout')); + * ``` + */ + ready(): Promise; + + /** + * Waits until queued exposures are acknowledged; rejects after bounded retries. + * Server/Worker handlers must await this before ending the request (or use waitUntil). + * Retries preserve event IDs but raw storage is not exactly-once. Calling again + * retries unacknowledged events with the same IDs. No new exposures are created. + */ + flush(): Promise; +} diff --git a/src/utils/fetch-with-auth.ts b/src/utils/fetch-with-auth.ts index 9b954982..5403b37d 100644 --- a/src/utils/fetch-with-auth.ts +++ b/src/utils/fetch-with-auth.ts @@ -58,6 +58,7 @@ export function createFetchWithAuth({ ? header : null; }; + const contextAuthorization = bearer(axios); return async function fetchWithAuth( path: string, @@ -84,6 +85,11 @@ export function createFetchWithAuth({ inherit("Base44-Functions-Version", functionsVersion); inherit("Base44-State", inherited.get("Base44-State")); inherit("X-Data-Env", inherited.get("X-Data-Env")); + inherit("Base44-Visitor-Id", inherited.get("Base44-Visitor-Id")); + inherit("Base44-Experiment-Preview", inherited.get("Base44-Experiment-Preview")); + if (headers.get("Authorization") === contextAuthorization) { + inherit("Base44-Experiments-Context", inherited.get("Base44-Experiments-Context")); + } // The path is passed through untouched: resolving it here would need a // document, and a root-relative path is already what a runtime that diff --git a/tests/types/experiments.types.ts b/tests/types/experiments.types.ts new file mode 100644 index 00000000..9d4be99f --- /dev/null +++ b/tests/types/experiments.types.ts @@ -0,0 +1,19 @@ +import type { Base44Client, ExperimentsModule, ExperimentsSnapshot } from "../../src/index.js"; + +declare const client: Base44Client; +const experiments: ExperimentsModule = client.experiments; +const enabled: boolean = experiments.isEnabled("checkout", false); +const snapshot: ExperimentsSnapshot = experiments.getSnapshot(); +const ready: Promise = experiments.ready(); +const unsubscribe: () => void = experiments.subscribe(() => {}); +const serverSnapshot: ExperimentsSnapshot = experiments.getServerSnapshot(); +const delivered: Promise = experiments.flush(); +// @ts-expect-error Fallbacks are boolean, not variant names. +experiments.isEnabled("checkout", "control"); +// @ts-expect-error Snapshots cannot override platform evaluations. +snapshot.flags.checkout = true; +// @ts-expect-error Identity is managed by auth, not a public caller-supplied user ID. +experiments.setUser("user-1"); +// @ts-expect-error Browser experiments are unavailable to service-role clients. +client.asServiceRole.experiments; +void [enabled, snapshot, ready, unsubscribe, serverSnapshot, delivered]; diff --git a/tests/unit/analytics-server.test.ts b/tests/unit/analytics-server.test.ts new file mode 100644 index 00000000..c063dff6 --- /dev/null +++ b/tests/unit/analytics-server.test.ts @@ -0,0 +1,109 @@ +import axios from "axios"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createClient, createClientFromRequest } from "../../src/client.js"; + +vi.mock("partysocket", () => ({ WebSocket: class {} })); + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function captureRequests() { + const create = axios.create.bind(axios); + const adapter = vi.fn(async (config) => ({ + data: config.url.endsWith("/entities/User/me") + ? { id: config.headers.get("Authorization").replace("Bearer token-", "user-") } + : { accepted: 1 }, + status: 200, statusText: "OK", headers: {}, config, + })); + vi.spyOn(axios, "create").mockImplementation((config) => { + const api = create(config); + api.defaults.adapter = adapter; + return api; + }); + const events = () => adapter.mock.calls.map(([config]) => config) + .filter((config) => config.url.endsWith("/analytics/track/batch")) + .flatMap((config) => JSON.parse(config.data).events.map((event: Record) => ({ + url: config.url, authorization: config.headers.get("Authorization"), ...event, + }))); + return { adapter, events }; +} + +function requestClient(appId: string, suffix: string) { + const context = { config: { v: 1, app_id: appId, flags: [], experiments: [] }, + identity: { visitorId: `visitor-${suffix}`, userId: `user-${suffix}`, status: "authenticated" }, preview: {} }; + return createClientFromRequest(new Request("https://app.example/checkout", { headers: { + "Base44-App-Id": appId, "Authorization": `Bearer token-${suffix}`, + "Base44-Experiments-Context": Buffer.from(JSON.stringify(context)).toString("base64url"), + } })); +} + +describe("server analytics request isolation", () => { + test.each(["app-a", "app-b"])("concurrent Worker goals keep each request's app and identity (%s)", async (secondApp) => { + const { events } = captureRequests(); + const a = requestClient("app-a", "a"); + const b = requestClient(secondApp, "b"); + a.analytics.track({ eventName: "goal_a" }); + b.analytics.track({ eventName: "goal_b" }); + await vi.advanceTimersByTimeAsync(1000); + expect(events()).toEqual(expect.arrayContaining([ + expect.objectContaining({ event_name: "goal_a", url: "/apps/app-a/analytics/track/batch", + authorization: "Bearer token-a", user_id: "user-a", session_id: "visitor-a" }), + expect.objectContaining({ event_name: "goal_b", url: `/apps/${secondApp}/analytics/track/batch`, + authorization: "Bearer token-b", user_id: "user-b", session_id: "visitor-b" }), + ])); + expect(events()).toHaveLength(2); + a.cleanup(); b.cleanup(); + }); + + test("cleaning up one request cannot stop another request's queued goal", async () => { + const { events } = captureRequests(); + const a = requestClient("app", "a"); + const b = requestClient("app", "b"); + a.analytics.track({ eventName: "warmup_a" }); + b.analytics.track({ eventName: "warmup_b" }); + await vi.advanceTimersByTimeAsync(0); + b.analytics.track({ eventName: "queued_b" }); + a.cleanup(); + await vi.advanceTimersByTimeAsync(1000); + expect(events().find((event) => event.event_name === "queued_b")).toMatchObject({ + user_id: "user-b", session_id: "visitor-b", authorization: "Bearer token-b", + }); + b.cleanup(); + }); + + test("changing one client's token resets only its own analytics identity", async () => { + const { adapter, events } = captureRequests(); + const a = requestClient("app", "a"); + const b = requestClient("app", "b"); + a.analytics.track({ eventName: "before_a" }); + b.analytics.track({ eventName: "before_b" }); + await vi.advanceTimersByTimeAsync(1000); + a.setToken("token-c"); + a.analytics.track({ eventName: "after_a" }); + b.analytics.track({ eventName: "after_b" }); + await vi.advanceTimersByTimeAsync(1000); + expect(events().find((event) => event.event_name === "after_a")).toMatchObject({ user_id: "user-c" }); + expect(events().find((event) => event.event_name === "after_b")).toMatchObject({ user_id: "user-b" }); + const meRequests = adapter.mock.calls.filter(([config]) => config.url.endsWith("/entities/User/me")); + expect(meRequests).toHaveLength(3); + a.cleanup(); b.cleanup(); + }); + + test("anonymous server clients have independent but stable fallback visitor IDs", async () => { + const { events } = captureRequests(); + const a = createClient({ appId: "app" }); + const b = createClient({ appId: "app" }); + a.analytics.track({ eventName: "first_a" }); + a.analytics.track({ eventName: "second_a" }); + b.analytics.track({ eventName: "first_b" }); + await vi.advanceTimersByTimeAsync(1000); + const visitor = (name: string) => events().find((event) => event.event_name === name).session_id; + expect(visitor("first_a")).toBeTruthy(); + expect(visitor("second_a")).toBe(visitor("first_a")); + expect(visitor("first_b")).not.toBe(visitor("first_a")); + a.cleanup(); b.cleanup(); + }); +}); diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 48e61f60..26db3566 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -22,6 +22,13 @@ describe("Analytics Module", () => { const serverUrl = "https://api.base44.com"; beforeEach(() => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn() }; + vi.stubGlobal("localStorage", storage); + vi.stubGlobal("document", { referrer: "", visibilityState: "visible" }); + vi.stubGlobal("window", { + location: { origin: "https://example.com", pathname: "/", search: "" }, + localStorage: storage, addEventListener: vi.fn(), removeEventListener: vi.fn(), + }); vi.mock("../../src/utils/axios-client.ts", () => ({ createAxiosClient: vi.fn().mockImplementation( () => @@ -46,9 +53,7 @@ describe("Analytics Module", () => { })); sharedState.isProcessing = false; sharedState.requestsQueue = []; - sharedState.sessionContext = { - user_id: "test-user-id", - }; + Object.assign(sharedState, { wasInitializationTracked: true }); sharedState.config = { enabled: true, maxQueueSize: 1000, @@ -64,6 +69,9 @@ describe("Analytics Module", () => { appId, token: "test-access-token", }); + sharedState.sessionContext = { + user_id: "test-user-id", + }; }); afterEach(() => { @@ -157,12 +165,11 @@ describe("Analytics Module", () => { }); test("should not start the heartbeat outside a browser", () => { - const heartBeatState = sharedState as unknown as { - isHeartBeatProcessing: boolean; - }; - - expect(typeof window).toBe("undefined"); - expect(heartBeatState.isHeartBeatProcessing).toBeFalsy(); + vi.stubGlobal("window", undefined); + const setInterval = vi.spyOn(globalThis, "setInterval"); + const server = createClient({ serverUrl, appId }); + expect(setInterval).not.toHaveBeenCalled(); + server.cleanup(); }); test("should not resolve an identity when no token is set", async () => { diff --git a/tests/unit/auth-identity.test.ts b/tests/unit/auth-identity.test.ts new file mode 100644 index 00000000..15b8399f --- /dev/null +++ b/tests/unit/auth-identity.test.ts @@ -0,0 +1,177 @@ +import axios from "axios"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createAuthModule } from "../../src/modules/auth.ts"; +import type { AuthState, User } from "../../src/modules/auth.types.ts"; + +afterEach(() => vi.unstubAllGlobals()); + +function deferredUser() { + let resolve!: (user: User) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function setup() { + const api = axios.create(); + const functionsApi = axios.create(); + const get = vi.spyOn(api, "get"); + const onAuthStateChange = vi.fn<(state: AuthState) => void>(); + const auth = createAuthModule(api, functionsApi, "app-id", { + serverUrl: "https://base44.example", + appBaseUrl: "https://base44.example", + onAuthStateChange, + }); + return { api, functionsApi, get, onAuthStateChange, auth }; +} + +describe("auth identity notifications", () => { + test("reports the returned user once for concurrent callers without caching settled identities", async () => { + const { get, onAuthStateChange, auth } = setup(); + const pending = deferredUser(); + const user = { id: "user-1" } as User; + get.mockReturnValueOnce(pending.promise); + + const first = auth.me(); + const second = auth.me(); + expect(onAuthStateChange).not.toHaveBeenCalled(); + pending.resolve(user); + + expect(await Promise.all([first, second])).toEqual([user, user]); + expect(get).toHaveBeenCalledTimes(1); + expect(onAuthStateChange.mock.calls).toEqual([ + [{ status: "authenticated", userId: "user-1" }], + ]); + + get.mockResolvedValueOnce({ id: "user-2" }); + await auth.me(); + expect(get).toHaveBeenCalledTimes(2); + expect(onAuthStateChange).toHaveBeenLastCalledWith({ + status: "authenticated", userId: "user-2", + }); + }); + + test("announces a token change only after subsequent requests can use it", () => { + const { api, functionsApi, onAuthStateChange, auth } = setup(); + const observed: unknown[] = []; + onAuthStateChange.mockImplementation((state) => { + observed.push({ + state, + hasToken: auth.hasToken(), + authorization: api.defaults.headers.common.Authorization, + functionsAuthorization: functionsApi.defaults.headers.common.Authorization, + }); + }); + + auth.setToken("next-token", false); + expect(observed).toEqual([{ + state: { status: "pending" }, + hasToken: true, + authorization: "Bearer next-token", + functionsAuthorization: "Bearer next-token", + }]); + }); + + test.each(["success", "failure"])("ignores an old token's late %s without retiring the new request", async (outcome) => { + const { get, onAuthStateChange, auth } = setup(); + const old = deferredUser(); + const current = deferredUser(); + get.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise); + const before = auth.me().catch((error) => error); + + auth.setToken("new-token", false); + const after = auth.me(); + if (outcome === "success") old.resolve({ id: "old-user" } as User); + else old.reject({ status: 401 }); + await before; + expect(onAuthStateChange.mock.calls).toEqual([[{ status: "pending" }]]); + + const joined = auth.me(); + current.resolve({ id: "new-user" } as User); + expect(await Promise.all([after, joined])).toEqual([ + { id: "new-user" }, { id: "new-user" }, + ]); + expect(get).toHaveBeenCalledTimes(2); + expect(onAuthStateChange.mock.calls).toEqual([ + [{ status: "pending" }], + [{ status: "authenticated", userId: "new-user" }], + ]); + }); + + test.each(["success", "failure"])("keeps logout anonymous after an old request's late %s", async (outcome) => { + const { api, get, onAuthStateChange, auth } = setup(); + const pending = deferredUser(); + auth.setToken("old-token", false); + onAuthStateChange.mockClear(); + get.mockReturnValueOnce(pending.promise); + const before = auth.me().catch((error) => error); + + const observed: unknown[] = []; + onAuthStateChange.mockImplementation(() => { + observed.push({ + hasToken: auth.hasToken(), + authorization: api.defaults.headers.common.Authorization, + }); + }); + auth.logout(); + if (outcome === "success") pending.resolve({ id: "old-user" } as User); + else pending.reject({ response: { status: 503 } }); + await before; + + expect(onAuthStateChange.mock.calls).toEqual([[{ status: "anonymous" }]]); + expect(observed).toEqual([{ hasToken: false, authorization: undefined }]); + }); + + test.each([ + [{ status: 401 }, "anonymous"], + [{ status: 403 }, "anonymous"], + [{ response: { status: 401 } }, "anonymous"], + [{ response: { status: 403 } }, "anonymous"], + [{ status: 503 }, "error"], + [new Error("Network unavailable"), "error"], + ])("classifies shared %j failures once without changing their rejection", async (error, status) => { + const { get, onAuthStateChange, auth } = setup(); + get.mockRejectedValueOnce(error); + + const results = await Promise.allSettled([auth.me(), auth.me()]); + expect(results).toEqual([ + { status: "rejected", reason: error }, + { status: "rejected", reason: error }, + ]); + expect(get).toHaveBeenCalledTimes(1); + expect(onAuthStateChange.mock.calls).toEqual([[{ status }]]); + }); + + test("preserves successful and failed auth results when an observer throws", async () => { + const { get, onAuthStateChange, auth } = setup(); + onAuthStateChange.mockImplementation(() => { throw new Error("Observer failed"); }); + get.mockResolvedValueOnce({ id: "user-1" }); + await expect(auth.me()).resolves.toEqual({ id: "user-1" }); + + const error = { status: 401 }; + get.mockRejectedValueOnce(error); + await expect(auth.me()).rejects.toBe(error); + }); + + test("still persists tokens and completes logout cleanup and redirect when an observer throws", () => { + const { onAuthStateChange, auth } = setup(); + const localStorage = { setItem: vi.fn(), removeItem: vi.fn() }; + const location = { href: "https://base44.example/dashboard" }; + vi.stubGlobal("window", { localStorage, location }); + onAuthStateChange.mockImplementation(() => { throw new Error("Observer failed"); }); + + auth.setToken("new-token"); + expect(localStorage.setItem).toHaveBeenCalledWith("base44_access_token", "new-token"); + expect(localStorage.setItem).toHaveBeenCalledWith("token", "new-token"); + + auth.logout(); + expect(localStorage.removeItem).toHaveBeenCalledWith("base44_access_token"); + expect(localStorage.removeItem).toHaveBeenCalledWith("token"); + expect(location.href).toBe( + "https://base44.example/api/apps/auth/logout?from_url=https%3A%2F%2Fbase44.example%2Fdashboard" + ); + }); +}); diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index c79df86b..21638b7f 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -177,13 +177,24 @@ describe('Auth Module', () => { expect(scope.isDone()).toBe(true); }); - test('setToken() clears the analytics session context', () => { - const analyticsState = getSharedInstance('analytics', () => ({})); - analyticsState.sessionContext = { user_id: 'anonymous-user', session_id: 's1' }; - - base44.auth.setToken('new-access-token', false); - - expect(analyticsState.sessionContext).toBeNull(); + test('setToken() clears the shared browser analytics session context', () => { + vi.stubGlobal('window', { + location: { origin: appBaseUrl, pathname: '/', search: '' }, + localStorage: { getItem: () => null }, + }); + let browserClient; + try { + browserClient = createClient({ serverUrl, appId, appBaseUrl, analytics: { enabled: false } }); + const analyticsState = getSharedInstance('analytics', () => ({})); + analyticsState.sessionContext = { user_id: 'anonymous-user', session_id: 's1' }; + + browserClient.auth.setToken('new-access-token', false); + + expect(analyticsState.sessionContext).toBeNull(); + } finally { + browserClient?.cleanup(); + vi.unstubAllGlobals(); + } }); }); @@ -967,4 +978,4 @@ describe('Auth Module', () => { global.window = originalWindow; }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/experiment-exposures.test.ts b/tests/unit/experiment-exposures.test.ts new file mode 100644 index 00000000..0f5739ac --- /dev/null +++ b/tests/unit/experiment-exposures.test.ts @@ -0,0 +1,202 @@ +import axios, { type AxiosInstance } from "axios"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createExposureTracker } from "../../src/modules/experiment-exposures.js"; +import { createAnalyticsModule, getAnalyticsSessionId, resetAnalyticsSessionContext } from "../../src/modules/analytics.js"; +import { createAuthModule } from "../../src/modules/auth.js"; + +const assignment = { experiment_id: "experiment-1", run_version: 1, variant_key: "control" }; +const identity = { visitorId: "runtime-visitor", userId: "user-1" }; +const appId = "66f1a2b3c4d5e6f7a8b9c0d1"; + +describe("experiment exposure transport", () => { + let client: AxiosInstance; + let request: ReturnType; + + beforeEach(() => { + vi.stubGlobal("window", { + location: { pathname: "/checkout", search: "" }, + history: { replaceState: vi.fn() }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + vi.stubGlobal("document", {}); + client = axios.create(); + client.defaults.headers.common.Authorization = "Bearer user-1-token"; + request = vi.spyOn(client, "request").mockResolvedValue({ accepted: 1 }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + test("sends one immediate batch with the runtime visitor and no client user claim", () => { + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith({ + method: "POST", + url: `/apps/${appId}/analytics/track/batch`, + headers: { Authorization: "Bearer user-1-token" }, + data: { events: [{ + event_name: "__experiment_exposure__", + event_id: expect.any(String), + timestamp: expect.any(String), + session_id: "runtime-visitor", + page_url: "/checkout", + properties: { ...assignment, source: "browser" }, + }] }, + }); + const event = request.mock.calls[0][0].data.events[0]; + expect(new Date(event.timestamp).toISOString()).toBe(event.timestamp); + }); + + test("deduplicates reads but allows new runs, variants, users and visitors", () => { + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + tracker.track({ ...assignment }, { ...identity }); + tracker.track({ ...assignment, run_version: 2 }, identity); + tracker.track({ ...assignment, variant_key: "treatment" }, identity); + tracker.track(assignment, { ...identity, userId: "user-2" }); + tracker.track(assignment, { ...identity, visitorId: "visitor-2" }); + + expect(request).toHaveBeenCalledTimes(5); + }); + + test.each(["getItem", "setItem"])("attributes goals to the exposure when storage %s fails", async (method) => { + vi.useFakeTimers(); + const storage = { getItem: vi.fn(() => null as string | null), setItem: vi.fn() }; + storage[method as keyof typeof storage].mockImplementation(() => { throw new Error("storage blocked"); }); + vi.stubGlobal("localStorage", storage); + Object.assign(window, { __B44_EXPERIMENTS__: identity }); + delete client.defaults.headers.common.Authorization; + resetAnalyticsSessionContext(); + const userAuthModule = createAuthModule(client, axios.create(), appId, { serverUrl: "https://example.test", appBaseUrl: "https://example.test" }); + const analytics = createAnalyticsModule({ axiosClient: client, appId, serverUrl: "https://example.test", userAuthModule, enabled: true }); + try { + createExposureTracker({ axiosClient: client, appId, enabled: true }).track(assignment, { ...identity, userId: null }); + analytics.track({ eventName: "purchase" }); + await vi.advanceTimersByTimeAsync(1000); + storage.getItem.mockReturnValue("recovered-storage-visitor"); + storage.setItem.mockImplementation(() => {}); + analytics.track({ eventName: "purchase_after_storage_recovers" }); + await vi.advanceTimersByTimeAsync(1000); + + const events = request.mock.calls.flatMap(([config]) => config.data.events); + for (const eventName of ["__experiment_exposure__", "purchase", "purchase_after_storage_recovers"]) { + expect(events.find((event) => event.event_name === eventName)?.session_id).toBe(identity.visitorId); + } + } finally { + analytics.cleanup(); + vi.useRealTimers(); + } + }); + + test.each([undefined, "anon"])("keeps ordinary visitor IDs when runtime ID is %s", (visitorId) => { + Object.assign(window, { __B44_EXPERIMENTS__: visitorId ? { visitorId } : undefined }); + const storage = { getItem: vi.fn(() => "stored-visitor"), setItem: vi.fn() }; + vi.stubGlobal("localStorage", storage); + expect(getAnalyticsSessionId()).toBe("stored-visitor"); + + storage.getItem.mockImplementation(() => { throw new Error("storage blocked"); }); + const fallback = getAnalyticsSessionId(); + expect(fallback).toBeTruthy(); + expect(fallback).not.toBe("anon"); + expect(getAnalyticsSessionId()).toBe(fallback); + }); + + test("automatically retries the same event and credentials after a lost acknowledgement", async () => { + vi.useFakeTimers(); + request.mockRejectedValueOnce(new Error("offline")); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + client.defaults.headers.common.Authorization = "Bearer replacement"; + await vi.advanceTimersByTimeAsync(100); + await tracker.flush(); + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1][0]).toEqual(request.mock.calls[0][0]); + expect(request.mock.calls[0][0].data.events[0].event_id).toMatch(/^[0-9a-f-]{36}$/); + vi.useRealTimers(); + }); + + test("backend flush rejects unaccepted batches and a later flush reuses the same event", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", undefined); + request.mockResolvedValue({ accepted: 0 }); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true, source: "backend", pageUrl: "/checkout" }); + tracker.track(assignment, identity); + const failed = expect(tracker.flush()).rejects.toThrow("not accepted"); + await vi.advanceTimersByTimeAsync(600); + await failed; + expect(request).toHaveBeenCalledTimes(3); + const initial = request.mock.calls[0][0]; + expect(initial.data.events[0].properties.source).toBe("backend"); + expect(initial.data.events[0].page_url).toBe("/checkout"); + request.mockResolvedValue({ accepted: 1 }); + await tracker.flush(); + expect(request).toHaveBeenCalledTimes(4); + expect(request.mock.calls[3][0]).toEqual(initial); + tracker.track(assignment, identity); + await tracker.flush(); + expect(request).toHaveBeenCalledTimes(4); + }); + + test.each(["user-1", null])("pins Authorization before defaults change for %s", async (userId) => { + request.mockRestore(); + const adapter = vi.fn(async (config) => ({ data: { accepted: 1 }, status: 200, statusText: "OK", headers: {}, config })); + client.defaults.adapter = adapter; + client.interceptors.response.use((response) => response.data); + client.interceptors.request.use(async (config) => { + await Promise.resolve(); + return config; + }); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, { ...identity, userId }); + client.defaults.headers.common.Authorization = "Bearer replacement-token"; + + await vi.waitFor(() => expect(adapter).toHaveBeenCalledOnce()); + expect(adapter.mock.calls[0][0].headers.get("Authorization")).toBe( + userId ? "Bearer user-1-token" : null, + ); + }); + + test("uses explicit null auth when no default header exists", () => { + delete client.defaults.headers.common.Authorization; + createExposureTracker({ axiosClient: client, appId, enabled: true }).track(assignment, identity); + + expect(request.mock.calls[0][0].headers.Authorization).toBeNull(); + }); + + test("does not send when disabled in client options or outside a browser", () => { + createExposureTracker({ axiosClient: client, appId, enabled: false }).track(assignment, identity); + vi.stubGlobal("window", undefined); + createExposureTracker({ axiosClient: client, appId, enabled: true }).track(assignment, identity); + + expect(request).not.toHaveBeenCalled(); + }); + + test("honors the URL opt-out after analytics consumes and removes the parameter", async () => { + window.location.search = "?analytics-enable=false"; + vi.resetModules(); + const { createExposureTracker: createTracker } = await import("../../src/modules/experiment-exposures.js"); + expect(window.history.replaceState).toHaveBeenCalledOnce(); + expect(window.history.replaceState).toHaveBeenCalledWith({}, "", "/checkout"); + window.location.search = ""; + createTracker({ axiosClient: client, appId, enabled: true }).track(assignment, identity); + + expect(request).not.toHaveBeenCalled(); + }); + + test("does not send on React Native", async () => { + vi.stubGlobal("window", {}); + vi.stubGlobal("document", undefined); + vi.resetModules(); + const { createExposureTracker: createTracker } = await import("../../src/modules/experiment-exposures.js"); + createTracker({ axiosClient: client, appId, enabled: true }).track(assignment, identity); + + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/experiments-auth.test.ts b/tests/unit/experiments-auth.test.ts new file mode 100644 index 00000000..ec013db6 --- /dev/null +++ b/tests/unit/experiments-auth.test.ts @@ -0,0 +1,104 @@ +import axios from "axios"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createAuthModule } from "../../src/modules/auth.js"; +import { createExperimentsModule } from "../../src/modules/experiments.js"; +import type { User } from "../../src/modules/auth.types.js"; +import type { ExperimentsRuntime } from "../../src/modules/experiments-runtime.types.js"; + +function setup(token?: string) { + const api = axios.create(); + const requests: { resolve: (user: User) => void; reject: (error: unknown) => void }[] = []; + const get = vi.spyOn(api, "get").mockImplementation(() => + new Promise((resolve, reject) => requests.push({ resolve, reject })) + ); + const runtime: ExperimentsRuntime = { + flags: { checkout: false }, assignments: [], visitorId: "visitor", + userId: null, pendingUser: false, + setUser(userId) { + this.userId = userId; + this.pendingUser = false; + this.flags = { checkout: userId !== null }; + }, + }; + vi.stubGlobal("window", { + __B44_EXPERIMENTS__: runtime, + localStorage: { setItem: vi.fn(), removeItem: vi.fn() }, + location: { href: "https://example.test/dashboard" }, + }); + vi.stubGlobal("document", {}); + const bridge = createExperimentsModule({ getAuth: () => auth, trackExposure: vi.fn() }); + const auth = createAuthModule(api, axios.create(), "app-id", { + serverUrl: "https://example.test", appBaseUrl: "https://example.test", + onAuthStateChange: bridge.onAuthStateChange, + }); + if (token) auth.setToken(token, false); + return { api, get, requests, runtime, auth, ...bridge }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("experiments with real SDK auth", () => { + test("ready follows token B without waiting for A, and A cannot restore its identity", async () => { + const b = setup("token-a"); + const ready = b.module.ready(); + const oldRequest = b.auth.me(); + b.auth.setToken("token-b", false); + const newRequest = b.auth.me(); + expect(b.get).toHaveBeenCalledTimes(2); + + b.requests[1].resolve({ id: "user-b" } as User); + await newRequest; + expect(await ready).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(b.runtime.userId).toBe("user-b"); + + b.requests[0].resolve({ id: "user-a" } as User); + await expect(oldRequest).resolves.toEqual({ id: "user-a" }); + expect(b.runtime.userId).toBe("user-b"); + expect(b.module.getSnapshot()).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(b.get).toHaveBeenCalledTimes(2); + }); + + test("logout settles ready immediately and ignores the old authenticated response", async () => { + const b = setup("old-token"); + const ready = b.module.ready(); + const oldRequest = b.auth.me(); + b.auth.logout(); + + expect(await ready).toEqual({ flags: { checkout: false }, isLoading: false }); + expect(b.runtime.userId).toBeNull(); + b.requests[0].resolve({ id: "old-user" } as User); + await oldRequest; + expect(b.module.getSnapshot()).toEqual({ flags: { checkout: false }, isLoading: false }); + expect(b.runtime.userId).toBeNull(); + expect(b.get).toHaveBeenCalledOnce(); + }); + + test("ready observes the common auth flow retry without starting a lookup", async () => { + const b = setup("valid-token"); + const ready = b.module.ready(); + const first = b.auth.me().catch(() => {}); + b.requests[0].reject({ status: 503 }); + await first; + expect(await ready).toEqual({ flags: {}, isLoading: false }); + + const retry = b.auth.me(); + b.requests[1].resolve({ id: "recovered-user" } as User); + await retry; + expect(await b.module.ready()).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(b.runtime.userId).toBe("recovered-user"); + }); + + test("email login changes an active anonymous experiment session to the resolved user", async () => { + const b = setup(); + expect(await b.module.ready()).toEqual({ flags: { checkout: false }, isLoading: false }); + const response = { access_token: "login-token", user: { id: "logged-in" } }; + vi.spyOn(b.api, "post").mockResolvedValueOnce(response); + + await expect(b.auth.loginViaEmailPassword("user@example.test", "password")).resolves.toEqual(response); + expect(b.module.getSnapshot()).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(b.api.defaults.headers.common.Authorization).toBe("Bearer login-token"); + expect(await b.module.ready()).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(b.runtime.userId).toBe("logged-in"); + expect(b.get).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/experiments-client.test.ts b/tests/unit/experiments-client.test.ts new file mode 100644 index 00000000..f0f752d5 --- /dev/null +++ b/tests/unit/experiments-client.test.ts @@ -0,0 +1,165 @@ +import axios from "axios"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createClient, createClientFromRequest } from "../../src/client.js"; +import { resetAnalyticsSessionContext } from "../../src/modules/analytics.js"; +import type { ExperimentsContext } from "../../src/modules/experiments-config.types.js"; +import { getSharedInstance } from "../../src/utils/sharedInstance.js"; + +vi.mock("partysocket", () => ({ WebSocket: class {} })); + +const context: ExperimentsContext = { + config: { v: 1, revision: 2, app_id: "app", flags: [], experiments: [{ + id: "exp", flag_key: "checkout", run_version: 1, assign_by: "user", traffic_allocation: 100, + variants: [{ key: "control", value: false, weight: 0 }, { key: "treatment", value: true, weight: 100 }], + }] }, + identity: { visitorId: "visitor", userId: "user", status: "authenticated" }, + preview: {}, +}; +const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + +beforeEach(() => { + const state = getSharedInstance("analytics", () => ({ config: {} })); + Object.assign(state, { requestsQueue: [], isProcessing: false, isHeartBeatProcessing: false, + wasInitializationTracked: true, sessionContext: null, sessionStartTime: null }); + Object.assign(state.config, { enabled: true, maxQueueSize: 1000, throttleTime: 1000, batchSize: 30, heartBeatInterval: 0 }); + resetAnalyticsSessionContext(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function captureAnalytics() { + const create = axios.create.bind(axios); + const adapter = vi.fn(async (config) => ({ data: { accepted: 1 }, status: 200, statusText: "OK", headers: {}, config })); + vi.spyOn(axios, "create").mockImplementation((config) => { + const api = create(config); + api.defaults.adapter = adapter; + return api; + }); + return adapter; +} + +describe("client experiments integration", () => { + test("request context evaluates synchronously and flushes with the request's user token", async () => { + const create = axios.create.bind(axios); + const adapter = vi.fn(async (config) => ({ data: { accepted: 1 }, status: 200, statusText: "OK", headers: {}, config })); + vi.spyOn(axios, "create").mockImplementation((config) => { + const api = create(config); + api.defaults.adapter = adapter; + return api; + }); + const client = createClientFromRequest(new Request("https://app.example/checkout", { headers: { + "Base44-App-Id": "app", "Authorization": "Bearer user-token", "Base44-Experiments-Context": encode(context), + } })); + expect(client.experiments.getSnapshot()).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(adapter).not.toHaveBeenCalled(); + expect(client.experiments.isEnabled("checkout")).toBe(true); + await client.experiments.flush(); + expect(adapter).toHaveBeenCalledOnce(); + const request = adapter.mock.calls[0][0]; + expect(request.url).toBe("/apps/app/analytics/track/batch"); + expect(request.headers.get("Authorization")).toBe("Bearer user-token"); + expect(request.headers.has("Base44-Experiments-Context")).toBe(false); + const [event] = JSON.parse(request.data).events; + expect(event.properties.source).toBe("backend"); + expect(event.session_id).toBe("visitor"); + expect(event.page_url).toBe("/checkout"); + const transport = vi.fn(async (_url: string, _init?: RequestInit) => new Response("ok")); + await client.fetchWithAuth("/api/child", { fetch: transport }); + expect(new Headers(transport.mock.calls[0][1]?.headers).get("Base44-Experiments-Context")).toBe(encode(context)); + client.setToken("different-user-token"); + await client.fetchWithAuth("/api/child", { fetch: transport }); + expect(new Headers(transport.mock.calls[1][1]?.headers).has("Base44-Experiments-Context")).toBe(false); + client.cleanup(); + }); + + test("browser waits for its token identity while retaining SSR flags and forwarding visitor/preview", async () => { + vi.stubGlobal("window", { + __B44_EXPERIMENTS_BOOTSTRAP__: { ...context, preview: { checkout: false } }, + location: { origin: "https://app.example", pathname: "/checkout" }, + localStorage: { getItem: () => "user-token", setItem: () => {} }, + }); + vi.stubGlobal("document", {}); + const client = createClient({ appId: "app", token: "user-token", analytics: { enabled: false } }); + expect(client.experiments.getSnapshot()).toEqual({ flags: {}, isLoading: true }); + expect(client.experiments.getServerSnapshot()).toEqual({ flags: { checkout: false }, isLoading: false }); + const transport = vi.fn(async (_url: string, _init?: RequestInit) => new Response("ok")); + await client.fetchWithAuth("/api/checkout", { fetch: transport }); + const headers = new Headers(transport.mock.calls[0][1]?.headers); + expect(headers.get("Base44-Visitor-Id")).toBe("visitor"); + expect(headers.get("Base44-Experiment-Preview")).toBe('{"checkout":false}'); + client.cleanup(); + }); + + test.each([false, true])("browser goals preserve bootstrap preview %s without a URL override", async (value) => { + vi.useFakeTimers(); + const preview = Object.assign(Object.create({ unrelated: true }), { checkout: value }); + const browserContext = { ...context, identity: { visitorId: "visitor", userId: null }, preview }; + vi.stubGlobal("window", { + __B44_EXPERIMENTS_BOOTSTRAP__: browserContext, + location: { origin: "https://app.example", pathname: "/checkout", search: "" }, + localStorage: { getItem: () => null, setItem: () => {} }, + addEventListener: vi.fn(), removeEventListener: vi.fn(), + }); + vi.stubGlobal("document", { referrer: "" }); + const adapter = captureAnalytics(); + const client = createClient({ appId: "app" }); + client.analytics.track({ eventName: "purchase", properties: { + amount: 42, __b44_experiment_preview: '{"unrelated":true}', + } }); + await vi.advanceTimersByTimeAsync(1000); + expect(adapter).toHaveBeenCalledOnce(); + const [event] = JSON.parse(adapter.mock.calls[0][0].data).events; + expect(event).toMatchObject({ event_name: "purchase", session_id: "visitor", properties: { + amount: 42, __b44_experiment_preview: JSON.stringify({ checkout: value }), + } }); + client.cleanup(); + }); + + test.each([false, true])("Worker goals retain request-scoped preview %s and all user properties", async (value) => { + vi.useFakeTimers(); + const adapter = captureAnalytics(); + const requestContext = { ...context, identity: { visitorId: "worker-visitor", userId: null }, preview: { checkout: value } }; + const client = createClientFromRequest(new Request("https://app.example/checkout", { headers: { + "Base44-App-Id": "app", "Base44-Experiments-Context": encode(requestContext), + } })); + const properties = Object.fromEntries(Array.from({ length: 50 }, (_, index) => [`item_${index}`, index])); + client.analytics.track({ eventName: "purchase", properties }); + await vi.advanceTimersByTimeAsync(1000); + const [event] = JSON.parse(adapter.mock.calls[0][0].data).events; + expect(event).toMatchObject({ event_name: "purchase", session_id: "worker-visitor", properties: { + ...properties, __b44_experiment_preview: JSON.stringify({ checkout: value }), + } }); + expect(Object.keys(event.properties)).toHaveLength(51); + expect(Object.keys(properties)).toHaveLength(50); + client.cleanup(); + }); + + test("queued goals keep occurrence-time previews while normal goals stay unchanged", async () => { + vi.useFakeTimers(); + const adapter = captureAnalytics(); + const experimentsContext: ExperimentsContext = { + ...context, identity: { visitorId: "visitor", userId: null }, preview: {}, + }; + const client = createClient({ appId: "app", experiments: experimentsContext }); + client.analytics.track({ eventName: "warmup" }); + await vi.advanceTimersByTimeAsync(0); + experimentsContext.preview = { checkout: false }; + client.analytics.track({ eventName: "preview_purchase", properties: { amount: 42 } }); + experimentsContext.preview = {}; + client.analytics.track({ eventName: "normal_purchase", properties: { amount: 42 } }); + client.analytics.track({ eventName: "reserved_collision", properties: { __b44_experiment_preview: '{"checkout":true}' } }); + await vi.advanceTimersByTimeAsync(1000); + const events = adapter.mock.calls.flatMap(([request]) => JSON.parse(request.data).events); + expect(events.map(({ event_name, properties }) => ({ event_name, properties }))).toEqual([ + { event_name: "warmup", properties: undefined }, + { event_name: "preview_purchase", properties: { amount: 42, __b44_experiment_preview: '{"checkout":false}' } }, + { event_name: "normal_purchase", properties: { amount: 42 } }, + { event_name: "reserved_collision", properties: {} }, + ]); + client.cleanup(); + }); +}); diff --git a/tests/unit/experiments-context.test.ts b/tests/unit/experiments-context.test.ts new file mode 100644 index 00000000..29303a5d --- /dev/null +++ b/tests/unit/experiments-context.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { ExperimentsContext } from "../../src/modules/experiments-config.types.js"; +import { getBrowserExperimentsContext, readExperimentsContext } from "../../src/modules/experiments-context.js"; +import { createExperimentsModule } from "../../src/modules/experiments.js"; +import type { InternalAuthModule } from "../../src/modules/auth.types.js"; + +const context: ExperimentsContext = { + config: { v: 1, revision: 8, app_id: "app", flags: [], experiments: [{ + id: "exp", flag_key: "checkout", run_version: 1, traffic_allocation: 100, assign_by: "user", + variants: [{ key: "control", value: false, weight: 0 }, { key: "treatment", value: true, weight: 100 }], + }] }, + identity: { visitorId: "ünïcödé-👩‍💻", userId: "user-a", status: "authenticated" }, +}; +const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + +afterEach(() => vi.unstubAllGlobals()); + +describe("platform experiments context", () => { + test("decodes UTF8 request context but rejects malformed, oversized and other-app context", () => { + expect(readExperimentsContext(encode(context), "app")).toEqual(context); + for (const value of [null, "not-json", "a".repeat(96 * 1024 + 1), encode({ ...context, config: { ...context.config, v: 2 } })]) { + expect(readExperimentsContext(value, "app")).toBeUndefined(); + } + expect(readExperimentsContext(encode(context), "other-app")).toBeUndefined(); + }); + + test("server requests evaluate independently without globals, auth calls or loading exposures", async () => { + const me = vi.fn(); + const track = vi.fn(); + const make = (value: ExperimentsContext) => createExperimentsModule({ + context: value, getAuth: () => ({ hasToken: () => true, me }) as unknown as InternalAuthModule, trackExposure: track, + }); + const a = make(context); + const b = make({ ...context, identity: { visitorId: "other", userId: null, status: "anonymous" } }); + expect(await a.module.ready()).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(a.module.getServerSnapshot()).toEqual(a.module.getSnapshot()); + expect(track).not.toHaveBeenCalled(); + expect(a.module.isEnabled("checkout")).toBe(true); + expect(b.module.isEnabled("checkout")).toBe(false); + expect(track).toHaveBeenCalledOnce(); + expect(track.mock.calls[0][1].userId).toBe("user-a"); + expect(me).not.toHaveBeenCalled(); + }); + + test("browser hydration preserves request preview despite conflicting session storage", () => { + const bootstrap = { ...context, preview: { checkout: false } }; + vi.stubGlobal("window", { __B44_EXPERIMENTS_BOOTSTRAP__: bootstrap }); + vi.stubGlobal("document", {}); + vi.stubGlobal("sessionStorage", { getItem: () => '{"checkout":true}' }); + const track = vi.fn(); + const sdk = createExperimentsModule({ + context: getBrowserExperimentsContext("app"), + getAuth: () => ({ hasToken: () => true }) as InternalAuthModule, trackExposure: track, + }); + const initial = sdk.module.getServerSnapshot(); + expect(initial).toEqual({ flags: { checkout: false }, isLoading: false }); + expect(sdk.module.isEnabled("checkout")).toBe(false); + sdk.onAuthStateChange({ status: "anonymous" }); + expect(sdk.module.getServerSnapshot()).toBe(initial); + expect(track).not.toHaveBeenCalled(); + }); + + test("preserves server-rendered flags while common browser auth is still pending", () => { + const serverFlags = { checkout: false }; + const sdk = createExperimentsModule({ + context: { ...context, identity: { ...context.identity, userId: null, status: "pending" }, serverSnapshot: { flags: serverFlags, isLoading: false } }, + getAuth: () => ({ hasToken: () => true }) as InternalAuthModule, trackExposure: vi.fn(), + }); + expect(sdk.module.getSnapshot()).toEqual({ flags: {}, isLoading: true }); + expect(sdk.module.getServerSnapshot()).toEqual({ flags: { checkout: false }, isLoading: false }); + sdk.onAuthStateChange({ status: "authenticated", userId: "user" }); + serverFlags.checkout = true; + expect(sdk.module.getSnapshot()).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(sdk.module.getServerSnapshot().flags.checkout).toBe(false); + }); +}); diff --git a/tests/unit/experiments-evaluator.test.ts b/tests/unit/experiments-evaluator.test.ts new file mode 100644 index 00000000..90899aa2 --- /dev/null +++ b/tests/unit/experiments-evaluator.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "vitest"; +import { evaluateExperiments } from "../../src/modules/experiments-evaluator.js"; +import type { ExperimentsConfig } from "../../src/modules/experiments-config.types.js"; + +const appId = "66f1a2b3c4d5e6f7a8b9c0d1"; +const config: ExperimentsConfig = { + v: 1, revision: 3, app_id: appId, + flags: [{ key: "checkout-flow", rollout_percentage: 54 }], + experiments: [{ + id: "exp-1", flag_key: "checkout-flow", run_version: 1, + assign_by: "visitor", traffic_allocation: 21, + variants: [{ key: "control", value: false, weight: 9 }, { key: "treatment", value: true, weight: 91 }], + }], +}; + +describe("shared local experiments evaluator", () => { + test.each([ + ["visitor-1", 54, 20, 9], + ["ünïcödé-👩‍💻", 8, 14, 59], + ] as const)("matches Python UTF8 golden boundaries for %s", (visitorId, rollout, enroll, variant) => { + const identity = { visitorId, userId: null }; + const golden: ExperimentsConfig = { + ...config, flags: [{ key: "checkout-flow", rollout_percentage: rollout }], + experiments: [{ ...config.experiments[0], traffic_allocation: enroll + 1, variants: [ + { key: "control", value: false, weight: variant }, { key: "treatment", value: true, weight: 100 - variant }, + ] }], + }; + expect(evaluateExperiments({ ...golden, experiments: [] }, identity).flags["checkout-flow"]).toBe(false); + const result = evaluateExperiments(golden, identity); + expect(result.flags["checkout-flow"]).toBe(true); + expect(result.assignments).toEqual([{ + experiment_id: "exp-1", flag_key: "checkout-flow", run_version: 1, variant_key: "treatment", preview: false, + }]); + expect(evaluateExperiments({ ...golden, experiments: [{ ...golden.experiments[0], traffic_allocation: enroll }] }, identity).assignments).toEqual([]); + }); + + test("user assignment ignores refresh visitor changes and excludes anonymous users", () => { + const userConfig: ExperimentsConfig = { ...config, experiments: [{ ...config.experiments[0], assign_by: "user", traffic_allocation: 100 }] }; + expect(evaluateExperiments(userConfig, { visitorId: "v1", userId: null }).assignments).toEqual([]); + expect(evaluateExperiments(userConfig, { visitorId: "v1", userId: "user" }).assignments) + .toEqual(evaluateExperiments(userConfig, { visitorId: "v2", userId: "user" }).assignments); + }); + + test("explicit preview suppresses enrollment, while inherited names are not overrides", () => { + const named: ExperimentsConfig = { ...config, experiments: [{ ...config.experiments[0], flag_key: "constructor", traffic_allocation: 100 }] }; + const identity = { visitorId: "visitor-1", userId: null }; + expect(evaluateExperiments(named, identity).assignments).toHaveLength(1); + const preview = evaluateExperiments(named, identity, { constructor: false }); + expect(preview.flags.constructor).toBe(false); + expect(preview.assignments).toEqual([]); + }); +}); diff --git a/tests/unit/experiments.test.ts b/tests/unit/experiments.test.ts new file mode 100644 index 00000000..83447a78 --- /dev/null +++ b/tests/unit/experiments.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createExperimentsModule } from "../../src/modules/experiments.js"; +import type { ExperimentsRuntime } from "../../src/modules/experiments-runtime.types.js"; +import type { AuthState, InternalAuthModule, User } from "../../src/modules/auth.types.js"; + +function setup(hasToken = false) { + const runtime: ExperimentsRuntime = { + flags: { checkout: false }, + assignments: [{ experiment_id: "exp", flag_key: "checkout", run_version: 1, variant_key: "control", preview: false }], + visitorId: "visitor", userId: null, pendingUser: false, + setUser(id) { + this.userId = id; + this.pendingUser = false; + this.flags = { checkout: id !== null }; + }, + }; + vi.stubGlobal("window", { __B44_EXPERIMENTS__: runtime }); + vi.stubGlobal("document", {}); + const requests: { resolve: (user: User) => void; reject: (error: Error) => void }[] = []; + const me = vi.fn(() => new Promise((resolve, reject) => requests.push({ resolve, reject }))); + const trackExposure = vi.fn(); + const bridge = createExperimentsModule({ + getAuth: () => ({ hasToken: () => hasToken, me }) as InternalAuthModule, + trackExposure, + }); + const settle = (index: number, state: AuthState) => { + bridge.onAuthStateChange(state); + if (state.status === "authenticated") requests[index]?.resolve({ id: state.userId } as User); + else requests[index]?.reject(new Error("lookup failed")); + }; + return { ...bridge, runtime, requests, settle, me, trackExposure }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("browser experiments", () => { + test("stays lazy and returns fallback without a browser or injected runtime", async () => { + const b = setup(true); + expect(b.me).not.toHaveBeenCalled(); + vi.stubGlobal("window", undefined); + expect(b.module.isEnabled("checkout", true)).toBe(true); + expect(await b.module.ready()).toEqual({ flags: {}, isLoading: false }); + vi.stubGlobal("window", {}); + expect(b.module.isEnabled("checkout")).toBe(false); + expect(b.me).not.toHaveBeenCalled(); + expect(b.trackExposure).not.toHaveBeenCalled(); + }); + + test("preserves explicit false, ignores inherited keys, and tracks only assigned reads", () => { + const b = setup(); + expect(b.module.isEnabled("missing", true)).toBe(true); + expect(b.module.isEnabled("toString")).toBe(false); + expect(b.trackExposure).not.toHaveBeenCalled(); + expect(b.module.isEnabled("checkout", true)).toBe(false); + expect(b.trackExposure).toHaveBeenCalledWith(b.runtime.assignments[0], b.runtime); + expect(b.me).not.toHaveBeenCalled(); + }); + + test("preview flags without assignments never report exposures", () => { + const b = setup(); + b.runtime.flags.checkout = true; + b.runtime.assignments = []; + expect(b.module.isEnabled("checkout")).toBe(true); + expect(b.trackExposure).not.toHaveBeenCalled(); + }); + + test("holds all exposures until token identity resolves, even when bootstrap is not pending", async () => { + const b = setup(true); + const observed: boolean[] = []; + b.module.subscribe(() => observed.push(b.module.getSnapshot().isLoading)); + expect(b.module.isEnabled("checkout")).toBe(false); + expect(b.module.getSnapshot()).toEqual({ flags: {}, isLoading: true }); + expect(b.trackExposure).not.toHaveBeenCalled(); + const ready = b.module.ready(); + b.settle(0, { status: "authenticated", userId: "user-1" }); + expect(await ready).toEqual({ flags: { checkout: true }, isLoading: false }); + expect(b.runtime.userId).toBe("user-1"); + expect(observed).toEqual([false]); + expect(b.module.isEnabled("checkout")).toBe(true); + expect(b.me).not.toHaveBeenCalled(); + }); + + test("snapshots are stable and immutable and observation alone does not expose", async () => { + const b = setup(); + const first = b.module.getSnapshot(); + expect(await b.module.ready()).toBe(first); + expect(Object.isFrozen(first.flags)).toBe(true); + const listener = vi.fn(); + const unsubscribe = b.module.subscribe(listener); + b.onAuthStateChange({ status: "anonymous" }); + expect(b.module.getSnapshot()).toBe(first); + expect(listener).not.toHaveBeenCalled(); + unsubscribe(); + b.onAuthStateChange({ status: "authenticated", userId: "user-1" }); + expect(listener).not.toHaveBeenCalled(); + expect(b.module.getSnapshot()).not.toBe(first); + expect(b.trackExposure).not.toHaveBeenCalled(); + }); + + test("ready follows a replacement token and logout immediately clears user assignments", async () => { + const b = setup(true); + const ready = b.module.ready(); + b.onAuthStateChange({ status: "pending" }); + expect(b.module.getSnapshot().isLoading).toBe(true); + b.settle(1, { status: "authenticated", userId: "new-user" }); + expect((await ready).flags.checkout).toBe(true); + expect(b.runtime.userId).toBe("new-user"); + b.onAuthStateChange({ status: "anonymous" }); + expect(b.runtime.userId).toBeNull(); + expect(b.module.isEnabled("checkout")).toBe(false); + }); + + test("failed common identity lookup returns fallbacks without starting its own retry", async () => { + const b = setup(true); + const ready = b.module.ready(); + b.settle(0, { status: "error" }); + expect(await ready).toEqual({ flags: {}, isLoading: false }); + expect(b.module.isEnabled("checkout", true)).toBe(true); + expect(b.me).not.toHaveBeenCalled(); + expect(b.trackExposure).not.toHaveBeenCalled(); + expect(await b.module.ready()).toEqual({ flags: {}, isLoading: false }); + b.onAuthStateChange({ status: "pending" }); + const retry = b.module.ready(); + b.settle(0, { status: "authenticated", userId: "user-1" }); + expect((await retry).flags.checkout).toBe(true); + }); + + test("invalid authentication resolves to visitor flags instead of user enrollment", async () => { + const b = setup(true); + const ready = b.module.ready(); + b.settle(0, { status: "anonymous" }); + expect((await ready).flags.checkout).toBe(false); + expect(b.runtime.userId).toBeNull(); + }); + + test("adopts a runtime injected later and clears stale bootstrap identity", () => { + const b = setup(); + vi.stubGlobal("window", {}); + b.module.getSnapshot(); + b.runtime.userId = "old-user"; + b.runtime.flags.checkout = true; + vi.stubGlobal("window", { __B44_EXPERIMENTS__: b.runtime }); + expect(b.module.isEnabled("checkout")).toBe(false); + expect(b.runtime.userId).toBeNull(); + }); + + test("cleanup and throwing subscribers cannot restore or interrupt identity", async () => { + const b = setup(true); + const listener = vi.fn(() => { throw new Error("render error"); }); + b.module.subscribe(listener); + b.settle(0, { status: "authenticated", userId: "user-1" }); + await b.module.ready(); + expect(b.module.isEnabled("checkout")).toBe(true); + b.cleanup(); + b.onAuthStateChange({ status: "authenticated", userId: "late-user" }); + expect(b.module.getSnapshot()).toEqual({ flags: {}, isLoading: false }); + expect(listener).toHaveBeenCalledOnce(); + }); + + test.each(["logout", "cleanup"])("ready settles on %s without waiting for an obsolete lookup", async (action) => { + const b = setup(true); + const ready = b.module.ready(); + if (action === "logout") b.onAuthStateChange({ status: "anonymous" }); + else b.cleanup(); + expect((await ready).isLoading).toBe(false); + }); +});