Skip to content

Commit 55202d3

Browse files
committed
fix(experiments): scope legacy runtime adoption to the client app
1 parent c69ae8f commit 55202d3

7 files changed

Lines changed: 71 additions & 9 deletions

File tree

‎src/client.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export function createClient(config: CreateClientConfig): Base44Client {
183183
pageUrl: experimentsContext?.pageUrl,
184184
});
185185
const experiments = createExperimentsModule({
186+
appId,
186187
getAuth: () => userAuthModule,
187188
trackExposure: exposureTracker.track,
188189
flushExposures: exposureTracker.flush,

‎src/modules/experiments-runtime.types.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ export interface ExperimentsRuntime {
1818
}
1919

2020
/** @internal */
21-
export function getExperimentsRuntime(): ExperimentsRuntime | undefined {
21+
export function getExperimentsRuntime(appId?: string): ExperimentsRuntime | undefined {
2222
if (typeof window === "undefined" || typeof document === "undefined") return;
23-
return (window as Window & { __B44_EXPERIMENTS__?: ExperimentsRuntime })
24-
.__B44_EXPERIMENTS__;
23+
const page = window as Window & {
24+
__B44_EXPERIMENTS__?: ExperimentsRuntime;
25+
__B44_EXPERIMENTS_BOOTSTRAP__?: { config: { app_id: string } };
26+
};
27+
// The legacy evaluator has no app ID; its companion bootstrap identifies its owner.
28+
if (appId !== undefined && page.__B44_EXPERIMENTS_BOOTSTRAP__?.config?.app_id !== appId) return;
29+
return page.__B44_EXPERIMENTS__;
2530
}

‎src/modules/experiments.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ const EMPTY: ExperimentsSnapshot = Object.freeze({
1818

1919
/** @internal */
2020
export function createExperimentsModule({
21+
appId,
2122
getAuth,
2223
trackExposure,
2324
flushExposures = async () => {},
2425
context,
2526
}: {
27+
appId: string;
2628
getAuth: () => InternalAuthModule;
2729
trackExposure: ReturnType<typeof createExposureTracker>["track"];
2830
flushExposures?: () => Promise<void>;
@@ -93,7 +95,7 @@ export function createExperimentsModule({
9395
function activate() {
9496
if (disposed) return;
9597
active = true;
96-
if (!context) runtime = getExperimentsRuntime();
98+
if (!context) runtime = getExperimentsRuntime(appId);
9799
if (!runtime) {
98100
publish();
99101
return;
@@ -109,7 +111,7 @@ export function createExperimentsModule({
109111
if (disposed) return;
110112
state = next;
111113
if (!active) return;
112-
if (!context) runtime = getExperimentsRuntime();
114+
if (!context) runtime = getExperimentsRuntime(appId);
113115
applyIdentity();
114116
}
115117

‎tests/unit/experiments-auth.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ function setup(token?: string) {
2222
};
2323
vi.stubGlobal("window", {
2424
__B44_EXPERIMENTS__: runtime,
25+
__B44_EXPERIMENTS_BOOTSTRAP__: { config: { app_id: "app-id" } },
2526
localStorage: { setItem: vi.fn(), removeItem: vi.fn() },
2627
location: { href: "https://example.test/dashboard" },
2728
});
2829
vi.stubGlobal("document", {});
29-
const bridge = createExperimentsModule({ getAuth: () => auth, trackExposure: vi.fn() });
30+
const bridge = createExperimentsModule({ appId: "app-id", getAuth: () => auth, trackExposure: vi.fn() });
3031
const auth = createAuthModule(api, axios.create(), "app-id", {
3132
serverUrl: "https://example.test", appBaseUrl: "https://example.test",
3233
onAuthStateChange: bridge.onAuthStateChange,

‎tests/unit/experiments-client.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
33
import { createClient, createClientFromRequest } from "../../src/client.js";
44
import { resetAnalyticsSessionContext } from "../../src/modules/analytics.js";
55
import type { ExperimentsContext } from "../../src/modules/experiments-config.types.js";
6+
import { createExperimentsRuntime } from "../../src/modules/experiments-context.js";
67
import { getSharedInstance } from "../../src/utils/sharedInstance.js";
78

89
vi.mock("partysocket", () => ({ WebSocket: class {} }));
@@ -43,6 +44,38 @@ function captureAnalytics() {
4344
}
4445

4546
describe("client experiments integration", () => {
47+
test.each(["other-app", "unidentified"])("does not adopt an %s legacy runtime or send its exposures", async (owner) => {
48+
vi.useFakeTimers();
49+
const published: ExperimentsContext = {
50+
...context, identity: { visitorId: "visitor-a", userId: null, status: "anonymous" },
51+
config: { ...context.config, experiments: [{ ...context.config.experiments[0], assign_by: "visitor" }] },
52+
};
53+
const runtime = createExperimentsRuntime(published);
54+
vi.stubGlobal("window", {
55+
__B44_EXPERIMENTS__: runtime,
56+
__B44_EXPERIMENTS_BOOTSTRAP__: owner === "other-app" ? published : undefined,
57+
location: { origin: "https://app.example", pathname: "/", search: "" },
58+
localStorage: { getItem: () => null, setItem: () => {} },
59+
addEventListener: vi.fn(), removeEventListener: vi.fn(),
60+
});
61+
vi.stubGlobal("document", { referrer: "" });
62+
const adapter = captureAnalytics();
63+
const client = createClient({ appId: "second-app" });
64+
try {
65+
expect(client.experiments.getSnapshot()).toEqual({ flags: {}, isLoading: false });
66+
expect(client.experiments.isEnabled("checkout")).toBe(false);
67+
expect(client.experiments.isEnabled("checkout", true)).toBe(true);
68+
await client.experiments.ready();
69+
await client.experiments.flush();
70+
await vi.advanceTimersByTimeAsync(1500);
71+
expect(adapter).not.toHaveBeenCalled();
72+
expect(runtime.flags.checkout).toBe(true);
73+
expect(runtime.userId).toBeNull();
74+
} finally {
75+
client.cleanup();
76+
}
77+
});
78+
4679
test("three distinct feature reads and a goal share one request-scoped Analytics batch", async () => {
4780
const create = axios.create.bind(axios);
4881
const adapter = vi.fn(async (config) => ({

‎tests/unit/experiments-context.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe("platform experiments context", () => {
2828
const me = vi.fn();
2929
const track = vi.fn();
3030
const make = (value: ExperimentsContext) => createExperimentsModule({
31+
appId: "app",
3132
context: value, getAuth: () => ({ hasToken: () => true, me }) as unknown as InternalAuthModule, trackExposure: track,
3233
});
3334
const a = make(context);
@@ -49,6 +50,7 @@ describe("platform experiments context", () => {
4950
vi.stubGlobal("sessionStorage", { getItem: () => '{"checkout":true}' });
5051
const track = vi.fn();
5152
const sdk = createExperimentsModule({
53+
appId: "app",
5254
context: getBrowserExperimentsContext("app"),
5355
getAuth: () => ({ hasToken: () => true }) as InternalAuthModule, trackExposure: track,
5456
});
@@ -63,6 +65,7 @@ describe("platform experiments context", () => {
6365
test("preserves server-rendered flags while common browser auth is still pending", () => {
6466
const serverFlags = { checkout: false };
6567
const sdk = createExperimentsModule({
68+
appId: "app",
6669
context: { ...context, identity: { ...context.identity, userId: null, status: "pending" }, serverSnapshot: { flags: serverFlags, isLoading: false } },
6770
getAuth: () => ({ hasToken: () => true }) as InternalAuthModule, trackExposure: vi.fn(),
6871
});

‎tests/unit/experiments.test.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ function setup(hasToken = false) {
1414
this.flags = { checkout: id !== null };
1515
},
1616
};
17-
vi.stubGlobal("window", { __B44_EXPERIMENTS__: runtime });
17+
const page = { __B44_EXPERIMENTS__: runtime, __B44_EXPERIMENTS_BOOTSTRAP__: { config: { app_id: "app" } } };
18+
vi.stubGlobal("window", page);
1819
vi.stubGlobal("document", {});
1920
const requests: { resolve: (user: User) => void; reject: (error: Error) => void }[] = [];
2021
const me = vi.fn(() => new Promise<User>((resolve, reject) => requests.push({ resolve, reject })));
2122
const trackExposure = vi.fn();
2223
const bridge = createExperimentsModule({
24+
appId: "app",
2325
getAuth: () => ({ hasToken: () => hasToken, me }) as InternalAuthModule,
2426
trackExposure,
2527
});
@@ -28,7 +30,7 @@ function setup(hasToken = false) {
2830
if (state.status === "authenticated") requests[index]?.resolve({ id: state.userId } as User);
2931
else requests[index]?.reject(new Error("lookup failed"));
3032
};
31-
return { ...bridge, runtime, requests, settle, me, trackExposure };
33+
return { ...bridge, runtime, page, requests, settle, me, trackExposure };
3234
}
3335

3436
afterEach(() => vi.unstubAllGlobals());
@@ -139,11 +141,26 @@ describe("browser experiments", () => {
139141
b.module.getSnapshot();
140142
b.runtime.userId = "old-user";
141143
b.runtime.flags.checkout = true;
142-
vi.stubGlobal("window", { __B44_EXPERIMENTS__: b.runtime });
144+
vi.stubGlobal("window", b.page);
143145
expect(b.module.isEnabled("checkout")).toBe(false);
144146
expect(b.runtime.userId).toBeNull();
145147
});
146148

149+
test("auth updates cannot adopt a replacement runtime owned by another app", () => {
150+
const b = setup();
151+
expect(b.module.isEnabled("checkout")).toBe(false);
152+
b.trackExposure.mockClear();
153+
b.page.__B44_EXPERIMENTS_BOOTSTRAP__.config.app_id = "other-app";
154+
const setUser = vi.spyOn(b.runtime, "setUser");
155+
156+
b.onAuthStateChange({ status: "authenticated", userId: "user-b" });
157+
expect(b.module.getSnapshot()).toEqual({ flags: {}, isLoading: false });
158+
b.onAuthStateChange({ status: "anonymous" });
159+
expect(b.module.isEnabled("checkout", true)).toBe(true);
160+
expect(setUser).not.toHaveBeenCalled();
161+
expect(b.trackExposure).not.toHaveBeenCalled();
162+
});
163+
147164
test("cleanup and throwing subscribers cannot restore or interrupt identity", async () => {
148165
const b = setup(true);
149166
const listener = vi.fn(() => { throw new Error("render error"); });

0 commit comments

Comments
 (0)