diff --git a/examples/bare/e2e/api-arguments.test.ts b/examples/bare/e2e/api-arguments.test.ts index 17a5e019..3d04cc84 100644 --- a/examples/bare/e2e/api-arguments.test.ts +++ b/examples/bare/e2e/api-arguments.test.ts @@ -118,11 +118,11 @@ describeDevice(`overloads and arguments on ${TARGET_NAME}`, () => { /** * The implementation plan (docs/design/plans/2026-09-16-implementation-plan.md, - * Phase 1 Red) says a blank token rejects with `E_TOKEN`. `assertUsableToken` - * (packages/react-native/src/index.ts) throws a plain Error with no `code`. - * Wrapper divergence, to fix (sdk-issues-filed.md "to fix"). + * Phase 1 Red) says a blank token rejects with `E_TOKEN`, the code the + * native modules use. `assertUsableToken` (packages/react-native/src/index.ts) + * runs first and carries the same code and message (W2). */ - it.failing('[API-01c] the rejection carries code E_TOKEN', () => { + it('[API-01c] the rejection carries code E_TOKEN', () => { for (const key of ['blank', 'spaces', 'number']) { expect(jsonAfter(rejected.text, key).error?.code).toBe('E_TOKEN'); } diff --git a/examples/bare/e2e/console-levels.test.ts b/examples/bare/e2e/console-levels.test.ts index bddc0d72..6958147b 100644 --- a/examples/bare/e2e/console-levels.test.ts +++ b/examples/bare/e2e/console-levels.test.ts @@ -78,15 +78,12 @@ describeDevice(`console levels and the native-only log stream on ${TARGET_NAME}` }); /** - * iOS (XS and simulator, beta5, Debug, 2026-10-07): the line lands three - * times -- the bridge's Custom (98) line at Warning (2), and twice more as - * source 2 at level 4, RN's own stderr/os_log output of the RCTLog, which - * the SDK captures -- and the log filter runs three times (counts.rct 3). - * BGSRNConsoleCapture.mm forwards a native RCTLog line without arming the - * echo drop it arms for console.* lines. A wrapper bug (G16), to fix: - * sdk-issues-filed.md W1. Android: once. + * iOS used to record this line three times (the bridge's Custom line plus + * two stderr echoes: RN's own delivery and LogBox's JS relog of the + * warning) and filter it three times (W1). BGSRNConsoleCapture.mm now arms + * the echo drop for a native line and its relog. Android: logcat, once. */ - (ON_IOS ? it.failing : it)('[FLOW-13] a native RCTLog warning with no console call lands once and is filtered once', () => { + it('[FLOW-13] a native RCTLog warning with no console call lands once and is filtered once', () => { expect(linesOf('rct')).toHaveLength(1); expect(counts.rct).toBe(1); }); diff --git a/packages/react-native/ios/BGSRNConsoleCapture.h b/packages/react-native/ios/BGSRNConsoleCapture.h index 5dc32423..1202eb94 100644 --- a/packages/react-native/ios/BGSRNConsoleCapture.h +++ b/packages/react-native/ios/BGSRNConsoleCapture.h @@ -16,6 +16,12 @@ NS_ASSUME_NONNULL_BEGIN * that echo. Keeping the channel line ends the equal-text claim. A later * Custom line of the same text is kept, including `Bugsee.log` and a native * `RCTLog` forwarded as Custom. + * + * A native line arms the same echo drop before the rest of the log function + * chain runs, so an app that mirrors RCTLog to stderr does not record it a + * second time. In a Debug build RN re-logs a native warning from JS (LogBox); + * that JavaScript delivery arms one more drop, for its own echo. The line is + * recorded and filtered once. */ void BGSRNInstallConsoleCapture(void); diff --git a/packages/react-native/ios/BGSRNConsoleCapture.mm b/packages/react-native/ios/BGSRNConsoleCapture.mm index 1b988157..d6172fa3 100644 --- a/packages/react-native/ios/BGSRNConsoleCapture.mm +++ b/packages/react-native/ios/BGSRNConsoleCapture.mm @@ -43,29 +43,6 @@ static BOOL BGSRNConsoleCaptureEnabled(void) { return [value boolValue]; } -static void BGSRNOnRCTLog(RCTLogLevel level, RCTLogSource source, NSString *message) { - // JavaScript source is the console echo. The JS patch forwards that call - // in dev and in release; recording it here would run the filter twice. - // Release builds often never produce this source for console.*, and - // dropping the patch instead of this echo would drop those logs. - if (source == RCTLogSourceJavaScript) { - return; - } - if (message.length == 0) { - return; - } - NSMutableDictionary *locals = NSThread.currentThread.threadDictionary; - if (locals[BGSRNConsoleCaptureKey] != nil) { - return; - } - locals[BGSRNConsoleCaptureKey] = @YES; - if (BGSRNConsoleCaptureEnabled()) { - [BGSRNWrapperChannelHolder.shared logMessage:message - level:BGSRNWireLevelForRCTLogLevel(level)]; - } - [locals removeObjectForKey:BGSRNConsoleCaptureKey]; -} - static const NSTimeInterval BGSRNEchoWindowSeconds = 2.0; static const NSUInteger BGSRNEchoNoteCap = 32; @@ -238,15 +215,130 @@ BOOL BGSRNDropConsoleEcho(NSString *line, NSInteger source) { } } +/// Native warnings React Native re-logs from JS. In a Debug build +/// `_RCTLogNativeInternal` also calls `RCTLog.logIfNoNativeHook`, and +/// LogBox's warning handler passes a `warn` to the `console.warn` it saved +/// before any patch. That reaches RCTLog a second time, as +/// `RCTLogSourceJavaScript`, with the same text. This hook does not record +/// it, but whatever the log function chain writes for it (an app's stderr +/// mirror, an NSLog) is a second echo of the line already recorded. Each +/// entry arms one more echo drop when that delivery arrives. Under the echo +/// lock. +static NSMutableArray *BGSRNRelogNotes; + +static void BGSRNExpectJsRelog(NSString *message) { + @synchronized(BGSRNEchoLock()) { + const NSTimeInterval now = [NSDate date].timeIntervalSince1970; + if (BGSRNRelogNotes == nil) { + BGSRNRelogNotes = [NSMutableArray array]; + } + BGSRNEchoNote *note = [BGSRNEchoNote new]; + note.text = message; + note.expires = now + BGSRNEchoWindowSeconds; + [BGSRNRelogNotes addObject:note]; + while (BGSRNRelogNotes.count > BGSRNEchoNoteCap) { + [BGSRNRelogNotes removeObjectAtIndex:0]; + } + } +} + +/// YES once per expected relog of `message`, within the echo window. +static BOOL BGSRNClaimJsRelog(NSString *message) { + @synchronized(BGSRNEchoLock()) { + const NSTimeInterval now = [NSDate date].timeIntervalSince1970; + for (NSInteger index = (NSInteger)BGSRNRelogNotes.count - 1; index >= 0; index--) { + if (BGSRNRelogNotes[(NSUInteger)index].expires <= now) { + [BGSRNRelogNotes removeObjectAtIndex:(NSUInteger)index]; + } + } + for (NSUInteger index = 0; index < BGSRNRelogNotes.count; index++) { + if ([BGSRNRelogNotes[index].text isEqualToString:message]) { + [BGSRNRelogNotes removeObjectAtIndex:index]; + return YES; + } + } + return NO; + } +} + +/** + * Runs before the rest of the log function chain, so every echo drop is + * armed before anything in that chain can write the line where the SDK + * captures it. Returns YES when this line is to be recorded on the channel + * after the chain has run. + * + * JavaScript source is the console echo. The JS patch forwards that call in + * dev and in release; recording it here would run the filter twice. Release + * builds often never produce this source for console.*, and dropping the + * patch instead of this echo would drop those logs. A JavaScript delivery + * that is the relog of a native warning arms one more echo drop. + * + * A native line is recorded here, once, and its echo drop is armed the way + * the JS patch arms one for a console call: a stderr stamp and one raw + * stdout or stderr line of that text. + */ +static BOOL BGSRNBeginRCTLog(RCTLogLevel level, RCTLogSource source, NSString *message) { + if (message.length == 0) { + return NO; + } + if (source == RCTLogSourceJavaScript) { + if (BGSRNClaimJsRelog(message)) { + BGSRNNoteConsoleEcho(message); + } + return NO; + } + if (NSThread.currentThread.threadDictionary[BGSRNConsoleCaptureKey] != nil) { + return NO; + } + if (!BGSRNConsoleCaptureEnabled()) { + return NO; + } + BGSRNNoteConsoleEcho(message); +#if RCT_DEBUG + if (level == RCTLogLevelWarning) { + BGSRNExpectJsRelog(message); + } +#endif + return YES; +} + +/// Records a native line on the channel, as Custom. The filter runs inside +/// this call on this thread: that is the channel line, which is kept. +static void BGSRNForwardRCTLog(RCTLogLevel level, NSString *message) { + NSMutableDictionary *locals = NSThread.currentThread.threadDictionary; + locals[BGSRNConsoleCaptureKey] = @YES; + BGSRNBeginChannelLine(message); + @try { + [BGSRNWrapperChannelHolder.shared logMessage:message + level:BGSRNWireLevelForRCTLogLevel(level)]; + } @finally { + BGSRNEndChannelLine(message); + [locals removeObjectForKey:BGSRNConsoleCaptureKey]; + } +} + +/** + * Wraps the current log function rather than appending to it + * (`RCTAddLogFunction` runs the existing function first): the echo drop of a + * line must be armed before the existing function writes that line to + * stderr, or the SDK could capture and filter the echo first. + */ void BGSRNInstallConsoleCapture(void) { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - RCTAddLogFunction(^(RCTLogLevel level, + RCTLogFunction existing = RCTGetLogFunction(); + RCTSetLogFunction(^(RCTLogLevel level, RCTLogSource source, - __unused NSString *fileName, - __unused NSNumber *lineNumber, + NSString *fileName, + NSNumber *lineNumber, NSString *message) { - BGSRNOnRCTLog(level, source, message); + const BOOL forward = BGSRNBeginRCTLog(level, source, message); + if (existing != nil) { + existing(level, source, fileName, lineNumber, message); + } + if (forward) { + BGSRNForwardRCTLog(level, message); + } }); }); } diff --git a/packages/react-native/src/__tests__/lifecycle.test.ts b/packages/react-native/src/__tests__/lifecycle.test.ts index fc60ef3a..ff909191 100644 --- a/packages/react-native/src/__tests__/lifecycle.test.ts +++ b/packages/react-native/src/__tests__/lifecycle.test.ts @@ -62,6 +62,22 @@ describe('launch', () => { expect(native.launch).not.toHaveBeenCalled(); }); + // The native modules reject a blank token with E_TOKEN (implementation plan, + // Task 1.3). The JS check runs first on both platforms, so it must carry + // the same code and message, or a caller matching on `.code` sees nothing. + it.each(['', ' ', undefined, null, 42])( + 'rejects the unusable token %p with code E_TOKEN and the native message', + async (token) => { + const rejection = Bugsee.launch(token as unknown as string); + await expect(rejection).rejects.toBeInstanceOf(Error); + await expect(rejection).rejects.toMatchObject({ + code: 'E_TOKEN', + message: 'Bugsee.launch requires a non-empty app token', + }); + expect(native.launch).not.toHaveBeenCalled(); + }, + ); + it('surfaces a native rejection to the caller', async () => { native.launch.mockRejectedValue(new Error('boom')); await expect(Bugsee.launch('tok')).rejects.toThrow('boom'); diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 05b2fac9..2ad80c43 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -861,9 +861,16 @@ function optionalReportText( return value; } +/** + * Rejects a blank or non-string token before the bridge, with the code and + * message the native modules use for the same refusal (`E_TOKEN`), so a + * caller matching on `.code` sees one answer whichever side raised it. + */ function assertUsableToken(token: string): void { if (typeof token !== 'string' || token.trim().length === 0) { - throw new Error('Bugsee.launch requires a non-empty app token'); + throw Object.assign(new Error('Bugsee.launch requires a non-empty app token'), { + code: 'E_TOKEN', + }); } }