diff --git a/docs/design/plans/2026-09-16-implementation-plan.md b/docs/design/plans/2026-09-16-implementation-plan.md index d57be30..d14913a 100644 --- a/docs/design/plans/2026-09-16-implementation-plan.md +++ b/docs/design/plans/2026-09-16-implementation-plan.md @@ -4057,6 +4057,10 @@ Review fix: `finish` drops the handle it was called with even when `isFinished` `appearance` over two mapping tables — Android is keyed (`setColor(ReportAppearance.X, int)`), iOS is property-based on `BugseeTheme`. `deleteCollectedDataOnDevice(includingIntermediate)`. Feedback appearance is **not** here; it ships with the feedback package, since `FeedbackAppearance` lives in the feedback artifact on Android. +The tables are the 7.x report colors: Android `Report::…` constants from bugsee-android 7.3.0 (`ReportAppearance`), iOS writable `report*Color` properties on `BugseeTheme` in the vendored 7.0.0-beta3 header. The 6.x Android names (`ReportBackgroundColor`, no `Report::` prefix) are not used. String placeholders stay off this surface (`setString`, not `setColor`). + +Device: WOD_LX1 `AMRJCP4718402860`, Debug, logged `BUGSEE_E2E appearance nonce=97aed4863fa7 background=#ff0000ff includingIntermediate=true deleted=false` (pass: the SDK read `#ff0000ff` back). iOS Simulator `6FA9B3E8-26C7-4232-AA2C-537D9DF32957`, Debug, logged `BUGSEE_E2E appearance nonce=603f8a16a924 background=#ff0000ff includingIntermediate=true deleted=false` (pass, same read-back; SDK `7.0.0-beta3` `0d9c9d0a-9`). `deleted=false` is the SDK refusing the deletion while launched. iPhone XS (KRSFT) was already running BareExample (pid 43437); waited and retried once, and that process was still there, so that run was not taken. + --- ## Phase 12 — Feedback package diff --git a/examples/bare/App.tsx b/examples/bare/App.tsx index f8e8d08..11c2fa8 100644 --- a/examples/bare/App.tsx +++ b/examples/bare/App.tsx @@ -113,6 +113,7 @@ import { } from './scenarios/add-network-event'; import { isFeedbackScenario, runFeedbackScenario } from './scenarios/feedback'; import { isApmScenario, runApmScenario } from './scenarios/apm'; +import { isAppearanceScenario, runAppearanceScenario } from './scenarios/appearance'; const STATUS_NAMES: Record = { [Status.Stopped]: 'Stopped', @@ -499,6 +500,11 @@ export default function App() { return; } + if (isAppearanceScenario(choice.scenario)) { + await runAppearanceScenario(choice.scenario, choice.nonce); + return; + } + if (isExceptionScenario(choice.scenario)) { if (isExceptionRenderScenario(choice.scenario)) { if (!cancelled) { diff --git a/examples/bare/e2e/appearance.test.ts b/examples/bare/e2e/appearance.test.ts new file mode 100644 index 0000000..653d91f --- /dev/null +++ b/examples/bare/e2e/appearance.test.ts @@ -0,0 +1,95 @@ +/** + * Phase 11: appearance is written and read back, then collected data is + * deleted, after launch. A second launch stops the SDK and deletes again. + * + * Markers, from scenarios/appearance.ts: + * BUGSEE_E2E appearance nonce= background=#ff0000ff includingIntermediate=true deleted=false + * BUGSEE_E2E appearance nonce= stopped=true includingIntermediate=true deleted=true + * + * The color in the launched line is what the SDK returned. While launched the + * SDK refuses deletion. After stop it must succeed. `duration` stays 90. + */ +import { terminateIosApp } from './bundles'; +import { ANDROID_PACKAGE } from './device'; +import { + ON_IOS, + type Run, + TARGET_NAME, + describeDevice, + must, + report, + startDeviceLog, + startRun, + stopDeviceLog, +} from './harness'; +import { type DeviceLog, adb, resetScenario } from './scenario'; + +jest.setTimeout(5 * 60_000); + +describeDevice(`appearance and data deletion on ${TARGET_NAME}`, () => { + let log: DeviceLog | undefined; + + beforeAll(async () => { + log = await startDeviceLog('11', '11'); + }); + + afterAll(async () => { + try { + if (ON_IOS) { + await terminateIosApp(); + } else { + await adb('shell', 'am', 'force-stop', ANDROID_PACKAGE).catch(() => {}); + } + } finally { + stopDeviceLog(log); + resetScenario(); + } + }); + + async function launch(name: string): Promise { + const run = await startRun(name); + report('banner', run.banner.text.trim()); + report('build', run.dev ? 'Debug' : 'Release'); + return run; + } + + it('reads the stored color back and records that deletion was refused', async () => { + const run = await launch('appearance'); + const nonce = run.scenario.nonce; + const found = must( + await log!.waitFor( + new RegExp( + `BUGSEE_E2E appearance nonce=${nonce} background=#ff0000ff includingIntermediate=true deleted=false`, + ), + 20_000, + run.launched.index, + ), + 'the launched appearance line with deleted=false', + run.start, + ); + expect(found.text).toContain(`nonce=${nonce}`); + expect(found.text).toContain('background=#ff0000ff'); + expect(found.text).toContain('includingIntermediate=true'); + expect(found.text).toContain('deleted=false'); + }); + + it('deletes collected data after stop', async () => { + const run = await launch('appearance-stopped'); + const nonce = run.scenario.nonce; + const found = must( + await log!.waitFor( + new RegExp( + `BUGSEE_E2E appearance nonce=${nonce} stopped=true includingIntermediate=true deleted=`, + ), + 20_000, + run.launched.index, + ), + 'the stop-then-delete appearance line', + run.start, + ); + expect(found.text).toContain(`nonce=${nonce}`); + expect(found.text).toContain('stopped=true'); + expect(found.text).toContain('includingIntermediate=true'); + expect(found.text).toContain('deleted=true'); + }); +}); diff --git a/examples/bare/scenarios/appearance.ts b/examples/bare/scenarios/appearance.ts new file mode 100644 index 0000000..c89fd23 --- /dev/null +++ b/examples/bare/scenarios/appearance.ts @@ -0,0 +1,40 @@ +/** + * Phase 11: a report color and a collected-data deletion, after launch. + * + * `backgroundColor` exists on both platforms. The getter reads the SDK + * back, so the logged color is what was stored, not the string we wrote. + * `includingIntermediate` is the flag handed to + * `deleteCollectedDataOnDevice`. While launched the SDK refuses the + * deletion; the line still records the flag that crossed. + */ +import Bugsee from '@bugsee/react-native'; + +export const APPEARANCE_SCENARIOS = ['appearance', 'appearance-stopped'] as const; + +export type AppearanceScenario = (typeof APPEARANCE_SCENARIOS)[number]; + +export function isAppearanceScenario(name: string): name is AppearanceScenario { + return (APPEARANCE_SCENARIOS as readonly string[]).includes(name); +} + +const COLOR = '#ff0000ff'; + +export async function runAppearanceScenario( + scenario: AppearanceScenario, + nonce: string, +): Promise { + if (scenario === 'appearance-stopped') { + await Bugsee.stop(); + const deleted = await Bugsee.deleteCollectedDataOnDevice(true); + console.log( + `BUGSEE_E2E appearance nonce=${nonce} stopped=true includingIntermediate=true deleted=${String(deleted)}`, + ); + return; + } + Bugsee.appearance.backgroundColor = COLOR; + const read = Bugsee.appearance.backgroundColor ?? 'unread'; + const deleted = await Bugsee.deleteCollectedDataOnDevice(true); + console.log( + `BUGSEE_E2E appearance nonce=${nonce} background=${read} includingIntermediate=true deleted=${String(deleted)}`, + ); +} diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/AppearanceBridge.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/AppearanceBridge.java new file mode 100644 index 0000000..a320bc9 --- /dev/null +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/AppearanceBridge.java @@ -0,0 +1,80 @@ +package com.bugsee.reactnative; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bugsee.library.Bugsee; +import com.bugsee.library.contracts.appearance.Appearance; + +import java.util.Locale; + +/** + * Report colors through {@code Bugsee.getAppearance()}. + * + *

{@code property} is a {@code ReportAppearance} constant value, such as + * {@code ReportAppearance.BackgroundColor} ({@code Report::BackgroundColor}). + * Components are 0–255. Android packs them ARGB, alpha in the high byte. + */ +public final class AppearanceBridge { + + private AppearanceBridge() { + } + + public static void setColor( + @NonNull final String property, + final int r, + final int g, + final int b, + final int a + ) { + if (property.length() == 0) { + throw new IllegalArgumentException("report appearance property is empty"); + } + checkComponent(r); + checkComponent(g); + checkComponent(b); + checkComponent(a); + final Appearance appearance = Bugsee.getAppearance(); + appearance.setColor(property, Integer.valueOf(packArgb(r, g, b, a))); + } + + @Nullable + @ColorInt + public static Integer getColor(@NonNull final String property) { + if (property.length() == 0) { + return null; + } + return Bugsee.getAppearance().getColor(property); + } + + /** + * {@code #rrggbbaa}, or {@code ""} when the SDK has no color stored. + * Alpha is last, matching the CSS hex the JS setter accepts. + */ + @NonNull + public static String toHex(@Nullable final Integer color) { + if (color == null) { + return ""; + } + final int packed = color.intValue(); + return String.format( + Locale.US, + "#%02x%02x%02x%02x", + Integer.valueOf((packed >> 16) & 0xff), + Integer.valueOf((packed >> 8) & 0xff), + Integer.valueOf(packed & 0xff), + Integer.valueOf((packed >>> 24) & 0xff) + ); + } + + static int packArgb(final int r, final int g, final int b, final int a) { + return (a & 0xff) << 24 | (r & 0xff) << 16 | (g & 0xff) << 8 | (b & 0xff); + } + + private static void checkComponent(final int value) { + if (value < 0 || value > 255) { + throw new IllegalArgumentException("color component out of range: " + value); + } + } +} 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 f810284..e310949 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 @@ -1546,6 +1546,45 @@ private static Map attributes( } } + // --- Appearance and collected data (Phase 11) --------------------------- + // set/get return a value so codegen runs them on the JS thread, ahead of + // the read that follows a write in the same turn. + + @Override + public boolean setAppearanceColor( + final String name, + final double r, + final double g, + final double b, + final double a + ) { + AppearanceBridge.setColor( + name, + (int) Math.round(r), + (int) Math.round(g), + (int) Math.round(b), + (int) Math.round(a) + ); + return true; + } + + @Override + @NonNull + public String getAppearanceColor(final String name) { + return AppearanceBridge.toHex(AppearanceBridge.getColor(name)); + } + + @Override + public void deleteCollectedDataOnDevice( + final boolean includingIntermediate, + final Promise promise + ) { + Bugsee.deleteCollectedDataOnDevice( + includingIntermediate, + success -> promise.resolve(Boolean.TRUE.equals(success)) + ); + } + /** One JSON value, or null when it is missing, null, an object or an array. */ @Nullable private static Object jsonValue(@Nullable final String json) { diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/AppearanceBridgeTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/AppearanceBridgeTest.java new file mode 100644 index 0000000..4895365 --- /dev/null +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/AppearanceBridgeTest.java @@ -0,0 +1,87 @@ +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.Bugsee; +import com.bugsee.library.contracts.appearance.ReportAppearance; + +import org.junit.Test; + +public class AppearanceBridgeTest { + + @Test + public void setColorStoresTheReportAppearanceConstant() { + final int packed = AppearanceBridge.packArgb(0x11, 0x22, 0x33, 0x44); + AppearanceBridge.setColor(ReportAppearance.BackgroundColor, 0x11, 0x22, 0x33, 0x44); + assertEquals( + Integer.valueOf(packed), + Bugsee.getAppearance().getColor(ReportAppearance.BackgroundColor)); + assertEquals("Report::BackgroundColor", ReportAppearance.BackgroundColor); + assertEquals("#11223344", AppearanceBridge.getColor(ReportAppearance.BackgroundColor) == null + ? "" + : AppearanceBridge.toHex(AppearanceBridge.getColor(ReportAppearance.BackgroundColor))); + } + + @Test + public void eachReportColorConstantRoundTrips() { + final String[] keys = new String[] { + ReportAppearance.ActionBarColor, + ReportAppearance.ActionBarTextColor, + ReportAppearance.ActionBarButtonBackgroundClickedColor, + ReportAppearance.BackgroundColor, + ReportAppearance.EditTextBackgroundColor, + ReportAppearance.HintColor, + ReportAppearance.SeverityLabelActiveColor, + ReportAppearance.TextColor, + ReportAppearance.VersionColor, + }; + int n = 1; + for (final String key : keys) { + AppearanceBridge.setColor(key, n, n + 1, n + 2, 255); + assertEquals( + Integer.valueOf(AppearanceBridge.packArgb(n, n + 1, n + 2, 255)), + AppearanceBridge.getColor(key)); + assertTrue(key.startsWith("Report::")); + n += 3; + } + } + + @Test + public void packArgbUsesAndroidChannelOrder() { + assertEquals(0xFF000000, AppearanceBridge.packArgb(0, 0, 0, 255)); + assertEquals(0xFFFF0000, AppearanceBridge.packArgb(255, 0, 0, 255)); + assertEquals(0x44223311, AppearanceBridge.packArgb(0x22, 0x33, 0x11, 0x44)); + } + + @Test + public void toHexPutsAlphaLast() { + assertEquals("#11223344", AppearanceBridge.toHex(Integer.valueOf(0x44112233))); + assertEquals("", AppearanceBridge.toHex(null)); + } + + @Test + public void anOutOfRangeComponentDoesNotReplaceTheStoredColor() { + AppearanceBridge.setColor(ReportAppearance.TextColor, 1, 2, 3, 4); + final Integer before = Bugsee.getAppearance().getColor(ReportAppearance.TextColor); + try { + AppearanceBridge.setColor(ReportAppearance.TextColor, 256, 0, 0, 255); + throw new AssertionError("expected IllegalArgumentException"); + } catch (final IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("256")); + } + assertEquals(before, Bugsee.getAppearance().getColor(ReportAppearance.TextColor)); + } + + @Test + public void anEmptyPropertyIsRejected() { + try { + AppearanceBridge.setColor("", 0, 0, 0, 255); + throw new AssertionError("expected IllegalArgumentException"); + } catch (final IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("empty")); + } + assertNull(AppearanceBridge.getColor("")); + } +} diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 10f2072..1ec7416 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -2261,6 +2261,130 @@ - (NSDictionary *)spanStartChild:(NSString *)handle return released; } +/// Writable report colors on BugseeTheme (7.0.0-beta3). Feedback properties +/// and the readonly palette are not report appearance. KVC with any other +/// name throws NSUnknownKeyException. +static NSSet *BGSRNReportColorKeys(void) { + static NSSet *keys; + static dispatch_once_t once; + dispatch_once(&once, ^{ + keys = [NSSet setWithArray:@[ + @"reportBackgroundColor", + @"reportCellBackgroundColor", + @"reportCloseButtonColor", + @"reportNavigationBarColor", + @"reportPlaceholderColor", + @"reportSendButtonColor", + @"reportTextColor", + @"reportVersionColor", + ]]; + }); + return keys; +} + +static BOOL BGSRNColorComponentOK(double value) { + return value >= 0.0 && value <= 255.0 && value == floor(value); +} + +static NSString *BGSRNHexFromColor(UIColor *color) { + if (![color isKindOfClass:[UIColor class]]) { + return @""; + } + CGFloat r = 0, g = 0, b = 0, a = 0; + if (![color getRed:&r green:&g blue:&b alpha:&a]) { + return @""; + } + int ri = (int)llround(r * 255.0); + int gi = (int)llround(g * 255.0); + int bi = (int)llround(b * 255.0); + int ai = (int)llround(a * 255.0); + ri = MAX(0, MIN(255, ri)); + gi = MAX(0, MIN(255, gi)); + bi = MAX(0, MIN(255, bi)); + ai = MAX(0, MIN(255, ai)); + return [NSString stringWithFormat:@"#%02x%02x%02x%02x", ri, gi, bi, ai]; +} + +- (NSNumber *)setAppearanceColor:(NSString *)name + r:(double)r + g:(double)g + b:(double)b + a:(double)a { + if (![BGSRNReportColorKeys() containsObject:name]) { + return @NO; + } + if (!BGSRNColorComponentOK(r) || !BGSRNColorComponentOK(g) || + !BGSRNColorComponentOK(b) || !BGSRNColorComponentOK(a)) { + return @NO; + } + UIColor *color = [UIColor colorWithRed:(CGFloat)(r / 255.0) + green:(CGFloat)(g / 255.0) + blue:(CGFloat)(b / 255.0) + alpha:(CGFloat)(a / 255.0)]; + __block BOOL applied = NO; + BGSRNRunOnMainSync(^{ + [[Bugsee getAppearance] setValue:color forKey:name]; + applied = YES; + }); + return @(applied); +} + +- (NSString *)getAppearanceColor:(NSString *)name { + if (![BGSRNReportColorKeys() containsObject:name]) { + return @""; + } + __block NSString *hex = @""; + BGSRNRunOnMainSync(^{ + id value = [[Bugsee getAppearance] valueForKey:name]; + hex = BGSRNHexFromColor(value); + }); + return hex; +} + +/// iOS 7.0.0-beta3 returns without invoking `completion` when the SDK is +/// not stopped, so a launched call resolves `false` immediately. That is the +/// missing completion, not a wait. Stopped, including a nil instance, still +/// calls the SDK method and settles through `BGSRNSettleOnce`: the +/// completion's success, or `false` if the deadline fires first. Status and +/// both SDK calls run on the main queue. A completion that arrives off main +/// hops back before it writes the result the settler resolves. +- (void)deleteCollectedDataOnDevice:(BOOL)includingIntermediate + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + BGSRNRunOnMain(^{ + Bugsee *instance = [Bugsee sharedInstance]; + BugseeStatus status = instance != nil ? instance.status : BugseeStatusStopped; + if (status != BugseeStatusStopped) { + __block BOOL settled = NO; + [Bugsee deleteCollectedDataOnDevice:includingIntermediate completion:^(BOOL success) { + BGSRNRunOnMain(^{ + if (settled) { + return; + } + settled = YES; + resolve(@(success)); + }); + }]; + if (!settled) { + settled = YES; + resolve(@NO); + } + return; + } + __block NSNumber *result = @NO; + dispatch_block_t done = + BGSRNSettleOnce(BGSRNUnhandledCompletionDeadlineMs, dispatch_get_main_queue(), ^{ + resolve(result); + }); + [Bugsee deleteCollectedDataOnDevice:includingIntermediate completion:^(BOOL success) { + BGSRNRunOnMain(^{ + result = @(success); + done(); + }); + }]; + }); +} + - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { auto module = std::make_shared(params); diff --git a/packages/react-native/src/NativeBugsee.ts b/packages/react-native/src/NativeBugsee.ts index 638013e..c06162d 100644 --- a/packages/react-native/src/NativeBugsee.ts +++ b/packages/react-native/src/NativeBugsee.ts @@ -523,6 +523,32 @@ export interface Spec extends TurboModule { * is `statusSet` because `explicit` is a keyword in the ObjC++ spec. */ spanFinish(handle: string, status: number, statusSet: boolean): string[]; + + /** + * One report color. `name` is already the platform's native key + * (`Report::BackgroundColor` on Android, `reportBackgroundColor` on iOS). + * Components are 0–255. + * + * Returns whether the SDK accepted it. The return keeps the call on the + * JS thread: codegen queues a `void` TurboModule method, and a set then + * a read in one turn would read the color before the set ran. + */ + setAppearanceColor(name: string, r: number, g: number, b: number, a: number): boolean; + /** + * The color the SDK has stored for `name`, as `#rrggbbaa`, or `''` when + * none is stored. Same thread rule as {@link setAppearanceColor}. + */ + getAppearanceColor(name: string): string; + /** + * Deletes collected data on the device. `includingIntermediate` is the + * SDK's own flag: Android `Bugsee.deleteCollectedDataOnDevice` and iOS + * `deleteCollectedDataOnDevice:completion:`. + * + * Resolves to the SDK's completion value. Both platforms refuse the + * deletion while launched; the promise still settles (`false`) so a + * caller is not left waiting on a completion the SDK does not invoke. + */ + deleteCollectedDataOnDevice(includingIntermediate: boolean): Promise; } export default TurboModuleRegistry.getEnforcing('Bugsee'); diff --git a/packages/react-native/src/__mocks__/native.ts b/packages/react-native/src/__mocks__/native.ts index 928d220..987f98d 100644 --- a/packages/react-native/src/__mocks__/native.ts +++ b/packages/react-native/src/__mocks__/native.ts @@ -15,6 +15,12 @@ const EMPTY_REPORT_SNAPSHOT = { }; /** What a freshly reset mock resolves, for the calls that return something. */ +/** Synchronous returns. A Promise default would make a boolean read back a Promise. */ +const SYNC_RETURNS: Record = { + setAppearanceColor: true, + getAppearanceColor: '', +}; + const DEFAULTS: Record = { launch: true, relaunch: true, @@ -353,6 +359,10 @@ export const native = { spanStartChild: jest.fn, [string, string, string | null]>(), spanFinish: jest.fn(), + setAppearanceColor: jest.fn(), + getAppearanceColor: jest.fn(), + deleteCollectedDataOnDevice: jest.fn, [boolean]>(), + /** * Resets every mock on this object, found rather than listed. * @@ -379,6 +389,8 @@ export const native = { fn.mockReset(); if (name in DEFAULTS) { fn.mockResolvedValue(DEFAULTS[name]); + } else if (name in SYNC_RETURNS) { + fn.mockReturnValue(SYNC_RETURNS[name]); } } } diff --git a/packages/react-native/src/appearance/__tests__/report.test.ts b/packages/react-native/src/appearance/__tests__/report.test.ts new file mode 100644 index 0000000..589ce2d --- /dev/null +++ b/packages/react-native/src/appearance/__tests__/report.test.ts @@ -0,0 +1,205 @@ +jest.mock('react-native', () => ({ Platform: { OS: 'android' } })); +jest.mock('../../NativeBugsee', () => require('../../__mocks__/native').nativeMock); + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { Platform } from 'react-native'; +import Bugsee from '../../index'; +import { native } from '../../__mocks__/native'; +import { REPORT_APPEARANCE, type ReportAppearanceName } from '../report'; + +/** + * `ReportAppearance` constants in bugsee-android 7.3.0. The 6.x names + * (`ReportActionBarColor`, no `Report::` prefix) are not these. + */ +const ANDROID_ENUM: Record = { + actionBarButtonBackgroundClickedColor: 'Report::ActionBarButtonBackgroundClickedColor', + actionBarColor: 'Report::ActionBarColor', + actionBarTextColor: 'Report::ActionBarTextColor', + backgroundColor: 'Report::BackgroundColor', + editTextBackgroundColor: 'Report::EditTextBackgroundColor', + hintColor: 'Report::HintColor', + severityLabelActiveColor: 'Report::SeverityLabelActiveColor', + textColor: 'Report::TextColor', + versionColor: 'Report::VersionColor', +}; + +/** + * Writable report color properties on `BugseeTheme` in the vendored + * 7.0.0-beta3 header. Feedback properties and the readonly palette are not + * report appearance. + */ +const IOS_PROPERTY: Record = { + backgroundColor: 'reportBackgroundColor', + cellBackgroundColor: 'reportCellBackgroundColor', + closeButtonColor: 'reportCloseButtonColor', + navigationBarColor: 'reportNavigationBarColor', + placeholderColor: 'reportPlaceholderColor', + sendButtonColor: 'reportSendButtonColor', + textColor: 'reportTextColor', + versionColor: 'reportVersionColor', +}; + +const PUBLIC_KEYS = Object.keys(REPORT_APPEARANCE).sort(); + +beforeEach(() => { + native.reset(); + native.setAppearanceColor.mockReturnValue(true); + native.getAppearanceColor.mockReturnValue(''); + (Platform as { OS: string }).OS = 'android'; +}); + +describe('report appearance mapping', () => { + it.each(PUBLIC_KEYS)('%s maps to the Android enum and the iOS property', (key) => { + const binding = REPORT_APPEARANCE[key as ReportAppearanceName] as { + readonly android?: string; + readonly ios?: string; + }; + expect(binding.android).toBe(ANDROID_ENUM[key]); + expect(binding.ios).toBe(IOS_PROPERTY[key]); + }); + + it('covers every 7.x report color and no 6.x Android name', () => { + expect(PUBLIC_KEYS).toEqual( + [...new Set([...Object.keys(ANDROID_ENUM), ...Object.keys(IOS_PROPERTY)])].sort(), + ); + const androidValues = PUBLIC_KEYS.map( + (key) => + (REPORT_APPEARANCE[key as ReportAppearanceName] as { android?: string }).android, + ).filter((value): value is string => value !== undefined); + expect(androidValues.every((value) => value.startsWith('Report::'))).toBe(true); + expect(androidValues).not.toContain('ReportActionBarColor'); + expect(androidValues).not.toContain('ReportBackgroundColor'); + }); + + it('sends the Android enum and the parsed components', () => { + Bugsee.appearance.backgroundColor = '#11223344'; + expect(native.setAppearanceColor).toHaveBeenCalledWith( + 'Report::BackgroundColor', + 0x11, + 0x22, + 0x33, + 0x44, + ); + }); + + it('sends the iOS property name on iOS', () => { + (Platform as { OS: string }).OS = 'ios'; + Bugsee.appearance.backgroundColor = '#abcdef'; + expect(native.setAppearanceColor).toHaveBeenCalledWith( + 'reportBackgroundColor', + 0xab, + 0xcd, + 0xef, + 255, + ); + }); + + it('reads the color the SDK reports', () => { + native.getAppearanceColor.mockReturnValue('#11223344'); + expect(Bugsee.appearance.backgroundColor).toBe('#11223344'); + expect(native.getAppearanceColor).toHaveBeenCalledWith('Report::BackgroundColor'); + }); + + it('rejects an unknown key the same way on both platforms', () => { + const messages: string[] = []; + for (const os of ['android', 'ios']) { + (Platform as { OS: string }).OS = os; + expect(() => { + (Bugsee.appearance as Record).notAReportColor = '#ffffff'; + }).toThrow(RangeError); + try { + (Bugsee.appearance as Record).notAReportColor = '#ffffff'; + } catch (error) { + messages.push((error as Error).message); + } + } + expect(messages[0]).toBe(messages[1]); + expect(messages[0]).toMatch(/notAReportColor/); + expect(native.setAppearanceColor).not.toHaveBeenCalled(); + }); + + it('refuses a color the current platform does not have', () => { + expect(() => { + Bugsee.appearance.cellBackgroundColor = '#ffffff'; + }).toThrow(/not available on android/); + (Platform as { OS: string }).OS = 'ios'; + expect(() => { + Bugsee.appearance.actionBarColor = '#ffffff'; + }).toThrow(/not available on ios/); + expect(native.setAppearanceColor).not.toHaveBeenCalled(); + }); + + it('reads a color the current platform does not have as undefined', () => { + expect(Bugsee.appearance.cellBackgroundColor).toBeUndefined(); + expect(native.getAppearanceColor).not.toHaveBeenCalled(); + (Platform as { OS: string }).OS = 'ios'; + expect(Bugsee.appearance.actionBarColor).toBeUndefined(); + expect(native.getAppearanceColor).not.toHaveBeenCalled(); + }); + + it('lists the report colors and leaves inspection properties alone', () => { + expect(Object.keys(Bugsee.appearance).sort()).toEqual(PUBLIC_KEYS); + expect('backgroundColor' in Bugsee.appearance).toBe(true); + expect(Bugsee.appearance.constructor).toBe(Object); + expect(typeof Bugsee.appearance.toString).toBe('function'); + expect(() => Bugsee.appearance.toString()).not.toThrow(); + expect((Bugsee.appearance as { then?: unknown }).then).toBeUndefined(); + expect(native.getAppearanceColor).not.toHaveBeenCalled(); + }); + + it('refuses a bad color before calling native', () => { + expect(() => { + Bugsee.appearance.backgroundColor = 'red'; + }).toThrow(RangeError); + expect(native.setAppearanceColor).not.toHaveBeenCalled(); + }); +}); + +describe('deleteCollectedDataOnDevice', () => { + it('passes includingIntermediate true through', async () => { + native.deleteCollectedDataOnDevice.mockResolvedValue(true); + await expect(Bugsee.deleteCollectedDataOnDevice(true)).resolves.toBe(true); + expect(native.deleteCollectedDataOnDevice).toHaveBeenCalledWith(true); + }); + + it('passes includingIntermediate false through', async () => { + native.deleteCollectedDataOnDevice.mockResolvedValue(false); + await expect(Bugsee.deleteCollectedDataOnDevice(false)).resolves.toBe(false); + expect(native.deleteCollectedDataOnDevice).toHaveBeenCalledWith(false); + }); + + it('refuses immediately while launched and settles the Stopped path', () => { + const source = readFileSync(join(__dirname, '../../../ios/BugseeModule.mm'), 'utf8'); + const start = source.indexOf('- (void)deleteCollectedDataOnDevice:'); + const end = source.indexOf('- (std::shared_ptr)getTurboModule:', start); + const method = source.slice(start, end); + const hop = method.indexOf('BGSRNRunOnMain('); + const status = method.indexOf('instance.status'); + const nilStopped = method.indexOf('BugseeStatusStopped'); + const launched = method.indexOf('status != BugseeStatusStopped'); + const call = method.indexOf('[Bugsee deleteCollectedDataOnDevice:includingIntermediate'); + const earlyResolve = method.indexOf('resolve(@NO)'); + const launchedReturn = method.indexOf('\n return;', earlyResolve); + const settle = method.indexOf('BGSRNSettleOnce(BGSRNUnhandledCompletionDeadlineMs'); + const stoppedCall = method.indexOf( + '[Bugsee deleteCollectedDataOnDevice:includingIntermediate', + settle, + ); + expect(hop).toBeGreaterThan(-1); + expect(method).not.toContain('BGSRNRunOnMainSync'); + expect(status).toBeGreaterThan(hop); + expect(nilStopped).toBeGreaterThan(status); + expect(launched).toBeGreaterThan(nilStopped); + expect(call).toBeGreaterThan(launched); + expect(earlyResolve).toBeGreaterThan(call); + expect(launchedReturn).toBeGreaterThan(earlyResolve); + expect(settle).toBeGreaterThan(launchedReturn); + expect(method.slice(0, settle)).not.toContain('BGSRNSettleOnce'); + expect(method.slice(0, settle)).not.toContain('BGSRNUnhandledCompletionDeadlineMs'); + expect(stoppedCall).toBeGreaterThan(settle); + expect(method.slice(settle)).toContain('@(success)'); + expect(method).toContain('includingIntermediate'); + }); +}); diff --git a/packages/react-native/src/appearance/report.ts b/packages/react-native/src/appearance/report.ts new file mode 100644 index 0000000..3f14581 --- /dev/null +++ b/packages/react-native/src/appearance/report.ts @@ -0,0 +1,172 @@ +import { Platform } from 'react-native'; + +import NativeBugsee from '../NativeBugsee'; + +/** + * One camelCase name, two native keys. + * + * Android values are the `ReportAppearance` constants in bugsee-android 7.3.0 + * (`setColor(ReportAppearance.X, int)`). iOS values are writable report color + * properties on `BugseeTheme` in the vendored 7.0.0-beta3 header. A name that + * exists on only one platform has only that side. Setting it on the other + * throws, so a color that will not be applied is not stored as if it had been. + * Reading it returns `undefined` and does not call native. + * + * String placeholders (`Report::SummaryPlaceholder`, `reportSummaryPlaceholder`) + * are not colors. Android sets them with `setString`, not `setColor`, so they + * are not on this surface. + * + * Feedback colors are not here. They live in `@bugsee/react-native-feedback`. + */ +export const REPORT_APPEARANCE = { + actionBarButtonBackgroundClickedColor: { + android: 'Report::ActionBarButtonBackgroundClickedColor', + }, + actionBarColor: { android: 'Report::ActionBarColor' }, + actionBarTextColor: { android: 'Report::ActionBarTextColor' }, + backgroundColor: { + android: 'Report::BackgroundColor', + ios: 'reportBackgroundColor', + }, + cellBackgroundColor: { ios: 'reportCellBackgroundColor' }, + closeButtonColor: { ios: 'reportCloseButtonColor' }, + editTextBackgroundColor: { android: 'Report::EditTextBackgroundColor' }, + hintColor: { android: 'Report::HintColor' }, + navigationBarColor: { ios: 'reportNavigationBarColor' }, + placeholderColor: { ios: 'reportPlaceholderColor' }, + sendButtonColor: { ios: 'reportSendButtonColor' }, + severityLabelActiveColor: { android: 'Report::SeverityLabelActiveColor' }, + textColor: { android: 'Report::TextColor', ios: 'reportTextColor' }, + versionColor: { android: 'Report::VersionColor', ios: 'reportVersionColor' }, +} as const; + +export type ReportAppearanceName = keyof typeof REPORT_APPEARANCE; + +export type ReportAppearance = { + [K in ReportAppearanceName]: string | undefined; +}; + +const HEX = /^#?([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +interface Rgba { + r: number; + g: number; + b: number; + a: number; +} + +/** CSS hex: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA` (alpha last). `#RRGGBB` is opaque. */ +function parseReportColor(input: string): Rgba | undefined { + if (typeof input !== 'string') { + return undefined; + } + const match = HEX.exec(input.trim()); + if (match === null) { + return undefined; + } + const body = match[1]; + if (body === undefined) { + return undefined; + } + const hex = + body.length <= 4 + ? body + .split('') + .map((ch) => ch + ch) + .join('') + : body; + const r = Number.parseInt(hex.slice(0, 2), 16); + const g = Number.parseInt(hex.slice(2, 4), 16); + const b = Number.parseInt(hex.slice(4, 6), 16); + const a = hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) : 255; + if ([r, g, b, a].some((n) => Number.isNaN(n))) { + return undefined; + } + return { r, g, b, a }; +} + +function unknownKey(name: string): RangeError { + return new RangeError(`Bugsee appearance ${name} is not a report color`); +} + +function bindingKey( + name: ReportAppearanceName, + platform: string, +): string | undefined { + const binding: { readonly android?: string; readonly ios?: string } = + REPORT_APPEARANCE[name]; + return platform === 'android' ? binding.android : binding.ios; +} + +function read(name: ReportAppearanceName): string | undefined { + const key = bindingKey(name, Platform.OS); + if (key === undefined) { + return undefined; + } + const hex = NativeBugsee.getAppearanceColor(key); + if (typeof hex !== 'string' || hex.length === 0) { + return undefined; + } + return hex; +} + +function write(name: ReportAppearanceName, color: string): void { + if (typeof color !== 'string') { + throw new TypeError( + `Bugsee appearance ${name} requires a hex color string, got ${typeof color}`, + ); + } + const parsed = parseReportColor(color); + if (parsed === undefined) { + throw new RangeError( + `Bugsee appearance ${name} requires a hex color, got ${JSON.stringify(color)}`, + ); + } + const key = bindingKey(name, Platform.OS); + if (key === undefined) { + throw new RangeError( + `Bugsee appearance ${name} is not available on ${Platform.OS}`, + ); + } + const applied = NativeBugsee.setAppearanceColor( + key, + parsed.r, + parsed.g, + parsed.b, + parsed.a, + ); + if (applied !== true) { + throw new Error(`Bugsee appearance ${name} was not applied`); + } +} + +export function createAppearance(): ReportAppearance { + const target = {} as ReportAppearance; + const names = Object.keys(REPORT_APPEARANCE) as ReportAppearanceName[]; + for (const name of names) { + Object.defineProperty(target, name, { + enumerable: true, + configurable: false, + get: () => read(name), + set: (color: string) => { + write(name, color); + }, + }); + } + // Known colors are own properties, so `Object.keys` and `in` see them and + // `constructor` / `toString` / `then` stay on the prototype. The trap only + // refuses a name that is not a report color. + return new Proxy(target, { + set(obj, prop, value: string) { + if ( + typeof prop !== 'string' || + !Object.prototype.hasOwnProperty.call(REPORT_APPEARANCE, prop) + ) { + throw unknownKey(String(prop)); + } + return Reflect.set(obj, prop, value); + }, + }); +} + +export const appearance: ReportAppearance = createAppearance(); diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 0576011..04b555d 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -61,6 +61,8 @@ import { } from './apm/span'; import type { BugseeSpan, BugseeTransaction } from './apm/span'; import type { SpanAttribute } from './apm/types'; +import { appearance as reportAppearance } from './appearance/report'; +import type { ReportAppearance } from './appearance/report'; const DETECT_CRASH_OPTION = 'com.bugsee.option.detect.crash'; @@ -790,6 +792,30 @@ class Bugsee { getActiveSpan(): BugseeSpan | null { return readActiveSpan(); } + + /** + * Report UI colors. One camelCase surface: Android applies + * `setColor(ReportAppearance.X, int)`, iOS sets the matching property on + * `BugseeTheme`. Setting a name that exists on only one platform throws on + * the other. Reading a color this platform does not have returns + * `undefined`. Reading a color it does have returns what the SDK stored. + */ + get appearance(): ReportAppearance { + return reportAppearance; + } + + /** + * Deletes collected data stored on the device. + * + * `includingIntermediate` crosses unchanged: `true` also deletes + * intermediate capture data (snapshots, partial bundles); `false` deletes + * only fully processed data. The SDK must be stopped for the deletion to + * succeed. While launched, both platforms refuse it and this resolves + * `false`. + */ + deleteCollectedDataOnDevice(includingIntermediate: boolean): Promise { + return NativeBugsee.deleteCollectedDataOnDevice(includingIntermediate); + } } /**