Skip to content

Commit 44460ec

Browse files
mikepitreclaude
andcommitted
test(expo): add behavioral native client sync suite
Adds an engine-agnostic behavior suite for JS <-> native client sync, backed by a fake Frontend API, a fake native SDK, and a clerk-js stand-in, with a thin adapter that drives today's ClerkProvider engine. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 8ad6eb2 commit 44460ec

9 files changed

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

0 commit comments

Comments
 (0)