diff --git a/docs/design/plans/2026-09-16-implementation-plan.md b/docs/design/plans/2026-09-16-implementation-plan.md index eaf170de..c2440291 100644 --- a/docs/design/plans/2026-09-16-implementation-plan.md +++ b/docs/design/plans/2026-09-16-implementation-plan.md @@ -3666,7 +3666,7 @@ FOUNDATION_EXPORT NSArray *_Nullable BGSRNStringArray(NSArray *_Null Both methods run inside `BGSRNRunOnMain`. - `upload:description:severity:labels:` → `[Bugsee uploadWithSummary:… description:… severity:(BugseeSeverityLevel)BGSRNUploadSeverity(severity, [Bugsee getLaunchOptions]) labels:BGSRNStringArray(labels)]`. -- `showReportDialog:…` → `[Bugsee showReportDialog]` when every argument is absent (`nil`, `nil`, `0`, `nil`). Otherwise → `showReportDialogWithSummary:(summary ?: @"") description:(description ?: @"") severity:(BugseeSeverityLevel)severity labels:BGSRNStringArray(labels)`. +- `showReportDialog:…` → `[Bugsee showReportDialog]` when every argument is absent (`nil`, `nil`, `0`, `nil`). Otherwise → `showReportDialogWithSummary:summary description:description severity:(BugseeSeverityLevel)severity labels:BGSRNStringArray(labels)`. An omitted summary or description stays `nil`. beta3 writes the field only when the pointer is non-nil, and `@""` would pre-fill an empty string. - [x] **Red** - `upload.test.ts`: diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index b63d1480..f1e0af2c 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -7,6 +7,7 @@ import android.util.Log; import com.bugsee.library.Bugsee; +import com.bugsee.library.contracts.options.IssueSeverity; import com.bugsee.library.contracts.reporting.Report; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; @@ -16,6 +17,7 @@ import com.facebook.react.bridge.ReadableMapKeySetIterator; import com.facebook.react.bridge.ReadableType; import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.WritableMap; import com.facebook.react.module.annotations.ReactModule; @@ -362,12 +364,56 @@ public void logUnhandledException(final String payloadJson, final Promise promis }); } - // The two-argument overload only -- severity and labels are Phase 8. JS - // has already checked both arguments are strings, so nothing is - // re-validated here. + /** + * JS has already checked the strings, the severity range and the labels. + * {@code severity} 0 and a null label list are the SDK default. A throw + * from the SDK must not escape a void method. + */ @Override - public void upload(final String summary, final String description) { - Bugsee.upload(summary, description); + public void upload( + final String summary, + final String description, + final double severity, + @Nullable final ReadableArray labels + ) { + try { + Bugsee.upload( + summary, + description, + ReportArgs.severity((int) severity), + ReportArgs.labels(labels == null ? null : labels.toArrayList())); + } catch (final RuntimeException e) { + Log.e(TAG, "upload failed", e); + } + } + + /** + * On the UI thread: the dialog presents from there. Same argument + * mapping as {@link #upload}. Does nothing before launch -- the SDK + * logs and returns. The dialog runs {@code onBeforeReportCreated} + * before it opens. + */ + @Override + public void showReportDialog( + @Nullable final String summary, + @Nullable final String description, + final double severity, + @Nullable final ReadableArray labels + ) { + try { + final IssueSeverity sev = ReportArgs.severity((int) severity); + final ArrayList labelList = + ReportArgs.labels(labels == null ? null : labels.toArrayList()); + UiThreadUtil.runOnUiThread(() -> { + try { + Bugsee.showReportDialog(summary, description, sev, labelList); + } catch (final RuntimeException e) { + Log.e(TAG, "showReportDialog failed", e); + } + }); + } catch (final RuntimeException e) { + Log.e(TAG, "showReportDialog failed", e); + } } /** diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReportArgs.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReportArgs.java new file mode 100644 index 00000000..1e66eb4c --- /dev/null +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReportArgs.java @@ -0,0 +1,56 @@ +package com.bugsee.reactnative; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.bugsee.library.contracts.options.IssueSeverity; + +import java.util.ArrayList; +import java.util.List; + +/** + * Turns the numbers and lists JS sends for a manual report into the types + * the Android SDK expects. + * + *

Severity is the enum's internal value, never its ordinal. + * {@code IssueSeverity.fromIntValue} maps anything outside 1..5 to + * {@code VeryLow}, so the range is checked here first: {@code 0} and anything + * else become null and the SDK applies its own default. Only a value outside + * that sentinel is logged; {@code 0} is the wire's way of saying "default". + */ +final class ReportArgs { + + private static final String TAG = "BugseeRN"; + + private ReportArgs() { + } + + /** 0 and anything outside 1..5 are null (the SDK default). 1..5 map by value. */ + @Nullable + static IssueSeverity severity(final int wire) { + if (wire == 0) { + return null; + } + if (wire >= 1 && wire <= 5) { + return IssueSeverity.fromIntValue(wire); + } + Log.w(TAG, "severity " + wire + " is outside 1..5; the SDK default applies"); + return null; + } + + /** null stays null. Anything that is not a string is dropped. */ + @Nullable + static ArrayList labels(@Nullable final List wire) { + if (wire == null) { + return null; + } + final ArrayList result = new ArrayList<>(); + for (final Object item : wire) { + if (item instanceof String) { + result.add((String) item); + } + } + return result; + } +} diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReportArgsTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReportArgsTest.java new file mode 100644 index 00000000..ceace960 --- /dev/null +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReportArgsTest.java @@ -0,0 +1,62 @@ +package com.bugsee.reactnative; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.bugsee.library.contracts.options.IssueSeverity; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Severity crosses as the enum's internal value, never its ordinal. + * {@code values()[4]} is Blocker; value 4 is Critical. + */ +public class ReportArgsTest { + + @Test + public void zeroIsTheSdkDefault() { + assertNull(ReportArgs.severity(0)); + } + + @Test + public void severityIsByValueNotOrdinal() { + assertEquals(IssueSeverity.Critical, ReportArgs.severity(4)); + assertEquals(IssueSeverity.VeryLow, ReportArgs.severity(1)); + assertEquals(IssueSeverity.Blocker, IssueSeverity.values()[4]); + assertEquals(IssueSeverity.Medium, IssueSeverity.values()[1]); + } + + @Test + public void outOfRangeIsTheDefault() { + assertNull(ReportArgs.severity(6)); + assertNull(ReportArgs.severity(-1)); + assertNull(ReportArgs.severity(99)); + } + + @Test + public void labelsBecomeAnArrayList() { + final ArrayList labels = ReportArgs.labels(Arrays.asList("a", "b")); + assertEquals(Arrays.asList("a", "b"), labels); + assertTrue(labels instanceof ArrayList); + } + + @Test + public void nullLabelsStayNull() { + assertNull(ReportArgs.labels(null)); + } + + @Test + public void nonStringLabelsAreDropped() { + final List mixed = new ArrayList<>(); + mixed.add("keep"); + mixed.add(1); + mixed.add(null); + mixed.add("also"); + assertEquals(Arrays.asList("keep", "also"), ReportArgs.labels(mixed)); + } +} diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 575e3f4c..c35c97b8 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -29,6 +29,7 @@ #import #import #import +#import #else #import "BGSRNMainThread.h" #import "BGSRNWrapper.h" @@ -47,6 +48,7 @@ #import "BGSRNReactWindow.h" #import "BGSRNExceptions.h" #import "BGSRNSettleOnce.h" +#import "BGSRNReportArgs.h" #endif /// The conformance lives here rather than in the Support package so that the @@ -608,14 +610,44 @@ - (void)logUnhandledException:(NSString *)payloadJson }); } -/// The two-argument form only -- severity and labels are Phase 8. JS has -/// already checked both arguments are strings. On main, like every other SDK -/// entry point; the report it creates is a LIVE one, so its handlers run on -/// main too, after this returns. +/// JS has already checked both strings, the severity range and the labels. +/// Severity 0 is resolved here: the requested value, else the launch option +/// `BugseeOptionReportingDefaultBugPriority`, else High. On main, like every +/// other SDK entry point. Does nothing before launch -- the SDK returns. +/// The report it creates is a LIVE one, so its handlers run on main too, +/// after this returns. - (void)upload:(NSString *)summary - description:(NSString *)description { + description:(NSString *)description + severity:(double)severity + labels:(NSArray *)labels { BGSRNRunOnMain(^{ - [Bugsee uploadWithSummary:summary description:description]; + [Bugsee uploadWithSummary:summary + description:description + severity:(BugseeSeverityLevel)BGSRNUploadSeverity((NSInteger)severity, + [Bugsee getLaunchOptions]) + labels:BGSRNStringArray(labels)]; + }); +} + +/// Every argument absent (`nil`, `nil`, `0`, `nil`) calls the no-argument +/// dialog. Otherwise an omitted summary or description stays `nil`: beta3 +/// writes the field only when the pointer is non-nil, and `@""` would +/// pre-fill an empty string. A 0 severity is left for the SDK to skip. +/// On main. Does nothing before launch. The dialog runs +/// `onBeforeReportCreated` before it opens. +- (void)showReportDialog:(NSString *)summary + description:(NSString *)description + severity:(double)severity + labels:(NSArray *)labels { + BGSRNRunOnMain(^{ + if (summary == nil && description == nil && severity == 0.0 && labels == nil) { + [Bugsee showReportDialog]; + return; + } + [Bugsee showReportDialogWithSummary:summary + description:description + severity:(BugseeSeverityLevel)severity + labels:BGSRNStringArray(labels)]; }); } diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReportArgs.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReportArgs.m new file mode 100644 index 00000000..f8719d42 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReportArgs.m @@ -0,0 +1,47 @@ +#import "include/BGSRNReportArgs.h" + +#import + +const NSInteger BGSRNDefaultBugPriorityFallback = 3; + +static BOOL BGSRNIntegralSeverity(id raw, NSInteger *out) { + if (![raw isKindOfClass:[NSNumber class]]) { + return NO; + } + // A boolean is an NSNumber whose integer value is 0 or 1. It is not a + // severity the launch option was given as. + if (CFGetTypeID((__bridge CFTypeRef)raw) == CFBooleanGetTypeID()) { + return NO; + } + const double value = [(NSNumber *)raw doubleValue]; + if (value < 1.0 || value > 5.0 || value != (NSInteger)value) { + return NO; + } + *out = (NSInteger)value; + return YES; +} + +NSInteger BGSRNUploadSeverity(NSInteger requested, NSDictionary *_Nullable launchOptions) { + if (requested >= 1 && requested <= 5) { + return requested; + } + NSInteger option = 0; + id raw = launchOptions[BugseeOptionReportingDefaultBugPriority]; + if (BGSRNIntegralSeverity(raw, &option)) { + return option; + } + return BGSRNDefaultBugPriorityFallback; +} + +NSArray *_Nullable BGSRNStringArray(NSArray *_Nullable raw) { + if (raw == nil) { + return nil; + } + NSMutableArray *result = [NSMutableArray arrayWithCapacity:raw.count]; + for (id item in raw) { + if ([item isKindOfClass:[NSString class]]) { + [result addObject:item]; + } + } + return result; +} diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReportArgs.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReportArgs.h new file mode 100644 index 00000000..a8fc63ff --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReportArgs.h @@ -0,0 +1,17 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// High (`BugseeSeverityHigh`, 3). Used when neither the requested severity +/// nor the launch option is an integer 1..5. +FOUNDATION_EXPORT const NSInteger BGSRNDefaultBugPriorityFallback; + +/// `requested` when it is 1..5; otherwise +/// `launchOptions[BugseeOptionReportingDefaultBugPriority]` when that value +/// is an integer 1..5; otherwise `BGSRNDefaultBugPriorityFallback`. +FOUNDATION_EXPORT NSInteger BGSRNUploadSeverity(NSInteger requested, NSDictionary *_Nullable launchOptions); + +/// nil for nil; the NSString elements otherwise. Anything else is dropped. +FOUNDATION_EXPORT NSArray *_Nullable BGSRNStringArray(NSArray *_Nullable raw); + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReportArgsTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReportArgsTests.m new file mode 100644 index 00000000..cc72d252 --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReportArgsTests.m @@ -0,0 +1,51 @@ +@import XCTest; +@import Bugsee; +@import BugseeRNSupport; + +/// Upload severity is the requested value when it is 1..5, otherwise the +/// launch option, otherwise High (3). A nil string array stays nil. +@interface BGSRNReportArgsTests : XCTestCase +@end + +@implementation BGSRNReportArgsTests + +- (void)testARequestedSeverityWins { + NSDictionary *options = @{BugseeOptionReportingDefaultBugPriority : @(BugseeSeverityLow)}; + XCTAssertEqual(BGSRNUploadSeverity(BugseeSeverityCritical, options), BugseeSeverityCritical); + XCTAssertEqual(BGSRNUploadSeverity(BugseeSeverityLow, nil), BugseeSeverityLow); + XCTAssertEqual(BGSRNDefaultBugPriorityFallback, 3); +} + +- (void)testZeroTakesTheLaunchOption { + NSDictionary *options = + @{BugseeOptionReportingDefaultBugPriority : @(BugseeSeverityCritical)}; + XCTAssertEqual(BGSRNUploadSeverity(0, options), BugseeSeverityCritical); +} + +- (void)testZeroWithoutTheOptionIsHigh { + XCTAssertEqual(BGSRNUploadSeverity(0, @{}), BugseeSeverityHigh); + XCTAssertEqual(BGSRNUploadSeverity(0, nil), BugseeSeverityHigh); +} + +- (void)testAnOutOfRangeOptionIsHigh { + XCTAssertEqual(BGSRNUploadSeverity(0, @{BugseeOptionReportingDefaultBugPriority : @(6)}), + BugseeSeverityHigh); + XCTAssertEqual(BGSRNUploadSeverity(0, @{BugseeOptionReportingDefaultBugPriority : @(0)}), + BugseeSeverityHigh); + XCTAssertEqual(BGSRNUploadSeverity(0, @{BugseeOptionReportingDefaultBugPriority : @(2.5)}), + BugseeSeverityHigh); + XCTAssertEqual(BGSRNUploadSeverity(0, @{BugseeOptionReportingDefaultBugPriority : @"4"}), + BugseeSeverityHigh); +} + +- (void)testStringArrayKeepsStrings { + NSArray *mixed = @[ @"a", @1, @"b" ]; + XCTAssertEqualObjects(BGSRNStringArray(mixed), (@[ @"a", @"b" ])); + XCTAssertEqualObjects(BGSRNStringArray(@[ @"only" ]), (@[ @"only" ])); +} + +- (void)testStringArrayOfNilIsNil { + XCTAssertNil(BGSRNStringArray(nil)); +} + +@end diff --git a/packages/react-native/src/NativeBugsee.ts b/packages/react-native/src/NativeBugsee.ts index 5f260166..502c6861 100644 --- a/packages/react-native/src/NativeBugsee.ts +++ b/packages/react-native/src/NativeBugsee.ts @@ -129,15 +129,29 @@ export interface Spec extends TurboModule { /** * Creates and uploads a bug report immediately, without showing any UI. * - * Pulled forward from Phase 8 (Task 3.4c): the report-handler device tests - * need a LIVE report that JS can trigger, and nothing else in the facade - * creates one this early. The two-argument form only, matching - * `Bugsee.upload(summary, description)` on Android; severity and labels - * are Phase 8. Fire-and-forget like `setSecureRectangles` -- the SDK - * assembles and uploads the report from its own capture buffer in the - * background, so there is nothing to await. + * `severity` `0` is the SDK default (Android passes null; iOS resolves the + * launch option, or High). `labels` `null` means none were given. + * Fire-and-forget like `setSecureRectangles` -- the SDK assembles and + * uploads the report from its own capture buffer in the background, so + * there is nothing to await. Does nothing before `launch()`. */ - upload(summary: string, description: string): void; + upload( + summary: string, + description: string, + severity: number, + labels: string[] | null, + ): void; + /** + * Shows the bug-report dialog. Null summary and description, severity `0` + * and null labels mean every argument was absent. Does nothing before + * `launch()`. The dialog runs `onBeforeReportCreated` before it opens. + */ + showReportDialog( + summary: string | null, + description: string | null, + severity: number, + labels: string[] | null, + ): void; /** * Records a named event, with optional params. diff --git a/packages/react-native/src/__mocks__/native.ts b/packages/react-native/src/__mocks__/native.ts index aadf9d36..49afd95b 100644 --- a/packages/react-native/src/__mocks__/native.ts +++ b/packages/react-native/src/__mocks__/native.ts @@ -130,7 +130,9 @@ export const native = { getStatus: jest.fn, []>(), getLaunchOptions: jest.fn>, []>(), testCrash: jest.fn(), - upload: jest.fn(), + upload: jest.fn(), + showReportDialog: + jest.fn(), wrapperLog: jest.fn(), /** `paramsJson` is JSON text (`encodeBridgeObject`), or null for no params. */ event: jest.fn(), diff --git a/packages/react-native/src/__tests__/report-dialog.test.ts b/packages/react-native/src/__tests__/report-dialog.test.ts new file mode 100644 index 00000000..74cd1847 --- /dev/null +++ b/packages/react-native/src/__tests__/report-dialog.test.ts @@ -0,0 +1,85 @@ +// The package entry now exports the options model, which reads Platform.OS, +// so importing it loads `react-native` -- which jest cannot parse. Mocked to a +// known platform; this suite is about `showReportDialog`, not about which one. +jest.mock('react-native', () => ({ Platform: { OS: 'ios' } })); + +import Bugsee, { IssueSeverity } from '../index'; +import { native } from '../__mocks__/native'; + +jest.mock('../NativeBugsee', () => require('../__mocks__/native').nativeMock); + +beforeEach(() => native.reset()); + +describe('showReportDialog', () => { + it('no arguments crosses null, null, 0, null', () => { + Bugsee.showReportDialog(); + expect(native.showReportDialog).toHaveBeenCalledWith(null, null, 0, null); + }); + + it('every argument crosses in order', () => { + const labels = ['dlg', 'two']; + Bugsee.showReportDialog('sum', 'desc', IssueSeverity.Critical, labels); + expect(native.showReportDialog).toHaveBeenCalledWith('sum', 'desc', 4, [ + 'dlg', + 'two', + ]); + const crossed = native.showReportDialog.mock.calls[0]?.[3]; + expect(crossed).not.toBe(labels); + }); + + it('a non-string summary or description throws before crossing', () => { + expect(() => Bugsee.showReportDialog(42 as never)).toThrow( + new TypeError( + 'Bugsee.showReportDialog requires summary to be a string, got number', + ), + ); + expect(() => Bugsee.showReportDialog('ok', 42 as never)).toThrow( + new TypeError( + 'Bugsee.showReportDialog requires description to be a string, got number', + ), + ); + for (const bad of [null, {}, []]) { + expect(() => Bugsee.showReportDialog(bad as never)).toThrow(TypeError); + expect(() => Bugsee.showReportDialog('ok', bad as never)).toThrow( + TypeError, + ); + } + expect(native.showReportDialog).not.toHaveBeenCalled(); + }); + + it("severity and labels are validated as upload's", () => { + expect(() => + Bugsee.showReportDialog('s', 'd', 0 as unknown as IssueSeverity), + ).toThrow(RangeError); + expect(() => + Bugsee.showReportDialog('s', 'd', 6 as unknown as IssueSeverity), + ).toThrow(RangeError); + expect(() => + Bugsee.showReportDialog('s', 'd', 2.5 as unknown as IssueSeverity), + ).toThrow(RangeError); + expect(() => + Bugsee.showReportDialog('s', 'd', 'high' as unknown as IssueSeverity), + ).toThrow(TypeError); + expect(() => + Bugsee.showReportDialog( + 's', + 'd', + IssueSeverity.High, + 'nope' as unknown as string[], + ), + ).toThrow(TypeError); + expect(() => + Bugsee.showReportDialog('s', 'd', IssueSeverity.High, [1] as never), + ).toThrow(TypeError); + const secret = 'SECRET_DIALOG_LABEL'; + expect(() => + Bugsee.showReportDialog('s', 'd', IssueSeverity.High, [secret, 1] as never), + ).toThrow(TypeError); + try { + Bugsee.showReportDialog('s', 'd', IssueSeverity.High, [secret, 1] as never); + } catch (error) { + expect((error as Error).message).not.toContain(secret); + } + expect(native.showReportDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native/src/__tests__/upload.test.ts b/packages/react-native/src/__tests__/upload.test.ts index c6df4d05..36eb58c6 100644 --- a/packages/react-native/src/__tests__/upload.test.ts +++ b/packages/react-native/src/__tests__/upload.test.ts @@ -3,7 +3,7 @@ // known platform; this suite is about `upload`, not about which one. jest.mock('react-native', () => ({ Platform: { OS: 'ios' } })); -import Bugsee from '../index'; +import Bugsee, { IssueSeverity } from '../index'; import { native } from '../__mocks__/native'; jest.mock('../NativeBugsee', () => require('../__mocks__/native').nativeMock); @@ -11,9 +11,142 @@ jest.mock('../NativeBugsee', () => require('../__mocks__/native').nativeMock); beforeEach(() => native.reset()); describe('upload', () => { - it('forwards summary and description', () => { + it('the two-argument form crosses severity 0 and null labels', () => { Bugsee.upload('the summary', 'the description'); - expect(native.upload).toHaveBeenCalledWith('the summary', 'the description'); + expect(native.upload).toHaveBeenCalledWith( + 'the summary', + 'the description', + 0, + null, + ); + }); + + it('severity crosses by value', () => { + Bugsee.upload('s', 'd', IssueSeverity.Critical); + expect(native.upload).toHaveBeenCalledWith('s', 'd', 4, null); + native.reset(); + Bugsee.upload('s', 'd', IssueSeverity.VeryLow); + expect(native.upload).toHaveBeenCalledWith('s', 'd', 1, null); + native.reset(); + Bugsee.upload('s', 'd', IssueSeverity.Blocker); + expect(native.upload).toHaveBeenCalledWith('s', 'd', 5, null); + }); + + it('labels cross as a copy', () => { + const labels = ['alpha', 'beta']; + Bugsee.upload('s', 'd', IssueSeverity.High, labels); + const crossed = native.upload.mock.calls[0]?.[3]; + expect(crossed).toEqual(['alpha', 'beta']); + expect(crossed).not.toBe(labels); + labels.push('gamma'); + expect(crossed).toEqual(['alpha', 'beta']); + }); + + it('rejects severity 0, 6, 2.5 and a string before crossing', () => { + const message = 'Bugsee.upload severity must be an integer 1..5'; + expect(() => + Bugsee.upload('s', 'd', 0 as unknown as IssueSeverity), + ).toThrow(new RangeError(message)); + expect(() => + Bugsee.upload('s', 'd', 6 as unknown as IssueSeverity), + ).toThrow(new RangeError(message)); + expect(() => + Bugsee.upload('s', 'd', 2.5 as unknown as IssueSeverity), + ).toThrow(new RangeError(message)); + expect(() => + Bugsee.upload('s', 'd', 'high' as unknown as IssueSeverity), + ).toThrow(new TypeError(message)); + expect(native.upload).not.toHaveBeenCalled(); + }); + + it('rejects labels that are not strings before crossing', () => { + const message = 'Bugsee.upload labels must be an array of strings'; + expect(() => + Bugsee.upload('s', 'd', undefined, 'nope' as unknown as string[]), + ).toThrow(new TypeError(message)); + expect(() => + Bugsee.upload('s', 'd', undefined, [1] as unknown as string[]), + ).toThrow(new TypeError(message)); + expect(() => + Bugsee.upload('s', 'd', undefined, null as unknown as string[]), + ).toThrow(new TypeError(message)); + expect(native.upload).not.toHaveBeenCalled(); + }); + + it('a fifth argument throws TypeError and crosses nothing', () => { + expect(() => + (Bugsee.upload as (...args: unknown[]) => void)( + 'summary', + 'description', + IssueSeverity.High, + ['a'], + true, + ), + ).toThrow( + new TypeError( + 'Bugsee.upload takes at most four arguments; 7.x has no includeVideo', + ), + ); + expect(native.upload).not.toHaveBeenCalled(); + }); + + it('upload has no fifth parameter', () => { + Bugsee.upload('summary', 'description', IssueSeverity.High, ['label']); + expect(() => { + // @ts-expect-error upload has no fifth parameter + Bugsee.upload('summary', 'description', IssueSeverity.High, ['label'], true); + }).toThrow(TypeError); + }); + + it('no message contains the rejected value', () => { + const severitySecret = 'SECRET_SEVERITY_VALUE'; + expect(() => + Bugsee.upload('summary', 'description', severitySecret as never), + ).toThrow(TypeError); + try { + Bugsee.upload('summary', 'description', severitySecret as never); + } catch (error) { + expect((error as Error).message).not.toContain(severitySecret); + } + + const labelSecret = 'SECRET_LABEL_VALUE'; + expect(() => + Bugsee.upload('summary', 'description', IssueSeverity.High, [ + labelSecret, + 1, + ] as never), + ).toThrow(TypeError); + try { + Bugsee.upload('summary', 'description', IssueSeverity.High, [ + labelSecret, + 1, + ] as never); + } catch (error) { + expect((error as Error).message).not.toContain(labelSecret); + } + + const fifth = 'SECRET_FIFTH_ARGUMENT'; + expect(() => + (Bugsee.upload as (...args: unknown[]) => void)( + 'summary', + 'description', + 1, + ['a'], + fifth, + ), + ).toThrow(TypeError); + try { + (Bugsee.upload as (...args: unknown[]) => void)( + 'summary', + 'description', + 1, + ['a'], + fifth, + ); + } catch (error) { + expect((error as Error).message).not.toContain(fifth); + } + expect(native.upload).not.toHaveBeenCalled(); }); // The signature says string, but the call arrives from untyped JS just as @@ -39,8 +172,4 @@ describe('upload', () => { }, ); - // The two-argument form only -- severity and labels are Phase 8. - it('is called with exactly two arguments', () => { - expect(Bugsee.upload.length).toBe(2); - }); }); diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 0c1c1855..dc2729f6 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -12,7 +12,8 @@ import type { LifecycleEvent } from './wrapper/events'; import { setReportHandler as installReportHandler } from './report/dispatcher'; import type { BugseeReportHandler } from './report/types'; import { forwardLog } from './wrapper/channel'; -import { LogLevel } from './options/enums'; +import { type IssueSeverity, LogLevel } from './options/enums'; +import { labelsArgument, severityArgument } from './report/fields'; import { assertEventOrTraceName, assertTraceValue, @@ -274,14 +275,25 @@ class Bugsee { /** * Creates and uploads a bug report immediately, without showing any UI. * - * Pulled forward from Phase 8 (Task 3.4c) as the live-report trigger the - * report-handler tests need. The two-argument form only -- severity and - * labels are Phase 8. `summary` and `description` must both be strings - * (an empty string is fine; only the type is checked) since a call this - * unstructured arrives from untyped JS as often as from TypeScript, and a - * non-string reaching the bridge would fail as something less legible. + * Does nothing before `launch()`. `showReportDialog` runs + * `onBeforeReportCreated` before the dialog opens. + * + * `summary` and `description` must both be strings (an empty string is + * fine; only the type is checked). Omitted `severity` crosses as `0`, + * which is the SDK's default. Omitted `labels` cross as `null`. There is + * no `includeVideo`: a fifth argument throws `TypeError`. */ - upload(summary: string, description: string): void { + upload( + summary: string, + description: string, + severity?: IssueSeverity, + labels?: readonly string[], + ): void { + if (arguments.length > 4) { + throw new TypeError( + 'Bugsee.upload takes at most four arguments; 7.x has no includeVideo', + ); + } if (typeof summary !== 'string') { throw new TypeError( `Bugsee.upload requires summary to be a string, got ${typeof summary}`, @@ -292,7 +304,38 @@ class Bugsee { `Bugsee.upload requires description to be a string, got ${typeof description}`, ); } - NativeBugsee.upload(summary, description); + NativeBugsee.upload( + summary, + description, + severityArgument(severity, 'upload'), + labelsArgument(labels, 'upload'), + ); + } + + /** + * Shows the bug-report dialog. + * + * Does nothing before `launch()`. The dialog runs `onBeforeReportCreated` + * before it opens. + * + * `summary` and `description` are omitted or a string, and cross as `null` + * when absent. Severity and labels follow `upload`: omitted severity is + * the SDK default (`0`), omitted labels cross as `null`. + */ + showReportDialog( + summary?: string, + description?: string, + severity?: IssueSeverity, + labels?: readonly string[], + ): void { + const summaryText = optionalReportText(summary, 'summary'); + const descriptionText = optionalReportText(description, 'description'); + NativeBugsee.showReportDialog( + summaryText, + descriptionText, + severityArgument(severity, 'showReportDialog'), + labelsArgument(labels, 'showReportDialog'), + ); } /** @@ -557,6 +600,21 @@ function dropFirstStackFrame(stack: string | undefined): string | undefined { return lines.join('\n'); } +function optionalReportText( + value: unknown, + name: 'summary' | 'description', +): string | null { + if (value === undefined) { + return null; + } + if (typeof value !== 'string') { + throw new TypeError( + `Bugsee.showReportDialog requires ${name} to be a string, got ${typeof value}`, + ); + } + return value; +} + function assertUsableToken(token: string): void { if (typeof token !== 'string' || token.trim().length === 0) { throw new Error('Bugsee.launch requires a non-empty app token'); diff --git a/packages/react-native/src/report/fields.ts b/packages/react-native/src/report/fields.ts new file mode 100644 index 00000000..ba8069b4 --- /dev/null +++ b/packages/react-native/src/report/fields.ts @@ -0,0 +1,40 @@ +/** + * Shared argument checks for `upload` and `showReportDialog`. + * + * Messages name the method and the field. They never include the rejected + * value: a label or a severity string can be something the caller did not + * mean to put in a log. + */ + +/** 0 for undefined; RangeError unless an integer 1..5. A non-number is a TypeError. */ +export function severityArgument(severity: unknown, method: string): number { + if (severity === undefined) { + return 0; + } + if (typeof severity !== 'number') { + throw new TypeError(`Bugsee.${method} severity must be an integer 1..5`); + } + if (!Number.isInteger(severity) || severity < 1 || severity > 5) { + throw new RangeError(`Bugsee.${method} severity must be an integer 1..5`); + } + return severity; +} + +/** null for undefined; TypeError unless an array of strings; a fresh copy. */ +export function labelsArgument( + labels: unknown, + method: string, +): string[] | null { + if (labels === undefined) { + return null; + } + if (!Array.isArray(labels)) { + throw new TypeError(`Bugsee.${method} labels must be an array of strings`); + } + for (const label of labels) { + if (typeof label !== 'string') { + throw new TypeError(`Bugsee.${method} labels must be an array of strings`); + } + } + return [...labels]; +}