diff --git a/scripts/mintlify-post-processing/appended-articles.json b/scripts/mintlify-post-processing/appended-articles.json index 27fb69b..d9eb3c3 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 824bb16..27fd211 100644 --- a/scripts/mintlify-post-processing/types-to-expose.json +++ b/scripts/mintlify-post-processing/types-to-expose.json @@ -30,6 +30,8 @@ "EntityTypeRegistry", "EntityUpsertOptions", "EntityUpsertResult", + "ExperimentsModule", + "ExperimentsSnapshot", "FunctionName", "FunctionNameRegistry", "FunctionsModule", diff --git a/src/client.ts b/src/client.ts index fe0c983..7516b71 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,21 @@ 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({ + appId, + getAuth: () => userAuthModule, + trackExposure: exposureTracker.track, + flushExposures: exposureTracker.flush, + context: experimentsContext, + }); + const userAuthModule = createAuthModule( axiosClient, functionsAxiosClient, @@ -174,6 +198,7 @@ export function createClient(config: CreateClientConfig): Base44Client { appBaseUrl: normalizedAppBaseUrl, serverUrl, token, + onAuthStateChange: experiments.onAuthStateChange, } ); @@ -187,6 +212,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 +261,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 +291,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 +368,10 @@ export function createClient(config: CreateClientConfig): Base44Client { appId: String(appId), serverUrl, functionsVersion, - platformHeaders: optionalHeaders, + platformHeaders: { + ...headers, + ...(inheritedExperimentsContext ? { [EXPERIMENTS_CONTEXT_HEADER]: inheritedExperimentsContext } : {}), + }, }), /** @@ -507,6 +547,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 +571,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 31fa838..635552d 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 9445259..11efaab 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-queue.ts b/src/modules/analytics-queue.ts new file mode 100644 index 0000000..194f3d6 --- /dev/null +++ b/src/modules/analytics-queue.ts @@ -0,0 +1,123 @@ +import type { AxiosError, AxiosInstance } from "axios"; +import type { AnalyticsApiRequestData, AnalyticsModuleOptions } from "./analytics.types.js"; + +const DELIVERY_BUDGET_MS = 5000; +type Event = AnalyticsApiRequestData & { event_id?: string }; +type Entry = { event: Promise; authorization: string | null; userId: Promise }; +type PreparedEntry = { event: Event; authorization: string | null; userId: string | null }; +const queues = new WeakMap>(); + +/** @internal One transport queue per client, shared by goals and exposures. */ +export function getAnalyticsQueue(axiosClient: AxiosInstance, appId: string, config: AnalyticsModuleOptions) { + let queue = queues.get(axiosClient); + if (!queue) { + queue = createAnalyticsQueue(axiosClient, appId, config); + queues.set(axiosClient, queue); + } + return queue; +} + +function createAnalyticsQueue(axiosClient: AxiosInstance, appId: string, config: AnalyticsModuleOptions) { + const entries: Entry[] = []; + const pending = new Set>(); + let timer: ReturnType | undefined; + + function deliver(batch: Entry[]) { + const controller = new AbortController(); + const deadlineAt = Date.now() + DELIVERY_BUDGET_MS; + const deadline = new Promise((resolve) => { + controller.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + const timeout = setTimeout(() => controller.abort(), DELIVERY_BUDGET_MS); + function send(prepared: PreparedEntry[]) { + const groups = new Map(); + for (const entry of prepared) { + const key = JSON.stringify([entry.authorization, entry.userId, entry.event.session_id]); + const group = groups.get(key) ?? []; + group.push(entry); + groups.set(key, group); + } + return Promise.all([...groups.values()].map(async (group) => { + const events = group.map(({ event }) => event); + const exposures = events.filter((event) => event.event_name === "__experiment_exposure__"); + const attempts = exposures.length ? 3 : 1; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + if (controller.signal.aborted) return; + await axiosClient.request({ + method: "POST", url: `/apps/${appId}/analytics/track/batch`, + headers: { Authorization: group[0].authorization }, + // Ordinary goals have no backend deduplication and remain single-attempt. + data: { events: attempt === 0 ? events : exposures }, + timeout: Math.max(1, deadlineAt - Date.now()), signal: controller.signal, + }); + return; + } catch (error) { + const status = (error as AxiosError).response?.status ?? (error as AxiosError).status; + if (controller.signal.aborted || attempt === attempts - 1 || + (status !== undefined && (status < 500 || status >= 600))) return; + await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 100 : 500)); + } + } + })); + } + const delivery = (async () => { + const ready: PreparedEntry[] = []; + const requests: Promise[] = []; + let wake = () => {}; + let preparedAll = false; + const preparation = Promise.all(batch.map(async (entry) => { + const [event, userId] = await Promise.all([entry.event, entry.userId]); + if (event && !controller.signal.aborted) ready.push({ event, userId, authorization: entry.authorization }); + wake(); + })).then(() => { preparedAll = true; wake(); }); + while (!preparedAll || ready.length) { + if (!ready.length && !preparedAll) await Promise.race([ + new Promise((resolve) => { wake = resolve; }), deadline, + ]); + if (controller.signal.aborted) return; + // Coalesce this turn's resolved identities without waiting for unrelated auth I/O. + let turnTimer: ReturnType | undefined; + await Promise.race([preparation, new Promise((resolve) => { turnTimer = setTimeout(resolve, 0); })]); + clearTimeout(turnTimer); + if (ready.length) requests.push(send(ready.splice(0))); + } + await Promise.all(requests); + })(); + // Identity lookup and transports that ignore cancellation must also be bounded. + const settlement = Promise.race([delivery, deadline]).catch(() => {}).finally(() => { + clearTimeout(timeout); + controller.abort(); + pending.delete(settlement); + }); + pending.add(settlement); + } + + function schedule() { + if (timer || entries.length === 0) return; + timer = setTimeout(() => { + timer = undefined; + deliver(entries.splice(0, config.batchSize ?? 30)); + schedule(); + }, config.throttleTime ?? 1000); + } + + return { + enqueue(event: Event | Promise, authorization: string | null, userId: string | null | Promise) { + if (entries.length >= (config.maxQueueSize ?? 1000)) return; + entries.push({ event: Promise.resolve(event).catch(() => undefined), authorization, + userId: Promise.resolve(userId).catch(() => null) }); + schedule(); + }, + async flush() { + clearTimeout(timer); + timer = undefined; + while (entries.length) deliver(entries.splice(0, config.batchSize ?? 30)); + await Promise.all([...pending]); + }, + cleanup() { + clearTimeout(timer); + timer = undefined; + }, + }; +} diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index e9aebfc..e733863 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -1,9 +1,6 @@ import { AxiosInstance } from "axios"; import { TrackEventParams, - TrackEventData, - AnalyticsApiRequestData, - AnalyticsApiBatchRequest, TrackEventIntrinsicData, AnalyticsModuleOptions, SessionContext, @@ -11,6 +8,9 @@ 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"; +import { getAnalyticsQueue } from "./analytics-queue.js"; export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__"; export const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__"; @@ -30,20 +30,13 @@ const defaultConfiguration: AnalyticsModuleOptions = { heartBeatInterval: 60 * 1000, }; -/////////////////////////////////////////////// -//// shared queue for analytics events //// -/////////////////////////////////////////////// - const ANALYTICS_SHARED_STATE_NAME = "analytics"; -// shared state// -const analyticsSharedState = getSharedInstance( - ANALYTICS_SHARED_STATE_NAME, - () => ({ - requestsQueue: [] as TrackEventData[], - isProcessing: false, +function createAnalyticsState() { + return { 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,10 +45,24 @@ const analyticsSharedState = getSharedInstance( ...defaultConfiguration, ...getAnalyticsConfigFromUrlParams(), } as Required, - }) -); - -/////////////////////////////////////////////// + }; +} +type AnalyticsState = ReturnType; +const analyticsSharedState = getSharedInstance(ANALYTICS_SHARED_STATE_NAME, createAnalyticsState); +const clientAnalyticsStates = new WeakMap(); + +/** @internal */ +export function getAnalyticsState(axiosClient: AxiosInstance): AnalyticsState { + let state = clientAnalyticsStates.get(axiosClient); + if (!state) { + state = createAnalyticsState(); + if (typeof window !== "undefined") { + state.config = analyticsSharedState.config; + } + clientAnalyticsStates.set(axiosClient, state); + } + return state; +} export interface AnalyticsModuleArgs { axiosClient: AxiosInstance; @@ -63,23 +70,32 @@ 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 = ({ axiosClient, - serverUrl, appId, userAuthModule, enabled, + getVisitorId, + experimentsContext, }: AnalyticsModuleArgs) => { - // prevent overflow of events // - const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config; + const state = getAnalyticsState(axiosClient); + const automaticState = typeof window === "undefined" ? state : analyticsSharedState; + const queue = getAnalyticsQueue(axiosClient, appId, 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: () => {}, @@ -87,87 +103,41 @@ export const createAnalyticsModule = ({ } let clearHeartBeatProcessor: (() => void) | undefined = undefined; - const trackBatchUrl = `${serverUrl}/api/apps/${appId}/analytics/track/batch`; - - const batchRequestFallback = async (events: AnalyticsApiRequestData[]) => { - await axiosClient.request({ - method: "POST", - url: `/apps/${appId}/analytics/track/batch`, - data: { events }, - } as AnalyticsApiBatchRequest); - }; - - // currently disabled, until fully tested // - const beaconRequest = (events: AnalyticsApiRequestData[]) => { - try { - const beaconPayload = JSON.stringify({ events }); - const blob = new Blob([beaconPayload], { type: "application/json" }); - return ( - typeof navigator === "undefined" || - beaconPayload.length > 60000 || - !navigator.sendBeacon(trackBatchUrl, blob) - ); - } catch { - return false; - } - }; - - const flush = async ( - eventsData: TrackEventData[], - options: { isBeacon?: boolean } = {} - ) => { - if (eventsData.length === 0) return; - - const sessionContext_ = await getSessionContext(userAuthModule); - const events = eventsData.map( - transformEventDataToApiRequestData(sessionContext_) - ); - - try { - if (!options.isBeacon || !beaconRequest(events)) { - await batchRequestFallback(events); - } - } catch { - // do nothing - } - }; - - const startProcessing = () => { - startAnalyticsProcessor(flush, { - throttleTime, - batchSize, - }); - }; - const track = (params: TrackEventParams) => { - if (analyticsSharedState.requestsQueue.length >= maxQueueSize) { - return; - } const intrinsicData = getEventIntrinsicData(); - analyticsSharedState.requestsQueue.push({ - ...params, - ...intrinsicData, - }); - startProcessing(); + const visitorId = getVisitorId?.() ?? getAnalyticsSessionId(state); + const authorization = userAuthModule.hasToken() ? axiosClient.defaults.headers.common.Authorization : null; + const context = getSessionContext(userAuthModule, state); + 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); + } + const event = { + event_name: params.eventName, + timestamp: intrinsicData.timestamp, + page_url: intrinsicData.pageUrl, + properties: params.properties || Object.keys(properties).length ? properties : undefined, + }; + queue.enqueue(context.then((identity) => ({ ...event, ...identity, session_id: visitorId })), + typeof authorization === "string" ? authorization : null, + context.then((identity) => identity.user_id ?? null)); }; const onDocVisible = () => { - startAnalyticsProcessor(flush, { - throttleTime, - batchSize, - }); - clearHeartBeatProcessor = startHeartBeatProcessor(track); - setSessionDurationTimerStart(); + clearHeartBeatProcessor = startHeartBeatProcessor(track, automaticState); + setSessionDurationTimerStart(automaticState); }; const onDocHidden = () => { - stopAnalyticsProcessor(); clearHeartBeatProcessor?.(); - trackSessionDurationEvent(track); + trackSessionDurationEvent(track, automaticState); - // flush entire queue on visibility change and hope for the best // - const eventsData = analyticsSharedState.requestsQueue.splice(0); - flush(eventsData, { isBeacon: true }); + void queue.flush(); }; const onVisibilityChange = () => { @@ -180,19 +150,17 @@ export const createAnalyticsModule = ({ }; const cleanup = () => { - stopAnalyticsProcessor(); + queue.cleanup(); clearHeartBeatProcessor?.(); if (typeof window !== "undefined") { window.removeEventListener("visibilitychange", onVisibilityChange); } }; - // start the flusing process /// - startProcessing(); // start the heart beat processor // - clearHeartBeatProcessor = startHeartBeatProcessor(track); + clearHeartBeatProcessor = startHeartBeatProcessor(track, automaticState); // track the referrer event // - trackInitializationEvent(track); + trackInitializationEvent(track, automaticState); // start the visibility change listener // if (typeof window !== "undefined") { window.addEventListener("visibilitychange", onVisibilityChange); @@ -204,68 +172,39 @@ export const createAnalyticsModule = ({ }; }; -function stopAnalyticsProcessor() { - analyticsSharedState.isProcessing = false; -} - -async function startAnalyticsProcessor( - handleTrack: (eventsData: TrackEventData[]) => Promise, - options?: { - throttleTime: number; - batchSize: number; - } -) { - if (analyticsSharedState.isProcessing) { - // only one instance of the analytics processor can be running at a time // - return; - } - analyticsSharedState.isProcessing = true; - - const { throttleTime = 1000, batchSize = 30 } = options ?? {}; - while ( - analyticsSharedState.isProcessing && - analyticsSharedState.requestsQueue.length > 0 - ) { - const requests = analyticsSharedState.requestsQueue.splice(0, batchSize); - requests.length && (await handleTrack(requests)); - await new Promise((resolve) => setTimeout(resolve, throttleTime)); - } - analyticsSharedState.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 +213,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 }, @@ -308,18 +247,6 @@ function getEventIntrinsicData(): TrackEventIntrinsicData { }; } -function transformEventDataToApiRequestData(sessionContext: SessionContext) { - return (eventData: TrackEventData): AnalyticsApiRequestData => ({ - event_name: eventData.eventName, - properties: eventData.properties, - timestamp: eventData.timestamp, - page_url: eventData.pageUrl, - ...sessionContext, - }); -} - -let sessionContextPromise: Promise | null = null; - /** * Clears the memoized analytics session context. * @@ -330,26 +257,28 @@ let sessionContextPromise: Promise | null = null; * * @internal */ -export function resetAnalyticsSessionContext() { - analyticsSharedState.sessionContext = null; - sessionContextPromise = null; +export function resetAnalyticsSessionContext(axiosClient?: AxiosInstance) { + const state = axiosClient ? getAnalyticsState(axiosClient) : 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 +289,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 +297,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 +330,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 +355,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 b9e2374..3007c0a 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 7c080ef..852f99e 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 0000000..ec41af9 --- /dev/null +++ b/src/modules/experiment-exposures.ts @@ -0,0 +1,42 @@ +import type { AxiosInstance } from "axios"; +import { v4 as uuid } from "uuid"; +import { getAnalyticsState, isAnalyticsEnabled } from "./analytics.js"; +import { getAnalyticsQueue } from "./analytics-queue.js"; + +/** @internal */ +export function createExposureTracker({ + axiosClient, appId, enabled, source = "browser", pageUrl, +}: { + axiosClient: AxiosInstance; + appId: string; + enabled: boolean; + source?: "browser" | "backend"; + pageUrl?: string; +}) { + const state = getAnalyticsState(axiosClient); + const queue = getAnalyticsQueue(axiosClient, appId, state.config); + const tracked = new Set(); + + 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, state)) return; + const { experiment_id, run_version, variant_key } = assignment; + const key = JSON.stringify([experiment_id, run_version, variant_key, identity.userId, identity.visitorId]); + if (tracked.has(key)) return; + tracked.add(key); + const authorization = identity.userId ? axiosClient.defaults.headers.common.Authorization : null; + queue.enqueue({ + 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 }, + }, typeof authorization === "string" ? authorization : null, identity.userId); + }, + flush: queue.flush, + }; +} diff --git a/src/modules/experiments-config.types.ts b/src/modules/experiments-config.types.ts new file mode 100644 index 0000000..fc8e849 --- /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 0000000..623ea09 --- /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 0000000..f7a273b --- /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 0000000..e9eb6be --- /dev/null +++ b/src/modules/experiments-runtime.types.ts @@ -0,0 +1,30 @@ +/** @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(appId?: string): ExperimentsRuntime | undefined { + if (typeof window === "undefined" || typeof document === "undefined") return; + const page = window as Window & { + __B44_EXPERIMENTS__?: ExperimentsRuntime; + __B44_EXPERIMENTS_BOOTSTRAP__?: { config: { app_id: string } }; + }; + // The legacy evaluator has no app ID; its companion bootstrap identifies its owner. + if (appId !== undefined && page.__B44_EXPERIMENTS_BOOTSTRAP__?.config?.app_id !== appId) return; + return page.__B44_EXPERIMENTS__; +} diff --git a/src/modules/experiments.ts b/src/modules/experiments.ts new file mode 100644 index 0000000..321b658 --- /dev/null +++ b/src/modules/experiments.ts @@ -0,0 +1,164 @@ +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({ + appId, + getAuth, + trackExposure, + flushExposures = async () => {}, + context, +}: { + appId: string; + 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(appId); + 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(appId); + 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 0000000..eff3ebe --- /dev/null +++ b/src/modules/experiments.types.ts @@ -0,0 +1,130 @@ +/** 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 a best-effort 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. Network and server failures retry up to + * three attempts within five seconds of batch delivery, preserving the event ID, + * timestamp and credentials. HTTP successes (including rejected measurements) and client errors + * are terminal. Exposures share the Analytics batch with compatible ordinary + * events; credentials and user/visitor identities are captured when tracking. + * Only exposures are retried; ordinary goals retain single-attempt delivery. + * On servers, use the runtime's background lifetime mechanism. + * + * @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; + + /** + * Flushes this client's queued Analytics goals and exposures without rejecting. + * Each delivery has a five-second total budget; exhausted or rejected events are + * dropped and are not retried by later reads or flushes. Settlement is not proof + * of ingestion, and raw storage is not exactly-once. No new exposures are created. + * Worker handlers should use `ctx.waitUntil(client.experiments.flush())` instead + * of awaiting Analytics on the application response path. Other runtimes must use + * their supported background lifetime mechanism; fire-and-forget alone may be cut off. + * Base44's legacy Cloudflare runtime exposes `globalThis.Base44.waitUntil(...)`; + * the newer runtime exports `waitUntil` from `base44:runtime`. Use the API provided + * by your deployed runtime. Deno without a background lifetime API must await flush. + * + * @returns A promise that resolves when the current batch deliveries settle. + * @example + * ```typescript + * // In a Worker handler with an execution context: + * ctx.waitUntil(base44.experiments.flush()); + * ``` + */ + flush(): Promise; +} diff --git a/src/utils/fetch-with-auth.ts b/src/utils/fetch-with-auth.ts index 9b95498..5403b37 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 0000000..9d4be99 --- /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 0000000..c063dff --- /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 48e61f6..ea8ee52 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -1,241 +1,232 @@ -import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; -import { - AnalyticsModuleOptions, - createClient, - SessionContext, - TrackEventData, -} from "../../src/index.ts"; -import { getSharedInstance } from "../../src/utils/sharedInstance.ts"; -import { resetAnalyticsSessionContext } from "../../src/modules/analytics.ts"; -import { InternalAuthModule, User } from "../../src/modules/auth.types.ts"; -import { AxiosInstance } from "axios"; +import axios from "axios"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createClient } from "../../src/client.js"; +import { getSharedInstance } from "../../src/utils/sharedInstance.js"; +import type { AnalyticsModuleOptions } from "../../src/modules/analytics.types.js"; + +vi.mock("partysocket", () => ({ WebSocket: class {} })); describe("Analytics Module", () => { - let base44: ReturnType; - let sharedState: null | { - requestsQueue: TrackEventData[]; - isProcessing: boolean; - sessionContext: SessionContext; - config: AnalyticsModuleOptions; + let client: ReturnType; + let adapter: ReturnType; + let config: AnalyticsModuleOptions; + const clients: ReturnType[] = []; + const events = () => batches().flatMap((request) => JSON.parse(request.data).events); + const batches = () => adapter.mock.calls.map(([request]) => request) + .filter((request) => request.url.endsWith("/analytics/track/batch")); + const makeClient = (options = {}) => { + const result = createClient({ appId: "app", ...options }); + clients.push(result); + return result; }; - const appId = "test-app-id"; - const serverUrl = "https://api.base44.com"; beforeEach(() => { - vi.mock("../../src/utils/axios-client.ts", () => ({ - createAxiosClient: vi.fn().mockImplementation( - () => - ({ - // `setToken` and `logout` write through to these, so the mock needs - // them present per instance. - defaults: { headers: { common: {} as Record } }, - request: vi.fn().mockResolvedValue({ - status: 200, - data: { - message: "success", - }, - }), - } as unknown as AxiosInstance) - ), - })); - sharedState = getSharedInstance("analytics", () => ({ - requestsQueue: [], - isProcessing: false, - sessionContext: {}, - config: {}, + vi.useFakeTimers(); + const stored = new Map(); + const storage = { getItem: vi.fn((key) => stored.get(key) ?? null), + setItem: vi.fn((key, value) => stored.set(key, value)), removeItem: vi.fn((key) => stored.delete(key)) }; + 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(), + }); + const shared = getSharedInstance("analytics", () => ({ config: {} })); + config = shared.config; + Object.assign(config, { enabled: true, maxQueueSize: 1000, throttleTime: 1000, batchSize: 2, heartBeatInterval: 0 }); + Object.assign(shared, { wasInitializationTracked: true, isHeartBeatProcessing: false, sessionStartTime: null }); + const create = axios.create.bind(axios); + adapter = vi.fn(async (request) => ({ + data: request.url.endsWith("/entities/User/me") + ? { id: request.headers.get("Authorization").replace("Bearer token-", "user-") } + : { accepted: 1 }, + status: 200, statusText: "OK", headers: {}, config: request, })); - sharedState.isProcessing = false; - sharedState.requestsQueue = []; - sharedState.sessionContext = { - user_id: "test-user-id", - }; - sharedState.config = { - enabled: true, - maxQueueSize: 1000, - throttleTime: 1000, - batchSize: 2, - heartBeatInterval: undefined, - }; - - // Token-bearing by default: most tests here exercise the flush path that - // resolves an identity, and that lookup is skipped without a session. - base44 = createClient({ - serverUrl, - appId, - token: "test-access-token", + vi.spyOn(axios, "create").mockImplementation((options) => { + const api = create(options); + api.defaults.adapter = adapter; + return api; }); + client = makeClient(); }); afterEach(() => { - vi.clearAllMocks(); - base44.cleanup(); + for (const client of clients.splice(0)) client.cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); - sharedState = null; }); - test("should create analytics module with shared state", () => { - expect(base44.analytics).toBeDefined(); - expect(sharedState).toBeDefined(); - expect(sharedState?.requestsQueue).toBeDefined(); - expect(sharedState?.isProcessing).toBe(false); + test("captures the event's time and properties before the scheduled batch", async () => { + const properties = { amount: 42 }; + const timestamp = new Date().toISOString(); + client.analytics.track({ eventName: "purchase", properties }); + properties.amount = 99; + expect(batches()).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1000); + expect(events()).toEqual([expect.objectContaining({ + event_name: "purchase", timestamp, properties: { amount: 42 }, + })]); }); - test("should track an event", () => { - vi.spyOn(base44.analytics, "track"); - - base44.analytics.track({ eventName: "test-event" }); - expect(sharedState?.isProcessing).toBe(true); - expect(base44.analytics.track).toHaveBeenCalledWith({ - eventName: "test-event", - }); + test("respects the configured batch size and interval", async () => { + for (let index = 0; index < 5; index++) client.analytics.track({ eventName: `event_${index}` }); + await vi.advanceTimersByTimeAsync(999); + expect(batches()).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1); + expect(events().map((event) => event.event_name)).toEqual(["event_0", "event_1"]); + client.analytics.track({ eventName: "event_5" }); + await vi.advanceTimersByTimeAsync(2000); + expect(batches().map((request) => JSON.parse(request.data).events.length)).toEqual([2, 2, 2]); + expect(events().map((event) => event.event_name)).toEqual(Array.from({ length: 6 }, (_, index) => `event_${index}`)); }); - test("should have no analytics side effects when disabled in client config", () => { - const storage = { - getItem: vi.fn(() => null), - setItem: vi.fn(), - }; - const addEventListener = vi.fn(); - vi.stubGlobal("localStorage", storage); - vi.stubGlobal("document", { referrer: "", visibilityState: "visible" }); - vi.stubGlobal("window", { - addEventListener, - removeEventListener: vi.fn(), - history: { replaceState: vi.fn() }, - localStorage: storage, - location: { origin: "https://example.com", pathname: "/", search: "" }, - }); - const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); - - const disabled = createClient({ - serverUrl, - appId, - analytics: { enabled: false }, - }); - disabled.analytics.track({ eventName: "should-not-track" }); - - expect(sharedState?.requestsQueue).toEqual([]); - expect(storage.setItem).not.toHaveBeenCalled(); - expect(setIntervalSpy).not.toHaveBeenCalled(); - expect(addEventListener).not.toHaveBeenCalled(); - - disabled.cleanup(); + test("drops overflow without replacing the first queued events", async () => { + config.maxQueueSize = 2; + for (const eventName of ["first", "second", "overflow"]) client.analytics.track({ eventName }); + await client.experiments.flush(); + expect(events().map((event) => event.event_name)).toEqual(["first", "second"]); }); - test("should clear the memoized session context on reset", () => { - expect(sharedState?.sessionContext).toEqual({ user_id: "test-user-id" }); - - resetAnalyticsSessionContext(); - - // Called on every identity change. Without it, a visitor who loads - // anonymously and then logs in keeps reporting the pre-login identity. - expect(sharedState?.sessionContext).toBeNull(); + test("ordinary Analytics failures remain best effort without new retries", async () => { + adapter.mockRejectedValue(new Error("offline")); + client.analytics.track({ eventName: "purchase" }); + await client.experiments.flush(); + await vi.advanceTimersByTimeAsync(10000); + expect(batches()).toHaveLength(1); }); - test("should not restore the pre-reset identity when a lookup settles late", async () => { - resetAnalyticsSessionContext(); - - let resolveMe: (user: User) => void; - vi.spyOn(base44.auth, "me").mockReturnValue( - new Promise((resolve) => { - resolveMe = resolve; - }) - ); - - // Flushing this event resolves the session context, which suspends on me(). - base44.analytics.track({ eventName: "anonymous-event" }); - await vi.waitFor(() => expect(base44.auth.me).toHaveBeenCalled()); - - // The identity changes while that lookup is still in flight. - resetAnalyticsSessionContext(); - resolveMe!({ id: "anonymous-user" } as User); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // The anonymous identity must not be written back: doing so pins user_id - // for the rest of the session, which is the bug the reset exists to prevent. - expect(sharedState?.sessionContext).toBeNull(); + test("disabled clients do not track or register automatic browser work", async () => { + const addListener = vi.mocked(window.addEventListener); + addListener.mockClear(); + const disabled = makeClient({ analytics: { enabled: false } }); + disabled.analytics.track({ eventName: "not_tracked" }); + await disabled.experiments.flush(); + expect(adapter).not.toHaveBeenCalled(); + expect(addListener).not.toHaveBeenCalled(); }); - 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(); + test("anonymous events skip identity lookup and retain null Authorization after login", async () => { + client.analytics.track({ eventName: "anonymous" }); + client.setToken("token-a"); + client.analytics.track({ eventName: "authenticated" }); + await client.experiments.flush(); + const requests = batches(); + expect(requests).toHaveLength(2); + expect(requests[0].headers.get("Authorization")).toBeNull(); + expect(requests[1].headers.get("Authorization")).toBe("Bearer token-a"); + expect(events()).toEqual([ + expect.objectContaining({ event_name: "anonymous", user_id: null }), + expect.objectContaining({ event_name: "authenticated", user_id: "user-a" }), + ]); + expect(adapter.mock.calls.filter(([request]) => request.url.endsWith("/entities/User/me"))).toHaveLength(1); }); - test("should not resolve an identity when no token is set", async () => { - resetAnalyticsSessionContext(); - - const anonymous = createClient({ serverUrl, appId }); - const me = vi.spyOn(anonymous.auth, "me"); - - anonymous.analytics.track({ eventName: "public-page-event" }); - await vi.waitFor(() => expect(sharedState?.requestsQueue.length).toBe(0)); - - // The whole point: on a public page `me()` can only answer 401, and the - // browser logs that to the console before any handler here sees it. The - // event still flushes -- anonymous events already reported user_id: null. - expect(me).not.toHaveBeenCalled(); - - anonymous.cleanup(); + test("different browser clients cannot share credentials, identities, or queued events", async () => { + const a = makeClient({ appId: "app-a", token: "token-a" }); + const b = makeClient({ appId: "app-b", token: "token-b" }); + a.analytics.track({ eventName: "goal_a" }); + b.analytics.track({ eventName: "goal_b" }); + await a.experiments.flush(); + expect(batches()).toHaveLength(1); + expect(batches()[0].url).toBe("/apps/app-a/analytics/track/batch"); + expect(events()[0]).toMatchObject({ event_name: "goal_a", user_id: "user-a" }); + a.cleanup(); + await b.experiments.flush(); + expect(batches()[1].url).toBe("/apps/app-b/analytics/track/batch"); + expect(batches()[1].headers.get("Authorization")).toBe("Bearer token-b"); + expect(events()[1]).toMatchObject({ event_name: "goal_b", user_id: "user-b" }); }); - test("should resolve an identity once a token is set", async () => { - resetAnalyticsSessionContext(); - - const anonymous = createClient({ serverUrl, appId }); - const me = vi - .spyOn(anonymous.auth, "me") - .mockResolvedValue({ id: "user-1" } as User); - - // A visitor who logs in mid-session must start reporting their identity, so - // the skip above must not be memoized. - anonymous.auth.setToken("token-acquired-after-login", false); - anonymous.analytics.track({ eventName: "post-login-event" }); - - await vi.waitFor(() => expect(me).toHaveBeenCalled()); - - anonymous.cleanup(); + test("a late old identity lookup cannot replace the new token's cached goal identity", async () => { + const user = makeClient({ token: "token-a" }); + let releaseOld: () => void; + const normalAdapter = adapter.getMockImplementation()!; + adapter.mockImplementation((request) => request.url.endsWith("/entities/User/me") && + request.headers.get("Authorization") === "Bearer token-a" + ? new Promise((resolve) => { releaseOld = async () => resolve(await normalAdapter(request)); }) + : normalAdapter(request)); + user.analytics.track({ eventName: "old_user" }); + await vi.advanceTimersByTimeAsync(0); + user.setToken("token-b"); + user.analytics.track({ eventName: "new_user" }); + await vi.advanceTimersByTimeAsync(0); + releaseOld!(); + await user.experiments.flush(); + user.analytics.track({ eventName: "new_user_again" }); + await user.experiments.flush(); + expect(events()).toEqual(expect.arrayContaining([ + expect.objectContaining({ event_name: "old_user", user_id: "user-a" }), + expect.objectContaining({ event_name: "new_user", user_id: "user-b" }), + expect.objectContaining({ event_name: "new_user_again", user_id: "user-b" }), + ])); + expect(adapter.mock.calls.filter(([request]) => request.url.endsWith("/entities/User/me"))).toHaveLength(2); }); - test("should report token presence across identity changes", () => { - const client = createClient({ serverUrl, appId }); - // `hasToken` lives on the internal auth surface only; the public client - // narrows to AuthModule, so reach past the narrowing deliberately here. - const auth = client.auth as InternalAuthModule; - - expect(auth.hasToken()).toBe(false); - - auth.setToken("some-token", false); - expect(auth.hasToken()).toBe(true); - - auth.logout(); - expect(auth.hasToken()).toBe(false); - - client.cleanup(); + test("hidden documents immediately drain their pending events", async () => { + client.analytics.track({ eventName: "purchase" }); + Object.assign(document, { visibilityState: "hidden" }); + const listener = vi.mocked(window.addEventListener).mock.calls.find(([name]) => name === "visibilitychange")![1] as () => void; + listener(); + await vi.advanceTimersByTimeAsync(0); + expect(events().map((event) => event.event_name)).toEqual(["purchase"]); }); - test("should track multiple events", async () => { - vi.useFakeTimers(); + test("multiple browser clients retain a single initialization and heartbeat stream", async () => { + const shared = getSharedInstance("analytics", () => ({ config: {} })); + Object.assign(shared, { wasInitializationTracked: false }); + config.heartBeatInterval = 2000; + const a = makeClient(); + const b = makeClient(); + await vi.advanceTimersByTimeAsync(3000); + expect(events().filter((event) => event.event_name === "__initialization_event__")).toHaveLength(1); + expect(events().filter((event) => event.event_name === "__user_heartbeat_event__")).toHaveLength(1); + a.cleanup(); + b.cleanup(); + }); - for (let i = 0; i < 5; i++) { - base44.analytics.track({ eventName: `test-event ${i}` }); - } + test("a hanging identity lookup cannot keep a Worker flush pending beyond five seconds", async () => { + vi.stubGlobal("window", undefined); + const worker = makeClient({ token: "token-a" }); + adapter.mockReturnValue(new Promise(() => {})); + worker.analytics.track({ eventName: "purchase" }); + const settled = vi.fn(); + const delivery = worker.experiments.flush().then(settled); + await vi.advanceTimersByTimeAsync(4999); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await delivery; + expect(settled).toHaveBeenCalledOnce(); + await worker.experiments.flush(); + expect(batches()).toHaveLength(0); + }); - expect(sharedState?.isProcessing).toBe(true); - expect(sharedState?.requestsQueue.length).toBe(4); - await vi.advanceTimersByTimeAsync(1000); - expect(sharedState?.requestsQueue.length).toBe(2); - // add another event while processing to mix things up - base44.analytics.track({ eventName: `test-event 5` }); + test("server clients never start automatic heartbeat timers", () => { + vi.stubGlobal("window", undefined); + const setInterval = vi.spyOn(globalThis, "setInterval"); + makeClient({ token: "token-a" }); + expect(setInterval).not.toHaveBeenCalled(); + }); - await vi.advanceTimersByTimeAsync(1000); - expect(sharedState?.requestsQueue.length).toBe(1); - await vi.advanceTimersByTimeAsync(1000); - expect(sharedState?.requestsQueue.length).toBe(0); - await vi.advanceTimersByTimeAsync(1000); - expect(sharedState?.isProcessing).toBe(false); + test("ready exposures are delivered while an unrelated goal's old identity lookup hangs", async () => { + vi.stubGlobal("window", undefined); + const context = { config: { v: 1 as const, app_id: "app", flags: [], experiments: [{ + id: "exp", flag_key: "checkout", run_version: 1, assign_by: "visitor" as const, traffic_allocation: 100, + variants: [{ key: "control", value: false, weight: 0 }, { key: "treatment", value: true, weight: 100 }], + }] }, identity: { visitorId: "visitor", userId: "user-a", status: "authenticated" as const } }; + const worker = makeClient({ token: "token-a", experiments: context }); + const normalAdapter = adapter.getMockImplementation()!; + adapter.mockImplementation((request) => request.url.endsWith("/entities/User/me") + ? new Promise(() => {}) : normalAdapter(request)); + worker.analytics.track({ eventName: "blocked_goal" }); + worker.auth.logout(); + expect(worker.experiments.isEnabled("checkout")).toBe(true); + const delivery = worker.experiments.flush(); + await vi.advanceTimersByTimeAsync(1); + expect(events()).toEqual([expect.objectContaining({ event_name: "__experiment_exposure__", session_id: "visitor" })]); + expect(batches()[0].headers.get("Authorization")).toBeNull(); + await vi.advanceTimersByTimeAsync(4999); + await delivery; + expect(batches()).toHaveLength(1); }); }); diff --git a/tests/unit/auth-identity.test.ts b/tests/unit/auth-identity.test.ts new file mode 100644 index 0000000..15b8399 --- /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 c79df86..f2dcae3 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -177,13 +177,46 @@ describe('Auth Module', () => { expect(scope.isDone()).toBe(true); }); - test('setToken() clears the analytics session context', () => { + test('setToken() attributes subsequent browser analytics to the new user', async () => { + vi.stubGlobal('window', { + location: { origin: appBaseUrl, pathname: '/', search: '' }, + localStorage: { getItem: () => null }, + addEventListener: vi.fn(), removeEventListener: vi.fn(), + }); const analyticsState = getSharedInstance('analytics', () => ({})); - analyticsState.sessionContext = { user_id: 'anonymous-user', session_id: 's1' }; - - base44.auth.setToken('new-access-token', false); - - expect(analyticsState.sessionContext).toBeNull(); + const wasInitialized = analyticsState.wasInitializationTracked; + analyticsState.wasInitializationTracked = true; + let browserClient; + try { + browserClient = createClient({ serverUrl, appId, appBaseUrl }); + scope.get(`/api/apps/${appId}/entities/User/me`) + .matchHeader('authorization', 'Bearer old-access-token') + .reply(200, { id: 'old-user' }); + scope.post(`/api/apps/${appId}/analytics/track/batch`, (body) => + body.events.length === 1 && body.events[0].event_name === 'before_login' && body.events[0].user_id === 'old-user') + .matchHeader('authorization', 'Bearer old-access-token') + .reply(200, { accepted: 1 }); + browserClient.auth.setToken('old-access-token', false); + browserClient.analytics.track({ eventName: 'before_login' }); + await browserClient.experiments.flush(); + + scope.get(`/api/apps/${appId}/entities/User/me`) + .matchHeader('authorization', 'Bearer new-access-token') + .reply(200, { id: 'new-user' }); + scope.post(`/api/apps/${appId}/analytics/track/batch`, (body) => + body.events.length === 1 && body.events[0].event_name === 'after_login' && body.events[0].user_id === 'new-user') + .matchHeader('authorization', 'Bearer new-access-token') + .reply(200, { accepted: 1 }); + browserClient.auth.setToken('new-access-token', false); + browserClient.analytics.track({ eventName: 'after_login' }); + await browserClient.experiments.flush(); + + expect(scope.isDone()).toBe(true); + } finally { + browserClient?.cleanup(); + analyticsState.wasInitializationTracked = wasInitialized; + vi.unstubAllGlobals(); + } }); }); @@ -967,4 +1000,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 0000000..098b673 --- /dev/null +++ b/tests/unit/experiment-exposures.test.ts @@ -0,0 +1,270 @@ +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("queues exposure until flush with the runtime visitor and no client user claim", async () => { + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + expect(request).not.toHaveBeenCalled(); + await tracker.flush(); + + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith({ + method: "POST", + url: `/apps/${appId}/analytics/track/batch`, + headers: { Authorization: "Bearer user-1-token" }, + timeout: expect.any(Number), + signal: expect.any(AbortSignal), + 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 and batches distinct assignments only within the same identity", async () => { + 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" }); + await tracker.flush(); + expect(request).toHaveBeenCalledTimes(3); + expect(request.mock.calls.map(([config]) => config.data.events.length)).toEqual([3, 1, 1]); + }); + + 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); + const delivery = tracker.flush(); + client.defaults.headers.common.Authorization = "Bearer replacement"; + await vi.advanceTimersByTimeAsync(100); + await delivery; + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1][0]).toMatchObject({ + data: request.mock.calls[0][0].data, headers: request.mock.calls[0][0].headers, + }); + expect(request.mock.calls[0][0].data.events[0].event_id).toMatch(/^[0-9a-f-]{36}$/); + vi.useRealTimers(); + }); + + test("captures credential and visitor partitions before a mixed batch is flushed", async () => { + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + client.defaults.headers.common.Authorization = "Bearer token-2"; + tracker.track({ ...assignment, experiment_id: "experiment-2" }, identity); + tracker.track(assignment, { ...identity, visitorId: "visitor-2" }); + tracker.track(assignment, { visitorId: "visitor-2", userId: null }); + await tracker.flush(); + expect(request.mock.calls.map(([config]) => ({ + authorization: config.headers.Authorization, visitor: config.data.events[0].session_id, + count: config.data.events.length, + }))).toEqual([ + { authorization: "Bearer user-1-token", visitor: "runtime-visitor", count: 1 }, + { authorization: "Bearer token-2", visitor: "runtime-visitor", count: 1 }, + { authorization: "Bearer token-2", visitor: "visitor-2", count: 1 }, + { authorization: null, visitor: "visitor-2", count: 1 }, + ]); + }); + + test.each([0, 1])("backend flush settles accepted %s without resending on later reads", async (accepted) => { + vi.stubGlobal("window", undefined); + request.mockResolvedValue({ accepted }); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true, source: "backend", pageUrl: "/checkout" }); + tracker.track(assignment, identity); + await expect(tracker.flush()).resolves.toBeUndefined(); + tracker.track(assignment, identity); + await tracker.flush(); + expect(request).toHaveBeenCalledOnce(); + expect(request.mock.calls[0][0].data.events[0]).toMatchObject({ properties: { source: "backend" }, page_url: "/checkout" }); + }); + + test.each([400, 401, 403, 429])("terminal HTTP %s does not reject or resend", async (status) => { + request.mockRejectedValue({ status }); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + await expect(tracker.flush()).resolves.toBeUndefined(); + tracker.track(assignment, identity); + await tracker.flush(); + expect(request).toHaveBeenCalledOnce(); + }); + + test.each([new Error("offline"), { response: { status: 503 } }])("exhausted transient delivery settles without changing the event or credentials", async (error) => { + vi.useFakeTimers(); + request.mockRejectedValue(error); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true, source: "backend" }); + tracker.track(assignment, identity); + const settled = expect(tracker.flush()).resolves.toBeUndefined(); + client.defaults.headers.common.Authorization = "Bearer replacement"; + await vi.advanceTimersByTimeAsync(600); + await settled; + expect(request).toHaveBeenCalledTimes(3); + const initial = request.mock.calls[0][0]; + expect(request.mock.calls[1][0]).toMatchObject({ data: initial.data, headers: initial.headers }); + expect(request.mock.calls[2][0]).toMatchObject({ data: initial.data, headers: initial.headers }); + tracker.track(assignment, identity); + await tracker.flush(); + expect(request).toHaveBeenCalledTimes(3); + }); + + test("a stalled transport is cancelled at the total budget without failing the Worker", async () => { + vi.useFakeTimers(); + vi.stubGlobal("window", undefined); + request.mockReturnValue(new Promise(() => {})); + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true, source: "backend" }); + tracker.track(assignment, identity); + const settled = vi.fn(); + const delivery = tracker.flush().then(settled); + await vi.advanceTimersByTimeAsync(4999); + expect(settled).not.toHaveBeenCalled(); + const signal = request.mock.calls[0][0].signal; + expect(signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await delivery; + expect(settled).toHaveBeenCalledOnce(); + expect(signal.aborted).toBe(true); + tracker.track(assignment, identity); + await tracker.flush(); + expect(request).toHaveBeenCalledOnce(); + }); + + 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 tracker.flush(); + 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", async () => { + delete client.defaults.headers.common.Authorization; + const tracker = createExposureTracker({ axiosClient: client, appId, enabled: true }); + tracker.track(assignment, identity); + await tracker.flush(); + 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 0000000..7ccf86e --- /dev/null +++ b/tests/unit/experiments-auth.test.ts @@ -0,0 +1,105 @@ +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, + __B44_EXPERIMENTS_BOOTSTRAP__: { config: { app_id: "app-id" } }, + localStorage: { setItem: vi.fn(), removeItem: vi.fn() }, + location: { href: "https://example.test/dashboard" }, + }); + vi.stubGlobal("document", {}); + const bridge = createExperimentsModule({ appId: "app-id", 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 0000000..0996ecc --- /dev/null +++ b/tests/unit/experiments-client.test.ts @@ -0,0 +1,274 @@ +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 { createExperimentsRuntime } from "../../src/modules/experiments-context.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.each(["other-app", "unidentified"])("does not adopt an %s legacy runtime or send its exposures", async (owner) => { + vi.useFakeTimers(); + const published: ExperimentsContext = { + ...context, identity: { visitorId: "visitor-a", userId: null, status: "anonymous" }, + config: { ...context.config, experiments: [{ ...context.config.experiments[0], assign_by: "visitor" }] }, + }; + const runtime = createExperimentsRuntime(published); + vi.stubGlobal("window", { + __B44_EXPERIMENTS__: runtime, + __B44_EXPERIMENTS_BOOTSTRAP__: owner === "other-app" ? published : undefined, + location: { origin: "https://app.example", pathname: "/", search: "" }, + localStorage: { getItem: () => null, setItem: () => {} }, + addEventListener: vi.fn(), removeEventListener: vi.fn(), + }); + vi.stubGlobal("document", { referrer: "" }); + const adapter = captureAnalytics(); + const client = createClient({ appId: "second-app" }); + try { + expect(client.experiments.getSnapshot()).toEqual({ flags: {}, isLoading: false }); + expect(client.experiments.isEnabled("checkout")).toBe(false); + expect(client.experiments.isEnabled("checkout", true)).toBe(true); + await client.experiments.ready(); + await client.experiments.flush(); + await vi.advanceTimersByTimeAsync(1500); + expect(adapter).not.toHaveBeenCalled(); + expect(runtime.flags.checkout).toBe(true); + expect(runtime.userId).toBeNull(); + } finally { + client.cleanup(); + } + }); + + test("three distinct feature reads and a goal share one request-scoped Analytics batch", async () => { + const create = axios.create.bind(axios); + const adapter = vi.fn(async (config) => ({ + data: config.url.endsWith("/entities/User/me") ? { id: "user" } : { accepted: 4 }, + status: 200, statusText: "OK", headers: {}, config, + })); + vi.spyOn(axios, "create").mockImplementation((options) => { + const api = create(options); + api.defaults.adapter = adapter; + return api; + }); + const requestContext = { ...context, config: { ...context.config, + experiments: ["checkout", "pricing", "headline"].map((flag_key) => ({ + ...context.config.experiments[0], id: `experiment_${flag_key}`, flag_key, + })), + } }; + const client = createClientFromRequest(new Request("https://app.example/checkout", { headers: { + "Base44-App-Id": "app", Authorization: "Bearer user-token", + "Base44-Experiments-Context": encode(requestContext), + } })); + for (const flag of ["checkout", "pricing", "headline"]) expect(client.experiments.isEnabled(flag)).toBe(true); + client.analytics.track({ eventName: "purchase", properties: { amount: 42 } }); + await client.experiments.flush(); + const batches = adapter.mock.calls.map(([request]) => request) + .filter((request) => request.url.endsWith("/analytics/track/batch")); + expect(batches).toHaveLength(1); + expect(batches[0].headers.get("Authorization")).toBe("Bearer user-token"); + const events = JSON.parse(batches[0].data).events; + expect(events.map((event) => event.event_name)).toEqual([ + "__experiment_exposure__", "__experiment_exposure__", "__experiment_exposure__", "purchase", + ]); + expect(new Set(events.slice(0, 3).map((event) => event.event_id)).size).toBe(3); + expect(events.every((event) => event.session_id === "visitor")).toBe(true); + client.cleanup(); + }); + + test("a lost mixed-batch acknowledgement retries only exposures with their original time, ID and credential", async () => { + vi.useFakeTimers(); + const create = axios.create.bind(axios); + let batchAttempt = 0; + const adapter = vi.fn(async (config) => { + if (config.url.endsWith("/analytics/track/batch") && batchAttempt++ === 0) throw new Error("lost acknowledgement"); + return { data: config.url.endsWith("/entities/User/me") ? { id: "user" } : { accepted: 0 }, + status: 200, statusText: "OK", headers: {}, config }; + }); + vi.spyOn(axios, "create").mockImplementation((options) => { + const api = create(options); + api.defaults.adapter = adapter; + return api; + }); + const client = createClientFromRequest(new Request("https://app.example/checkout", { headers: { + "Base44-App-Id": "app", Authorization: "Bearer original-token", "Base44-Experiments-Context": encode(context), + } })); + const exposureTime = new Date().toISOString(); + client.experiments.isEnabled("checkout"); + await vi.advanceTimersByTimeAsync(25); + const goalTime = new Date().toISOString(); + client.analytics.track({ eventName: "purchase" }); + const delivery = client.experiments.flush(); + client.setToken("replacement-token"); + await vi.advanceTimersByTimeAsync(100); + await delivery; + const batches = adapter.mock.calls.map(([request]) => request) + .filter((request) => request.url.endsWith("/analytics/track/batch")); + expect(batches).toHaveLength(2); + expect(batches.map((request) => request.headers.get("Authorization"))).toEqual(["Bearer original-token", "Bearer original-token"]); + const first = JSON.parse(batches[0].data).events; + expect(JSON.parse(batches[1].data).events).toEqual([first[0]]); + expect(first.map((event) => event.timestamp)).toEqual([exposureTime, goalTime]); + expect(first[0].event_id).toMatch(/^[0-9a-f-]{36}$/); + expect(first[1]).not.toHaveProperty("event_id"); + await client.experiments.flush(); + expect(adapter.mock.calls.filter(([request]) => request.url.endsWith("/analytics/track/batch"))).toHaveLength(2); + client.cleanup(); + }); + + 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 0000000..ace9166 --- /dev/null +++ b/tests/unit/experiments-context.test.ts @@ -0,0 +1,79 @@ +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({ + appId: "app", + 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({ + appId: "app", + 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({ + appId: "app", + 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 0000000..90899aa --- /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 0000000..c84a339 --- /dev/null +++ b/tests/unit/experiments.test.ts @@ -0,0 +1,184 @@ +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 }; + }, + }; + const page = { __B44_EXPERIMENTS__: runtime, __B44_EXPERIMENTS_BOOTSTRAP__: { config: { app_id: "app" } } }; + vi.stubGlobal("window", page); + 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({ + appId: "app", + 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, page, 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", b.page); + expect(b.module.isEnabled("checkout")).toBe(false); + expect(b.runtime.userId).toBeNull(); + }); + + test("auth updates cannot adopt a replacement runtime owned by another app", () => { + const b = setup(); + expect(b.module.isEnabled("checkout")).toBe(false); + b.trackExposure.mockClear(); + b.page.__B44_EXPERIMENTS_BOOTSTRAP__.config.app_id = "other-app"; + const setUser = vi.spyOn(b.runtime, "setUser"); + + b.onAuthStateChange({ status: "authenticated", userId: "user-b" }); + expect(b.module.getSnapshot()).toEqual({ flags: {}, isLoading: false }); + b.onAuthStateChange({ status: "anonymous" }); + expect(b.module.isEnabled("checkout", true)).toBe(true); + expect(setUser).not.toHaveBeenCalled(); + expect(b.trackExposure).not.toHaveBeenCalled(); + }); + + 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); + }); +});