@hcaptcha/react-native-hcaptcha
Version:
hCaptcha Library for React Native (both Android and iOS)
534 lines (461 loc) • 15.3 kB
JavaScript
import React from 'react';
import { AppRegistry, Text, View } from 'react-native';
import { fireEvent, render } from '@testing-library/react-native';
import JourneyWrapper from '../journey/wrapper';
import {
__unsafeResetJourneyRuntime,
clearJourneyEvents,
disableJourneyConsumer,
drainJourneyEvents,
enableJourneyConsumer,
emitJourneyEvent,
initJourneyTracking,
peekJourneyEvents,
registerJourneyNavigationContainer,
resolveJourneyIdentifier,
} from '../journey';
describe('journey runtime', () => {
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
__unsafeResetJourneyRuntime();
});
it('installs the wrapper provider once and buffers events before the first consumer mounts', () => {
const wrapperSpy = jest.spyOn(AppRegistry, 'setWrapperComponentProvider');
initJourneyTracking();
initJourneyTracking();
emitJourneyEvent('click', 'View', { id: 'screen', ac: 'tap' });
expect(wrapperSpy).toHaveBeenCalledTimes(1);
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
k: 'click',
v: 'View',
m: { id: 'screen', ac: 'tap' },
}),
]);
});
it('composes wrapper providers registered after journey tracking initializes', () => {
const installedProviders = [];
jest.spyOn(AppRegistry, 'setWrapperComponentProvider').mockImplementation((provider) => {
installedProviders.push(provider);
});
initJourneyTracking();
const ExternalWrapper = ({ children }) => (
<View testID="external-wrapper">{children}</View>
);
AppRegistry.setWrapperComponentProvider(() => ExternalWrapper);
enableJourneyConsumer();
const provider = installedProviders[installedProviders.length - 1];
const ComposedWrapper = provider({ rootTag: 1 });
const component = render(
<ComposedWrapper>
<Text>child</Text>
</ComposedWrapper>
);
expect(component.getByTestId('external-wrapper')).toBeTruthy();
const wrappers = component.UNSAFE_getAllByType(View);
fireEvent(wrappers[1], 'touchStart', {
nativeEvent: { pageX: 7, pageY: 8, target: 70 },
});
fireEvent(wrappers[1], 'touchEnd', {
nativeEvent: { pageX: 7, pageY: 8, target: 70 },
});
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
k: 'click',
v: 'View',
m: { id: '70', ac: 'tap', x: 7, y: 8 },
}),
]);
});
it('captures initial and subsequent navigation transitions', () => {
const listeners = new Set();
const route = { current: { key: 'home-key', name: 'Home' } };
const navigation = {
addListener: jest.fn((eventName, listener) => {
expect(eventName).toBe('state');
listeners.add(listener);
return () => listeners.delete(listener);
}),
getCurrentRoute: jest.fn(() => route.current),
};
initJourneyTracking();
registerJourneyNavigationContainer(navigation);
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
k: 'screen',
v: 'Screen',
m: { id: 'screen', sc: 'Home', ac: 'appear' },
}),
]);
clearJourneyEvents();
route.current = { key: 'settings-key', name: 'Settings' };
listeners.forEach((listener) => listener());
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
m: { id: 'screen', sc: 'Home', ac: 'disappear' },
}),
expect.objectContaining({
m: { id: 'screen', sc: 'Settings', ac: 'appear' },
}),
]);
});
it('re-emits the current screen when a consumer restarts on the same route', () => {
const navigation = {
addListener: jest.fn((eventName, listener) => {
expect(eventName).toBe('state');
return () => listener;
}),
getCurrentRoute: jest.fn(() => ({ key: 'home-key', name: 'Home' })),
};
initJourneyTracking();
registerJourneyNavigationContainer(navigation);
clearJourneyEvents();
enableJourneyConsumer();
disableJourneyConsumer();
enableJourneyConsumer();
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
k: 'screen',
v: 'Screen',
m: { id: 'screen', sc: 'Home', ac: 'appear' },
}),
]);
});
it('does not duplicate the current screen when a consumer restarts and the route is already buffered', () => {
const navigation = {
addListener: jest.fn((eventName, listener) => {
expect(eventName).toBe('state');
return () => listener;
}),
getCurrentRoute: jest.fn(() => ({ key: 'home-key', name: 'Home' })),
};
initJourneyTracking();
registerJourneyNavigationContainer(navigation);
expect(peekJourneyEvents()).toHaveLength(1);
enableJourneyConsumer();
disableJourneyConsumer();
enableJourneyConsumer();
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
k: 'screen',
v: 'Screen',
m: { id: 'screen', sc: 'Home', ac: 'appear' },
}),
]);
});
it('does not emit automatic touch events until a journey consumer is enabled', () => {
initJourneyTracking();
const component = render(
<JourneyWrapper>
<Text>child</Text>
</JourneyWrapper>
);
const wrapper = component.UNSAFE_getByType(View);
fireEvent(wrapper, 'touchStart', {
nativeEvent: { pageX: 5, pageY: 6, target: 21 },
});
fireEvent(wrapper, 'touchEnd', {
nativeEvent: { pageX: 5, pageY: 6, target: 21 },
});
expect(peekJourneyEvents()).toEqual([]);
enableJourneyConsumer();
fireEvent(wrapper, 'touchStart', {
nativeEvent: { pageX: 5, pageY: 6, target: 21 },
});
fireEvent(wrapper, 'touchEnd', {
nativeEvent: { pageX: 5, pageY: 6, target: 21 },
});
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
k: 'click',
v: 'View',
m: { id: '21', ac: 'tap', x: 5, y: 6 },
}),
]);
});
it('does not emit drag events for a stationary long press', () => {
initJourneyTracking();
enableJourneyConsumer();
const nowSpy = jest.spyOn(Date, 'now');
let now = 1000;
nowSpy.mockImplementation(() => now);
const component = render(
<JourneyWrapper>
<Text>child</Text>
</JourneyWrapper>
);
const wrapper = component.UNSAFE_getByType(View);
fireEvent(wrapper, 'touchStart', {
nativeEvent: { pageX: 5, pageY: 6, target: 23 },
});
now = 1401;
fireEvent(wrapper, 'touchEnd', {
nativeEvent: { pageX: 5, pageY: 6, target: 23 },
});
expect(peekJourneyEvents()).toEqual([]);
});
it('rounds captured touch coordinates to integers', () => {
initJourneyTracking();
enableJourneyConsumer();
const component = render(
<JourneyWrapper>
<Text>child</Text>
</JourneyWrapper>
);
const wrapper = component.UNSAFE_getByType(View);
fireEvent(wrapper, 'touchStart', {
nativeEvent: { pageX: 5.4, pageY: 6.6, target: 22 },
});
fireEvent(wrapper, 'touchEnd', {
nativeEvent: { pageX: 5.4, pageY: 6.6, target: 22 },
});
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
m: { id: '22', ac: 'tap', x: 5, y: 7 },
}),
]);
});
it('emits drag events from the automatic wrapper when movement exceeds threshold', () => {
initJourneyTracking();
enableJourneyConsumer();
const component = render(
<JourneyWrapper>
<Text>child</Text>
</JourneyWrapper>
);
const wrapper = component.UNSAFE_getByType(View);
fireEvent(wrapper, 'touchStart', {
nativeEvent: { pageX: 0, pageY: 0, target: 30 },
});
fireEvent(wrapper, 'touchEnd', {
nativeEvent: { pageX: 20, pageY: 20, target: 30 },
});
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
k: 'drag',
v: 'View',
m: { id: '30', ac: 'drag_start', x: 20, y: 20 },
}),
expect.objectContaining({
k: 'drag',
v: 'View',
m: expect.objectContaining({ id: '30', ac: 'drag_end', x: 20, y: 20 }),
}),
]);
});
it('emits scroll-shaped drag events when movement is axis-dominant', () => {
initJourneyTracking();
enableJourneyConsumer();
const component = render(
<JourneyWrapper>
<Text>child</Text>
</JourneyWrapper>
);
const wrapper = component.UNSAFE_getByType(View);
fireEvent(wrapper, 'touchStart', {
nativeEvent: { pageX: 0, pageY: 0, target: 40 },
});
fireEvent(wrapper, 'touchMove', {
nativeEvent: { pageX: 35, pageY: 3, target: 40 },
});
fireEvent(wrapper, 'touchEnd', {
nativeEvent: { pageX: 55, pageY: 4, target: 40 },
});
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
k: 'drag',
v: 'ScrollView',
m: { id: '40', ac: 'scroll_start', x: 35, y: 3, val: 'horizontal:right' },
}),
expect.objectContaining({
k: 'drag',
v: 'ScrollView',
m: expect.objectContaining({ id: '40', ac: 'scroll_end', x: 55, y: 4 }),
}),
]);
});
it('upgrades numeric targets to semantic identifiers when richer metadata becomes available', () => {
initJourneyTracking();
enableJourneyConsumer();
const component = render(
<JourneyWrapper>
<Text>child</Text>
</JourneyWrapper>
);
const wrapper = component.UNSAFE_getByType(View);
fireEvent(wrapper, 'touchStart', {
nativeEvent: { pageX: 0, pageY: 0, target: 41 },
});
fireEvent(wrapper, 'touchMove', {
nativeEvent: {
pageX: 25,
pageY: 2,
target: 41,
_dispatchInstances: {
pendingProps: { testID: 'checkout-cta' },
return: null,
},
},
});
fireEvent(wrapper, 'touchEnd', {
nativeEvent: { pageX: 30, pageY: 4, target: 41 },
});
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
m: { id: 'checkout-cta', ac: 'scroll_start', x: 25, y: 2, val: 'horizontal:right' },
}),
expect.objectContaining({
m: expect.objectContaining({ id: 'checkout-cta', ac: 'scroll_end' }),
}),
]);
});
it('reads semantic identifiers from the synthetic event when nativeEvent lacks fiber metadata', () => {
initJourneyTracking();
enableJourneyConsumer();
const component = render(
<JourneyWrapper>
<Text>child</Text>
</JourneyWrapper>
);
const wrapper = component.UNSAFE_getByType(View);
fireEvent(wrapper, 'touchStart', {
nativeEvent: { pageX: 0, pageY: 0, target: 42 },
});
fireEvent(wrapper, 'touchMove', {
_dispatchInstances: {
pendingProps: { nativeID: 'launch-captcha' },
return: null,
},
nativeEvent: {
pageX: 25.2,
pageY: 2.2,
target: 42,
},
});
fireEvent(wrapper, 'touchEnd', {
nativeEvent: { pageX: 30.1, pageY: 4.4, target: 42 },
});
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
m: { id: 'launch-captcha', ac: 'scroll_start', x: 25, y: 2, val: 'horizontal:right' },
}),
expect.objectContaining({
m: expect.objectContaining({ id: 'launch-captcha', ac: 'scroll_end', x: 30, y: 4 }),
}),
]);
});
it('prefers semantic identifiers over numeric fallbacks during resolution', () => {
expect(resolveJourneyIdentifier({
target: 99,
_dispatchInstances: {
pendingProps: { nativeID: 'primary-action' },
return: null,
},
})).toBe('primary-action');
});
it('resolves identifiers from the synthetic event before falling back to native numeric targets', () => {
expect(resolveJourneyIdentifier({
_dispatchInstances: {
pendingProps: { testID: 'warmup-touch' },
return: null,
},
nativeEvent: {
target: 99,
},
})).toBe('warmup-touch');
});
it('retains the last 50 events in the ring buffer', () => {
initJourneyTracking();
for (let index = 0; index < 60; index += 1) {
emitJourneyEvent('click', 'View', { id: `event-${index}`, ac: 'tap' });
}
const events = peekJourneyEvents();
expect(events).toHaveLength(50);
expect(events[0].m.id).toBe('event-10');
expect(events[49].m.id).toBe('event-59');
});
it('peek keeps buffered events while drain returns and clears them', () => {
initJourneyTracking();
emitJourneyEvent('click', 'View', { id: 'peeked', ac: 'tap' });
expect(peekJourneyEvents()).toHaveLength(1);
expect(peekJourneyEvents()).toHaveLength(1);
expect(drainJourneyEvents()).toEqual([
expect.objectContaining({
k: 'click',
v: 'View',
m: { id: 'peeked', ac: 'tap' },
}),
]);
expect(peekJourneyEvents()).toEqual([]);
});
it('keeps buffering after the last consumer disables', () => {
initJourneyTracking();
enableJourneyConsumer();
disableJourneyConsumer();
emitJourneyEvent('click', 'View', { id: 'idle-event', ac: 'tap' });
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
k: 'click',
v: 'View',
m: { id: 'idle-event', ac: 'tap' },
}),
]);
});
it('publishes runtime stats and optional debug logs', () => {
const onStats = jest.fn();
const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
initJourneyTracking({ debug: true, onStats });
emitJourneyEvent('click', 'View', { id: 'screen', ac: 'tap' });
enableJourneyConsumer();
disableJourneyConsumer();
expect(debugSpy).toHaveBeenCalledWith('[hcaptcha] journey', expect.objectContaining({
k: 'click',
v: 'View',
}));
expect(onStats).toHaveBeenLastCalledWith(expect.objectContaining({
activeConsumers: 0,
bufferedEvents: 1,
capturing: true,
initialized: true,
touchCaptureEnabled: true,
wrapperInstalled: true,
}));
});
it('supports disabling touch capture while keeping the rest of journey tracking enabled', () => {
const wrapperSpy = jest.spyOn(AppRegistry, 'setWrapperComponentProvider');
const navigation = {
addListener: jest.fn(() => () => {}),
getCurrentRoute: jest.fn(() => ({ key: 'home-key', name: 'Home' })),
};
initJourneyTracking({ navigationContainerRef: navigation, touchCapture: false });
enableJourneyConsumer();
expect(wrapperSpy).not.toHaveBeenCalled();
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
k: 'screen',
v: 'Screen',
m: { id: 'screen', sc: 'Home', ac: 'appear' },
}),
]);
const component = render(
<JourneyWrapper>
<Text>child</Text>
</JourneyWrapper>
);
const wrapper = component.UNSAFE_getByType(View);
fireEvent(wrapper, 'touchStart', {
nativeEvent: { pageX: 9, pageY: 10, target: 90 },
});
fireEvent(wrapper, 'touchEnd', {
nativeEvent: { pageX: 9, pageY: 10, target: 90 },
});
expect(peekJourneyEvents()).toEqual([
expect.objectContaining({
k: 'screen',
v: 'Screen',
m: { id: 'screen', sc: 'Home', ac: 'appear' },
}),
]);
});
});