Skip to content

Commit 56f6356

Browse files
mikepitreclaudewobsoriano
authored
test(expo): add behavioral native client sync suite (#9954)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: wobsoriano <sorianorobertc@gmail.com>
1 parent 7f4b9a8 commit 56f6356

9 files changed

Lines changed: 1276 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import type { Clerk } from '@clerk/clerk-js';
2+
import { act, render, waitFor } from '@testing-library/react';
3+
import { vi } from 'vitest';
4+
5+
import { CLERK_CLIENT_JWT_KEY } from '../../../constants';
6+
import type { NativeClientSnapshot } from '../../../hooks/useNativeClientEvents';
7+
import type { ClerkExpoNativeModule } from '../../../utils/native-module';
8+
import { FakePersistentTokenCache, setActiveEnvironment } from './environment';
9+
import { FakeClerk } from './fakeClerk';
10+
import { FakeNativeClerk } from './fakeNativeClerk';
11+
import { FakeClerkServer } from './fakeServer';
12+
import type { NativeSyncHarness, RenderWithNativeSync } from './harness';
13+
14+
const nativeClientChangedEvent = 'clerkNativeClientChanged';
15+
16+
function serializeClient(native: FakeNativeClerk): string {
17+
return JSON.stringify(native.client);
18+
}
19+
20+
function createCurrentNativeModule(native: FakeNativeClerk, calls: string[]): ClerkExpoNativeModule {
21+
const listeners = new Set<(snapshot: NativeClientSnapshot) => void>();
22+
23+
const configure = async (bearerToken: string | null) => {
24+
await native.waitForStartup();
25+
if (bearerToken && (bearerToken !== native.token || !native.client)) {
26+
await native.adoptToken(bearerToken);
27+
} else {
28+
await native.refreshClient();
29+
}
30+
native.isConfigured = true;
31+
};
32+
33+
native.onChange((change, origin) => {
34+
const snapshot: NativeClientSnapshot = {
35+
changed: { client: change.client, deviceToken: change.deviceToken },
36+
deviceToken: native.token,
37+
...(origin.kind === 'js' && origin.tag ? { sourceId: origin.tag } : {}),
38+
};
39+
for (const listener of [...listeners]) {
40+
listener(snapshot);
41+
}
42+
});
43+
44+
return {
45+
addListener: (eventName: string, listener?: (...args: unknown[]) => void) => {
46+
calls.push('addListener');
47+
if (eventName !== nativeClientChangedEvent || !listener) {
48+
return { remove: () => undefined };
49+
}
50+
const typedListener = listener as (snapshot: NativeClientSnapshot) => void;
51+
listeners.add(typedListener);
52+
return { remove: () => listeners.delete(typedListener) };
53+
},
54+
configure: (_publishableKey: string, bearerToken: string | null) => {
55+
calls.push('configure');
56+
return configure(bearerToken);
57+
},
58+
configureWithOptions: (_publishableKey: string, options: { bearerToken: string | null }) => {
59+
calls.push('configureWithOptions');
60+
return configure(options.bearerToken);
61+
},
62+
getClientToken: () => {
63+
calls.push('getClientToken');
64+
return Promise.resolve(native.token);
65+
},
66+
syncClientStateFromJs: async (
67+
deviceToken: string | null,
68+
sourceId: string | null,
69+
didChangeClient: boolean,
70+
didChangeDeviceToken: boolean,
71+
) => {
72+
calls.push('syncClientStateFromJs');
73+
if (!native.isConfigured) {
74+
return;
75+
}
76+
const previousToken = native.token;
77+
const previousClient = serializeClient(native);
78+
const changeSincePrevious = () => ({
79+
client: serializeClient(native) !== previousClient,
80+
deviceToken: native.token !== previousToken,
81+
});
82+
try {
83+
if (didChangeDeviceToken && deviceToken && deviceToken !== native.token) {
84+
await native.adoptToken(deviceToken);
85+
} else if (didChangeClient || didChangeDeviceToken) {
86+
await native.refreshClient();
87+
}
88+
} catch (error) {
89+
const change = changeSincePrevious();
90+
if (change.client || change.deviceToken) {
91+
native.emitChange(change, { kind: 'native' });
92+
}
93+
throw error;
94+
}
95+
native.emitChange(changeSincePrevious(), { kind: 'js', tag: sourceId });
96+
},
97+
};
98+
}
99+
100+
async function flushMacrotasks(count: number): Promise<void> {
101+
for (let i = 0; i < count; i++) {
102+
await new Promise(resolve => setTimeout(resolve, 0));
103+
}
104+
}
105+
106+
export const renderWithNativeSync: RenderWithNativeSync = async (scenario = {}) => {
107+
const server = scenario.server ?? new FakeClerkServer();
108+
const native = new FakeNativeClerk(server, scenario.nativeDeviceToken ?? null);
109+
const js = new FakeClerk(server);
110+
const persistentTokenCache =
111+
scenario.persistentTokenCache || scenario.jsDeviceToken !== undefined
112+
? new FakePersistentTokenCache(scenario.jsDeviceToken ? { [CLERK_CLIENT_JWT_KEY]: scenario.jsDeviceToken } : {})
113+
: null;
114+
const releaseNativeStartup = scenario.delayNativeStartup ? native.delayStartup() : () => undefined;
115+
const nativeModuleCalls: string[] = [];
116+
117+
// Fresh engine modules per scenario: the native module is resolved at import time and the
118+
// coordinator, Clerk instance and default in-memory token cache are module singletons.
119+
vi.resetModules();
120+
const { createClerkInstance } = await import('../../singleton/createClerkInstance');
121+
setActiveEnvironment({
122+
getClerkInstance: createClerkInstance(function () {
123+
return js;
124+
} as unknown as typeof Clerk) as unknown as (options?: unknown) => FakeClerk,
125+
nativeModule: scenario.nativeModule === 'absent' ? null : createCurrentNativeModule(native, nativeModuleCalls),
126+
});
127+
const { ClerkProvider } = await import('../../ClerkProvider');
128+
129+
const view = render(
130+
<ClerkProvider
131+
publishableKey='pk_test_native_client_sync_behavior'
132+
tokenCache={persistentTokenCache ?? undefined}
133+
__experimental_disableNativeClientSync={scenario.disableNativeClientSync}
134+
/>,
135+
);
136+
137+
const settle = async () => {
138+
for (let round = 0; round < 8; round++) {
139+
await act(async () => {
140+
await flushMacrotasks(10);
141+
});
142+
}
143+
};
144+
145+
await waitFor(() => {
146+
if (!js.loaded) {
147+
throw new Error('Clerk has not loaded yet');
148+
}
149+
});
150+
await settle();
151+
152+
const harness: NativeSyncHarness = {
153+
server,
154+
native,
155+
js,
156+
persistentTokenCache,
157+
jsDeviceToken: () => js.currentDeviceToken(),
158+
nativeModuleCalls,
159+
run: async action => {
160+
let result!: Awaited<ReturnType<typeof action>>;
161+
await act(async () => {
162+
result = await action();
163+
});
164+
return result;
165+
},
166+
settle,
167+
releaseNativeStartup,
168+
unmount: () => view.unmount(),
169+
};
170+
return harness;
171+
};
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { type ReactNode, useEffect } from 'react';
2+
3+
import type { TokenCache } from '../../../cache/types';
4+
import type { FakeClerk } from './fakeClerk';
5+
6+
type BehaviorEnvironment = {
7+
getClerkInstance: ((options?: unknown) => FakeClerk) | null;
8+
nativeModule: unknown;
9+
};
10+
11+
// Lives on globalThis so module-level mock factories see the same state after `vi.resetModules()`.
12+
const registryKey = Symbol.for('@clerk/expo/nativeClientSyncBehavior');
13+
const globalRegistry = globalThis as typeof globalThis & { [registryKey]?: BehaviorEnvironment };
14+
15+
export function activeEnvironment(): BehaviorEnvironment {
16+
globalRegistry[registryKey] ??= { getClerkInstance: null, nativeModule: null };
17+
return globalRegistry[registryKey];
18+
}
19+
20+
export function setActiveEnvironment(environment: BehaviorEnvironment): void {
21+
globalRegistry[registryKey] = environment;
22+
}
23+
24+
export function getClerkInstanceForActiveEnvironment(options?: unknown): FakeClerk {
25+
const { getClerkInstance } = activeEnvironment();
26+
if (!getClerkInstance) {
27+
throw new Error('No native client sync behavior environment is active.');
28+
}
29+
return getClerkInstance(options);
30+
}
31+
32+
/** Stands in for `InternalClerkProvider`, which loads the Clerk instance after mounting. */
33+
export function LoadingClerkProvider({ children, Clerk }: { children?: ReactNode; Clerk?: FakeClerk | null }) {
34+
useEffect(() => {
35+
void Clerk?.load();
36+
}, [Clerk]);
37+
return <>{children}</>;
38+
}
39+
40+
export class FakePersistentTokenCache implements TokenCache {
41+
#values = new Map<string, string>();
42+
43+
constructor(initial: Record<string, string> = {}) {
44+
for (const [key, value] of Object.entries(initial)) {
45+
this.#values.set(key, value);
46+
}
47+
}
48+
49+
read(key: string): string | null {
50+
return this.#values.get(key) ?? null;
51+
}
52+
53+
getToken = (key: string): Promise<string | undefined | null> => Promise.resolve(this.#values.get(key) ?? null);
54+
55+
saveToken = (key: string, token: string): Promise<void> => {
56+
this.#values.set(key, token);
57+
return Promise.resolve();
58+
};
59+
60+
clearToken = (key: string): void => {
61+
this.#values.delete(key);
62+
};
63+
}

0 commit comments

Comments
 (0)