Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
316 changes: 316 additions & 0 deletions uxcam-react-wrapper/__tests__/UXLogBuffer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
/**
* UXLogBuffer Tests
*
* Tests for the shared log buffer used by React Native console capture.
* Covers: lazy serialization, buffer behavior, adaptive flush, truncation, timestamps.
*/

const { UXLogBuffer } = require('../src/UXLogBuffer');

describe('UXLogBuffer', () => {
let transportCalls;
let buffer;

beforeEach(() => {
jest.useFakeTimers();
transportCalls = [];
buffer = new UXLogBuffer({
transport: (batch) => transportCalls.push([...batch]),
});
});

afterEach(() => {
jest.useRealTimers();
});

// --- Safe Serialization (_fmt) ---

describe('safe serialization', () => {
test('string passes through unchanged', () => {
buffer.enqueue('log', ['hello']);
buffer.flush();
expect(transportCalls[0][0].m).toBe('hello');
});

test('number converted to string', () => {
buffer.enqueue('log', [42]);
buffer.flush();
expect(transportCalls[0][0].m).toBe('42');
});

test('boolean converted to string', () => {
buffer.enqueue('log', [true]);
buffer.flush();
expect(transportCalls[0][0].m).toBe('true');
});

test('object serialized as depth-limited JSON', () => {
buffer.enqueue('log', [{ user: 'test', count: 1 }]);
buffer.flush();
expect(transportCalls[0][0].m).toBe('{"user":"test","count":1}');
});

test('array serialized as JSON', () => {
buffer.enqueue('log', [[1, 2, 3]]);
buffer.flush();
expect(transportCalls[0][0].m).toBe('[1,2,3]');
});

test('null shows as null', () => {
buffer.enqueue('log', [null]);
buffer.flush();
expect(transportCalls[0][0].m).toBe('null');
});

test('undefined shows as undefined', () => {
buffer.enqueue('log', [undefined]);
buffer.flush();
expect(transportCalls[0][0].m).toBe('undefined');
});

test('function shows as [Function]', () => {
buffer.enqueue('log', [function foo() {}]);
buffer.flush();
expect(transportCalls[0][0].m).toBe('[Function]');
});

test('mixed args joined with space', () => {
buffer.enqueue('log', ['hello', 42, true, { a: 1 }]);
buffer.flush();
expect(transportCalls[0][0].m).toBe('hello 42 true {"a":1}');
});

test('depth limit caps deeply nested objects', () => {
const deep = { l1: { l2: { l3: { l4: 'deep' } } } };
buffer.enqueue('log', [deep]);
buffer.flush();
const msg = transportCalls[0][0].m;
expect(msg).toContain('"l1"');
expect(msg).toContain('"l2"');
expect(msg).toContain('"l3"');
// l3 value is at depth 3, so it becomes [Object]
expect(msg).toContain('[Object]');
expect(msg).not.toContain('"l4"');
});

test('circular references handled safely', () => {
const obj = { a: 1 };
obj.self = obj;
buffer.enqueue('log', [obj]);
buffer.flush();
const msg = transportCalls[0][0].m;
expect(msg).toContain('"a":1');
expect(msg).toContain('[Circular]');
});

test('per-arg cap at 512 chars', () => {
const big = {};
for (let i = 0; i < 100; i++) {
big['key_' + i] = 'value_' + i;
}
buffer.enqueue('log', [big]);
buffer.flush();
const msg = transportCalls[0][0].m;
expect(msg.length).toBeLessThanOrEqual(2048);
// Object was truncated with ... suffix
expect(msg).toMatch(/\.\.\.$/);
});

test('sibling branches both get full depth', () => {
const obj = { a: { nested: 1 }, b: { nested: 2 } };
buffer.enqueue('log', [obj]);
buffer.flush();
const msg = transportCalls[0][0].m;
expect(msg).toContain('"nested":1');
expect(msg).toContain('"nested":2');
});

test('uses JSON.stringify for objects', () => {
const spy = jest.spyOn(JSON, 'stringify');
buffer.enqueue('log', [{ complex: { nested: true } }]);
buffer.flush();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});

// --- Buffer Behavior ---

describe('buffer behavior', () => {
test('enqueue adds to buffer (no immediate transport)', () => {
buffer.enqueue('log', ['test']);
expect(transportCalls).toHaveLength(0);
});

test('flush sends buffered entries to transport', () => {
buffer.enqueue('log', ['msg1']);
buffer.enqueue('info', ['msg2']);
buffer.flush();
expect(transportCalls).toHaveLength(1);
expect(transportCalls[0]).toHaveLength(2);
});

test('flush clears the buffer', () => {
buffer.enqueue('log', ['test']);
buffer.flush();
buffer.flush(); // second flush should be no-op
expect(transportCalls).toHaveLength(1);
});

test('flush on empty buffer does not call transport', () => {
buffer.flush();
expect(transportCalls).toHaveLength(0);
});

test('max buffer triggers flush at 20 entries', () => {
for (let i = 0; i < 20; i++) {
buffer.enqueue('log', ['msg ' + i]);
}
expect(transportCalls).toHaveLength(1);
expect(transportCalls[0]).toHaveLength(20);
});

test('flush timer fires after 1 second', () => {
buffer.enqueue('log', ['test']);
expect(transportCalls).toHaveLength(0);
jest.advanceTimersByTime(1000);
expect(transportCalls).toHaveLength(1);
});

test('flush timer resets on new enqueue', () => {
buffer.enqueue('log', ['msg1']);
jest.advanceTimersByTime(500);
buffer.enqueue('log', ['msg2']);
jest.advanceTimersByTime(500);
// Timer from first enqueue should not fire yet (was not reset per impl)
// But the original timer should fire at 1000ms from first enqueue
expect(transportCalls.length).toBeGreaterThanOrEqual(1);
});
});

// --- Adaptive Flush ---

describe('adaptive flush', () => {
test('error level flushes immediately', () => {
buffer.enqueue('error', ['critical failure']);
expect(transportCalls).toHaveLength(1);
expect(transportCalls[0][0].l).toBe('error');
});

test('log level does not flush immediately', () => {
buffer.enqueue('log', ['normal message']);
expect(transportCalls).toHaveLength(0);
});

test('warn level does not flush immediately', () => {
buffer.enqueue('warn', ['warning']);
expect(transportCalls).toHaveLength(0);
});

test('info level does not flush immediately', () => {
buffer.enqueue('info', ['info']);
expect(transportCalls).toHaveLength(0);
});

test('debug level does not flush immediately', () => {
buffer.enqueue('debug', ['debug']);
expect(transportCalls).toHaveLength(0);
});
});

// --- Truncation ---

describe('truncation', () => {
test('message under 2048 chars unchanged', () => {
const msg = 'x'.repeat(100);
buffer.enqueue('log', [msg]);
buffer.flush();
expect(transportCalls[0][0].m).toBe(msg);
});

test('message over 2048 chars is capped', () => {
const msg = 'x'.repeat(5000);
buffer.enqueue('log', [msg]);
buffer.flush();
expect(transportCalls[0][0].m.length).toBe(2048);
});
});

// --- Entry Format ---

describe('entry format', () => {
test('each entry has level, message, and timestamp', () => {
const before = Date.now();
buffer.enqueue('warn', ['test message']);
buffer.flush();
const entry = transportCalls[0][0];
expect(entry.l).toBe('warn');
expect(entry.m).toBe('test message');
expect(entry.t).toBeGreaterThanOrEqual(before);
expect(entry.t).toBeLessThanOrEqual(Date.now());
});

test('batch preserves insertion order', () => {
for (let i = 0; i < 5; i++) {
buffer.enqueue('log', ['msg' + i]);
}
buffer.flush();
const batch = transportCalls[0];
for (let i = 0; i < 5; i++) {
expect(batch[i].m).toBe('msg' + i);
}
});
});

// --- Custom Configuration ---

describe('custom configuration', () => {
test('custom maxBufferSize triggers flush earlier', () => {
const smallBuffer = new UXLogBuffer({
transport: (batch) => transportCalls.push([...batch]),
maxBufferSize: 5,
});
for (let i = 0; i < 5; i++) {
smallBuffer.enqueue('log', ['msg']);
}
expect(transportCalls).toHaveLength(1);
expect(transportCalls[0]).toHaveLength(5);
});

test('custom flushIntervalMs fires at specified time', () => {
const fastBuffer = new UXLogBuffer({
transport: (batch) => transportCalls.push([...batch]),
flushIntervalMs: 200,
});
fastBuffer.enqueue('log', ['test']);
jest.advanceTimersByTime(200);
expect(transportCalls).toHaveLength(1);
});

test('custom maxMsgLen truncates at specified length', () => {
const shortBuffer = new UXLogBuffer({
transport: (batch) => transportCalls.push([...batch]),
maxMsgLen: 10,
});
shortBuffer.enqueue('log', ['this is a very long message']);
shortBuffer.flush();
expect(transportCalls[0][0].m.length).toBe(10);
});
});

// --- Error Handling ---

describe('error handling', () => {
test('transport error does not crash enqueue', () => {
const badBuffer = new UXLogBuffer({
transport: () => { throw new Error('transport failed'); },
});
// Should not throw
expect(() => {
for (let i = 0; i < 20; i++) {
badBuffer.enqueue('log', ['msg']);
}
}).not.toThrow();
});
});
});
16 changes: 14 additions & 2 deletions uxcam-react-wrapper/ios/RNUxcam/RNUxcam.mm
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
static NSString* const RNUxcam_ScreenTag = @"enableAutomaticScreenNameTagging";
static NSString* const RNUxcam_AdvancedGestures = @"enableAdvancedGestureRecognition";
static NSString* const RNUxcam_EnableNetworkLogs = @"enableNetworkLogging";
static NSString* const RNUxcam_EnableJSConsoleLogCapture = @"enableJavaScriptConsoleLogCapture";

static NSString* const RNUxcam_Occlusion = @"occlusions";
static NSString* const RNUxcam_OccludeScreens = @"screens";
Expand Down Expand Up @@ -92,7 +93,8 @@ + (BOOL)requiresMainQueueSetup
RNUxcam_CrashHandling: @(configuration.enableCrashHandling),
RNUxcam_ScreenTag: @(configuration.enableAutomaticScreenNameTagging),
RNUxcam_AdvancedGestures: @(configuration.enableAdvancedGestureRecognition),
RNUxcam_EnableNetworkLogs: @(configuration.enableNetworkLogging)
RNUxcam_EnableNetworkLogs: @(configuration.enableNetworkLogging),
RNUxcam_EnableJSConsoleLogCapture: @(configuration.enableJavaScriptConsoleLogCapture)
};
resolve(configDict);
}
Expand Down Expand Up @@ -150,7 +152,12 @@ - (void)updateConfiguration:(UXCamConfiguration *)configuration fromDict:(NSDict
{
configuration.enableNetworkLogging = [RCTConvert BOOL:enableNetworkLogging];
}

NSNumber *enableJSConsoleLogCapture = config[RNUxcam_EnableJSConsoleLogCapture];
if (enableJSConsoleLogCapture)
{
configuration.enableJavaScriptConsoleLogCapture = [RCTConvert BOOL:enableJSConsoleLogCapture];
}

NSArray *occlusionList = config[RNUxcam_Occlusion];
if (occlusionList && ![occlusionList isKindOfClass:NSNull.class]) {
UXCamOcclusion *occlusion = [[UXCamOcclusion alloc] init];
Expand Down Expand Up @@ -521,6 +528,11 @@ - (void)occludeView:(UIView *)view hideGesture:(BOOL)hideGesture {
[UXCam setSessionProperty:propertyName value:value];
}

RCT_EXPORT_METHOD(reportJavaScriptConsoleLog:(NSString *)level message:(NSString *)message timestamp:(double)jsTimestampMs)
{
[UXCam reportConsoleLog:level message:message source:@"react-native" timestamp:jsTimestampMs];
}

// Thanks to this guard, we won't compile this code when we build for the old architecture.
#ifdef RCT_NEW_ARCH_ENABLED
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
Expand Down
4 changes: 3 additions & 1 deletion uxcam-react-wrapper/src/NativeRNUxcam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ export interface Spec extends TurboModule {

applyOcclusion: (occlusion: Object) => void;
removeOcclusion: (occlusion: Object) => void;


// JavaScript console log capture (internal, used by patchRNConsole)
reportJavaScriptConsoleLog: (level: string, message: string, timestamp: number) => void;

// Event Listeneres
addListener: (eventType: string) => void;
Expand Down
Loading