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 f1e0af2c..8508bcd0 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 @@ -763,6 +763,58 @@ public void reportAddDataAttachment( } } + // Task 8.2b. No registry yet, so this does not call the SDK: the SDK made none. + @Override + public void createReport(final Promise promise) { + promise.resolve(null); + } + + // Task 8.2b. No created-report handle exists until the registry lands. + @Override + public void createdReportRead(final String handleId, final Promise promise) { + rejectHandleDead(promise); + } + + // Task 8.2b. No created-report handle exists until the registry lands. + @Override + public void createdReportUpdate( + final String handleId, + final String patchJson, + final Promise promise + ) { + rejectHandleDead(promise); + } + + // Task 8.2b. No created-report handle exists until the registry lands. + @Override + public void createdReportAddDataAttachment( + final String handleId, + final String base64, + final String name, + @Nullable final String mimeType, + final Promise promise + ) { + rejectHandleDead(promise); + } + + // Task 8.2b. No created-report handle exists until the registry lands. No move argument. + @Override + public void createdReportAddFileAttachment( + final String handleId, + final String path, + final String name, + @Nullable final String mimeType, + final Promise promise + ) { + rejectHandleDead(promise); + } + + // Task 8.2b. No created-report handle exists until the registry lands. + @Override + public void createdReportUpload(final String handleId, final Promise promise) { + rejectHandleDead(promise); + } + /** * The SDK returns null both for a declined attachment and for a report * that is no longer live. The handle tells the two apart: if it died while diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index c35c97b8..1fe32e22 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -928,6 +928,72 @@ - (void)reportAddDataAttachment:(NSString *)handleId } } +// Task 8.2c. No registry yet, so this does not call the SDK: the SDK made none. +- (void)createReport:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + (void)reject; + resolve(nil); +} + +// Task 8.2c. No created-report handle exists until the registry lands. +- (void)createdReportRead:(NSString *)handleId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + (void)handleId; + (void)resolve; + BGSRNRejectHandleDead(reject); +} + +// Task 8.2c. No created-report handle exists until the registry lands. +- (void)createdReportUpdate:(NSString *)handleId + patchJson:(NSString *)patchJson + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + (void)handleId; + (void)patchJson; + (void)resolve; + BGSRNRejectHandleDead(reject); +} + +// Task 8.2c. No created-report handle exists until the registry lands. +- (void)createdReportAddDataAttachment:(NSString *)handleId + base64:(NSString *)base64 + name:(NSString *)name + mimeType:(NSString * _Nullable)mimeType + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + (void)handleId; + (void)base64; + (void)name; + (void)mimeType; + (void)resolve; + BGSRNRejectHandleDead(reject); +} + +// Task 8.2c. No created-report handle exists until the registry lands. No move argument. +- (void)createdReportAddFileAttachment:(NSString *)handleId + path:(NSString *)path + name:(NSString *)name + mimeType:(NSString * _Nullable)mimeType + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + (void)handleId; + (void)path; + (void)name; + (void)mimeType; + (void)resolve; + BGSRNRejectHandleDead(reject); +} + +// Task 8.2c. No created-report handle exists until the registry lands. +- (void)createdReportUpload:(NSString *)handleId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + (void)handleId; + (void)resolve; + BGSRNRejectHandleDead(reject); +} + - (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 502c6861..6576ec0a 100644 --- a/packages/react-native/src/NativeBugsee.ts +++ b/packages/react-native/src/NativeBugsee.ts @@ -309,6 +309,35 @@ export interface Spec extends TurboModule { mimeType: string | null, ): Promise; + /** + * Opens a report the app fills and then uploads. Resolves `'cr-'`, or + * `null` when the SDK made none. Rejects `E_REPORT_CREATE_BUSY` when one + * created report is already outstanding. + */ + createReport(): Promise; + /** The live state of the created report behind `handleId`, the `BugseeReportSnapshot` wire shape. */ + createdReportRead(handleId: string): Promise; + /** + * Applies a validated patch, as JSON text, the same transport as + * {@link reportUpdate}. + */ + createdReportUpdate(handleId: string, patchJson: string): Promise; + createdReportAddDataAttachment( + handleId: string, + base64: string, + name: string, + mimeType: string | null, + ): Promise; + /** No `move`: a created-report file attachment is copied. */ + createdReportAddFileAttachment( + handleId: string, + path: string, + name: string, + mimeType: string | null, + ): Promise; + /** Uploads the created report. The handle is dead afterwards, whatever the result. */ + createdReportUpload(handleId: string): Promise; + /** A handled JS exception. `payloadJson` is the Task 7.1a payload; `optionsJson` is `{domain?, labels?, includeVideo?}` or null. */ logException(payloadJson: string, optionsJson: string | null): void; /** An unhandled JS exception. Resolves once the SDK has the report; never rejects. */ diff --git a/packages/react-native/src/__mocks__/native.ts b/packages/react-native/src/__mocks__/native.ts index 49afd95b..b9a8fd1d 100644 --- a/packages/react-native/src/__mocks__/native.ts +++ b/packages/react-native/src/__mocks__/native.ts @@ -22,6 +22,7 @@ const DEFAULTS: Record = { getStatus: 0, getLaunchOptions: {}, reportRead: EMPTY_REPORT_SNAPSHOT, + createReport: null, getAttribute: {}, getAllAttributes: {}, getUserIdentifier: {}, @@ -191,6 +192,18 @@ export const native = { reportAddDataAttachment: jest.fn, [string, string, string, string | null]>(), + /** Resolves `null` by default: native made none. */ + createReport: jest.fn, []>(), + createdReportRead: jest.fn, [string]>(), + /** `patchJson` is the validated patch as JSON text (`encodeBridgeObject`). */ + createdReportUpdate: jest.fn, [string, string]>(), + createdReportAddDataAttachment: + jest.fn, [string, string, string, string | null]>(), + /** No `move` argument: a created-report file is copied. */ + createdReportAddFileAttachment: + jest.fn, [string, string, string, string | null]>(), + createdReportUpload: jest.fn, [string]>(), + /** `payloadJson` / `optionsJson` are JSON text (`encodeBridgeObject` / `encodeExceptionOptions`). */ logException: jest.fn(), /** Resolves by default; tests that hang native replace the implementation. */ diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index dc2729f6..93ddd7dd 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -10,7 +10,8 @@ import { Status } from './status'; import { statusForEvent } from './wrapper/events'; import type { LifecycleEvent } from './wrapper/events'; import { setReportHandler as installReportHandler } from './report/dispatcher'; -import type { BugseeReportHandler } from './report/types'; +import { createReport as openCreatedReport } from './report/CreatedReport'; +import type { BugseeCreatedReport, BugseeReportHandler } from './report/types'; import { forwardLog } from './wrapper/channel'; import { type IssueSeverity, LogLevel } from './options/enums'; import { labelsArgument, severityArgument } from './report/fields'; @@ -253,7 +254,13 @@ class Bugsee { * `onBeforeReportCreated` is at-most-once and may be skipped for a given * report; `onAfterReportCreated` is at-least-once and MUST be idempotent * (`setLabels`/`setAttribute`, not appending; check `getAttachmentNames()` - * before adding one). A mutation made after the callback settles, or after + * before adding one). + * + * **Attachments.** A handler report takes up to 1000 attachments on both + * platforms. A file is captured when it is added, and `move` is honoured. + * `onAfterReportCreated` must check `getAttachmentNames()` before adding. + * + * A mutation made after the callback settles, or after * its handle's deadline passes -- the SDK's deadline, not one the app * negotiates -- does not reach the report. * @@ -338,6 +345,28 @@ class Bugsee { ); } + /** + * A report to fill and upload. Resolves `null` when the SDK made none + * (not launched). + * + * One outstanding created report at a time. A second `createReport()` + * before that one is uploaded rejects with `E_REPORT_CREATE_BUSY`. iOS + * beta3 keeps created-report attributes in file-scope globals, so a second + * create wipes the first. + * + * Android runs `onBeforeReportCreated` inside `createReport` and + * `onAfterReportCreated` at upload. iOS runs both at upload, after copying + * the created report's fields, so a handler `setLabels` there replaces the + * app's labels. + * + * iOS created-report attachments: at most 3, each at most 3 MiB. The bridge + * rejects a further or larger one with `E_REPORT_ATTACHMENT_REJECTED`. iOS + * ignores `mimeType`. Android's cap is 1000. + */ + async createReport(): Promise { + return openCreatedReport(); + } + /** * One line into the Bugsee log, attributed to the wrapper (source Custom, no * tag) and filtered natively by the app's log filter, exactly once. Dropped @@ -646,6 +675,7 @@ export type { LifecycleEvent } from './wrapper/events'; export { ReportErrorCode, BugseeReportError } from './report/errors'; export type { + BugseeCreatedReport, BugseeReport, BugseeReportHandler, BugseeReportSnapshot, diff --git a/packages/react-native/src/report/BugseeReport.ts b/packages/react-native/src/report/BugseeReport.ts index 366b171b..931169bc 100644 --- a/packages/react-native/src/report/BugseeReport.ts +++ b/packages/react-native/src/report/BugseeReport.ts @@ -26,7 +26,7 @@ const KNOWN_ERROR_CODES: ReadonlySet = new Set( * Anything else -- a rejection with no recognised code -- passes through * unchanged rather than being reinterpreted as something it is not. */ -function toReportError(error: unknown): unknown { +export function toReportError(error: unknown): unknown { if (error instanceof BugseeReportError) { return error; } @@ -53,7 +53,7 @@ function emptySnapshot(): BugseeReportSnapshot { }; } -function normalizeSnapshot(raw: unknown): BugseeReportSnapshot { +export function normalizeSnapshot(raw: unknown): BugseeReportSnapshot { if (typeof raw !== 'object' || raw === null) { return emptySnapshot(); } diff --git a/packages/react-native/src/report/CreatedReport.ts b/packages/react-native/src/report/CreatedReport.ts new file mode 100644 index 00000000..7e9461de --- /dev/null +++ b/packages/react-native/src/report/CreatedReport.ts @@ -0,0 +1,127 @@ +import NativeBugsee from '../NativeBugsee'; +import { encodeBridgeObject } from '../bridge/json'; +import { normalizeSnapshot, toReportError } from './BugseeReport'; +import { BugseeReportError, ReportErrorCode } from './errors'; +import type { BugseeCreatedReport, BugseeReportSnapshot, ReportPatch } from './types'; +import { + normalizeFilePath, + validateBase64, + validateNonEmptyString, + validateReportPatch, +} from './validate'; + +/** + * The live `BugseeCreatedReport` `createReport()` hands back. + * + * One instance per handle. `upload()` marks it dead synchronously, before the + * call crosses, so a second `upload()` rejects locally and the bridge sees + * the first call only. + */ +export class CreatedReport implements BugseeCreatedReport { + private readonly handleId: string; + private dead = false; + + constructor(handleId: string) { + this.handleId = handleId; + } + + private ensureAlive(): void { + if (this.dead) { + throw new BugseeReportError( + ReportErrorCode.HandleDead, + 'This created report handle is no longer valid: upload() has already been called.', + ); + } + } + + private async callNative(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + throw toReportError(error); + } + } + + async read(): Promise { + this.ensureAlive(); + const raw = await this.callNative(() => + NativeBugsee.createdReportRead(this.handleId), + ); + return normalizeSnapshot(raw); + } + + async update(patch: ReportPatch): Promise { + this.ensureAlive(); + // Validated in full before anything crosses the bridge: a patch with one + // bad field must send nothing, not everything except that field. Then + // sent as JSON text, the only form in which a clearing `null` survives + // iOS's TurboModule argument conversion. + const wire = encodeBridgeObject(validateReportPatch(patch)); + await this.callNative(() => + NativeBugsee.createdReportUpdate(this.handleId, wire), + ); + } + + async addFileAttachment( + path: string, + options: { name: string; mimeType?: string }, + ): Promise { + this.ensureAlive(); + const normalizedPath = normalizeFilePath( + validateNonEmptyString(path, 'path'), + ); + validateNonEmptyString(normalizedPath, 'path'); + const name = validateNonEmptyString(options?.name, 'name'); + await this.callNative(() => + NativeBugsee.createdReportAddFileAttachment( + this.handleId, + normalizedPath, + name, + options?.mimeType ?? null, + ), + ); + } + + async addDataAttachment( + base64: string, + options: { name: string; mimeType?: string }, + ): Promise { + this.ensureAlive(); + const validBase64 = validateBase64(base64); + const name = validateNonEmptyString(options?.name, 'name'); + await this.callNative(() => + NativeBugsee.createdReportAddDataAttachment( + this.handleId, + validBase64, + name, + options?.mimeType ?? null, + ), + ); + } + + async upload(): Promise { + this.ensureAlive(); + // Before the call, not after it resolves: two overlapping upload() calls + // must cross once. The handle stays dead whatever the native result is. + this.dead = true; + return this.callNative(() => NativeBugsee.createdReportUpload(this.handleId)); + } +} + +/** + * Asks native for a created report and wraps the handle it mints. + * `null` is "the SDK made none". A native `E_REPORT_CREATE_BUSY` (and any + * other known report code) comes back as a `BugseeReportError`. + */ +export async function createReport(): Promise { + let handleId: string | null; + try { + handleId = await NativeBugsee.createReport(); + } catch (error) { + throw toReportError(error); + } + if (handleId == null) { + return null; + } + return new CreatedReport(handleId); +} diff --git a/packages/react-native/src/report/__tests__/created-report.test.ts b/packages/react-native/src/report/__tests__/created-report.test.ts new file mode 100644 index 00000000..0cf6c23e --- /dev/null +++ b/packages/react-native/src/report/__tests__/created-report.test.ts @@ -0,0 +1,300 @@ +// The package entry reads Platform.OS. Mocked to a known platform; this suite +// is about createReport, not about which one. +jest.mock('react-native', () => ({ Platform: { OS: 'ios' } })); + +import Bugsee, { BugseeReportError, IssueSeverity, ReportErrorCode } from '../../index'; +import { jsonOf, native } from '../../__mocks__/native'; + +jest.mock('../../NativeBugsee', () => require('../../__mocks__/native').nativeMock); + +beforeEach(() => native.reset()); + +async function opened(handle = 'cr-1') { + native.createReport.mockResolvedValueOnce(handle); + const report = await Bugsee.createReport(); + if (report === null) { + throw new Error(`expected createReport to wrap ${handle}`); + } + return report; +} + +describe('createReport', () => { + it('createReport resolves null when native made none', async () => { + native.createReport.mockResolvedValueOnce(null); + + await expect(Bugsee.createReport()).resolves.toBeNull(); + }); + + it('createReport wraps a handle', async () => { + native.createReport.mockResolvedValueOnce('cr-7'); + native.createdReportRead.mockResolvedValueOnce({ summary: 'kept' }); + + const report = await Bugsee.createReport(); + + expect(report).not.toBeNull(); + await expect(report!.read()).resolves.toMatchObject({ summary: 'kept' }); + expect(native.createdReportRead).toHaveBeenCalledTimes(1); + expect(native.createdReportRead).toHaveBeenCalledWith('cr-7'); + }); + + it('E_REPORT_CREATE_BUSY surfaces as BugseeReportError with its code', async () => { + native.createReport.mockRejectedValueOnce({ + code: 'E_REPORT_CREATE_BUSY', + message: 'a created report is already outstanding', + }); + + const error = await Bugsee.createReport().then( + () => { + throw new Error('expected createReport to reject'); + }, + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(BugseeReportError); + expect(error).toMatchObject({ + code: ReportErrorCode.CreateBusy, + message: 'a created report is already outstanding', + }); + }); +}); + +describe('CreatedReport', () => { + it('update sends the validated patch as JSON', async () => { + const report = await opened(); + native.createdReportUpdate.mockResolvedValueOnce(undefined); + + await report.update({ + summary: 'new summary', + description: null, + severity: IssueSeverity.Blocker, + labels: ['x', 'y'], + clearAttributes: true, + attributes: { a: 1, b: null }, + }); + + expect(native.createdReportUpdate).toHaveBeenCalledTimes(1); + expect(native.createdReportUpdate).toHaveBeenCalledWith( + 'cr-1', + jsonOf({ + summary: 'new summary', + description: null, + severity: IssueSeverity.Blocker, + labels: ['x', 'y'], + clearAttributes: true, + attributes: { a: 1, b: null }, + }), + ); + }); + + it('update with one bad field sends nothing', async () => { + const report = await opened(); + + await expect( + report.update({ summary: 'ok', severity: 999 as IssueSeverity }), + ).rejects.toMatchObject({ code: ReportErrorCode.BadArgument }); + expect(native.createdReportUpdate).not.toHaveBeenCalled(); + }); + + it('addFileAttachment without a mime type sends null', async () => { + const report = await opened(); + native.createdReportAddFileAttachment.mockResolvedValueOnce(undefined); + + await report.addFileAttachment('/plain.png', { name: 'shot' }); + + expect(native.createdReportAddFileAttachment).toHaveBeenCalledWith( + 'cr-1', + '/plain.png', + 'shot', + null, + ); + }); + + it('addFileAttachment rejects an empty path and an empty name before crossing', async () => { + const report = await opened(); + + await expect( + report.addFileAttachment('file://', { name: 'shot' }), + ).rejects.toMatchObject({ + code: ReportErrorCode.BadArgument, + message: 'path must be a non-empty string', + }); + await expect( + report.addFileAttachment('/plain.png', { name: '' }), + ).rejects.toMatchObject({ + code: ReportErrorCode.BadArgument, + message: 'name must be a non-empty string', + }); + expect(native.createdReportAddFileAttachment).not.toHaveBeenCalled(); + }); + + it('addFileAttachment strips file:// and sends no move', async () => { + const report = await opened(); + native.createdReportAddFileAttachment.mockResolvedValueOnce(undefined); + + await report.addFileAttachment('file:///a%20b.png', { + name: 'shot', + mimeType: 'image/png', + }); + + expect(native.createdReportAddFileAttachment).toHaveBeenCalledTimes(1); + expect(native.createdReportAddFileAttachment).toHaveBeenCalledWith( + 'cr-1', + '/a b.png', + 'shot', + 'image/png', + ); + expect(native.createdReportAddFileAttachment.mock.calls[0]).toHaveLength(4); + expect(native.reportAddFileAttachment).not.toHaveBeenCalled(); + }); + + it('addDataAttachment rejects non-base64 before crossing', async () => { + const report = await opened(); + + await expect( + report.addDataAttachment('not base64!!', { name: 'x' }), + ).rejects.toMatchObject({ + code: ReportErrorCode.BadArgument, + message: expect.stringMatching(/base64-encoded/), + }); + expect(native.createdReportAddDataAttachment).not.toHaveBeenCalled(); + }); + + it('addDataAttachment sends the bytes, and null when mime type is omitted', async () => { + const report = await opened(); + native.createdReportAddDataAttachment.mockResolvedValueOnce(undefined); + + await report.addDataAttachment('YWJj', { name: 'blob' }); + await report.addDataAttachment('YWJj', { + name: 'blob', + mimeType: 'application/octet-stream', + }); + + expect(native.createdReportAddDataAttachment).toHaveBeenNthCalledWith( + 1, + 'cr-1', + 'YWJj', + 'blob', + null, + ); + expect(native.createdReportAddDataAttachment).toHaveBeenNthCalledWith( + 2, + 'cr-1', + 'YWJj', + 'blob', + 'application/octet-stream', + ); + }); + + it('a native rejection comes back as BugseeReportError', async () => { + const report = await opened(); + native.createdReportRead.mockRejectedValueOnce({ + code: 'E_REPORT_ATTACHMENT_REJECTED', + message: 'too big', + }); + + await expect(report.read()).rejects.toMatchObject({ + code: ReportErrorCode.AttachmentRejected, + message: 'too big', + }); + }); + + it('a dead handle names the reason', async () => { + const report = await opened(); + native.createdReportUpload.mockResolvedValueOnce(true); + await report.upload(); + + await expect(report.read()).rejects.toThrow( + 'This created report handle is no longer valid: upload() has already been called.', + ); + }); + + it("upload resolves native's result", async () => { + const yes = await opened('cr-yes'); + native.createdReportUpload.mockResolvedValueOnce(true); + await expect(yes.upload()).resolves.toBe(true); + expect(native.createdReportUpload).toHaveBeenCalledWith('cr-yes'); + + const no = await opened('cr-no'); + native.createdReportUpload.mockResolvedValueOnce(false); + await expect(no.upload()).resolves.toBe(false); + expect(native.createdReportUpload).toHaveBeenLastCalledWith('cr-no'); + }); + + it('after upload every op rejects E_REPORT_HANDLE_DEAD without crossing', async () => { + const report = await opened(); + native.createdReportUpload.mockResolvedValueOnce(true); + await report.upload(); + + native.createdReportRead.mockClear(); + native.createdReportUpdate.mockClear(); + native.createdReportAddFileAttachment.mockClear(); + native.createdReportAddDataAttachment.mockClear(); + native.createdReportUpload.mockClear(); + + await expect(report.read()).rejects.toMatchObject({ + code: ReportErrorCode.HandleDead, + }); + await expect(report.update({ summary: 'later' })).rejects.toMatchObject({ + code: ReportErrorCode.HandleDead, + }); + await expect( + report.addFileAttachment('/a.png', { name: 'a' }), + ).rejects.toMatchObject({ code: ReportErrorCode.HandleDead }); + await expect( + report.addDataAttachment('YWJj', { name: 'b' }), + ).rejects.toMatchObject({ code: ReportErrorCode.HandleDead }); + await expect(report.upload()).rejects.toMatchObject({ + code: ReportErrorCode.HandleDead, + }); + + expect(native.createdReportRead).not.toHaveBeenCalled(); + expect(native.createdReportUpdate).not.toHaveBeenCalled(); + expect(native.createdReportAddFileAttachment).not.toHaveBeenCalled(); + expect(native.createdReportAddDataAttachment).not.toHaveBeenCalled(); + expect(native.createdReportUpload).not.toHaveBeenCalled(); + }); + + it('two concurrent upload calls cross once', async () => { + const report = await opened(); + let release: (value: boolean) => void = () => {}; + native.createdReportUpload.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const first = report.upload(); + const second = report.upload(); + + expect(native.createdReportUpload).toHaveBeenCalledTimes(1); + release(true); + await expect(first).resolves.toBe(true); + await expect(second).rejects.toMatchObject({ + code: ReportErrorCode.HandleDead, + }); + expect(native.createdReportUpload).toHaveBeenCalledTimes(1); + }); + + it('read normalises the snapshot as the handler proxy does', async () => { + const report = await opened(); + native.createdReportRead.mockResolvedValueOnce({ + summary: 'a summary', + description: null, + severity: 4, + labels: ['a', 'b'], + attributes: { flag: true, count: 3, name: 'x', dropped: { nested: true } }, + screenshotDisplayIds: [2, 1], + attachmentNames: ['shot.png'], + }); + + await expect(report.read()).resolves.toEqual({ + summary: 'a summary', + description: undefined, + severity: IssueSeverity.Critical, + labels: ['a', 'b'], + attributes: { flag: true, count: 3, name: 'x' }, + screenshotDisplayIds: [1, 2], + attachmentNames: ['shot.png'], + }); + }); +}); diff --git a/packages/react-native/src/report/__tests__/validate.test.ts b/packages/react-native/src/report/__tests__/validate.test.ts index 812256e2..5f900c3e 100644 --- a/packages/react-native/src/report/__tests__/validate.test.ts +++ b/packages/react-native/src/report/__tests__/validate.test.ts @@ -1,11 +1,12 @@ import { BugseeReportError, ReportErrorCode } from '../errors'; describe('ReportErrorCode', () => { - it('is exactly the three stable strings', () => { + it('ReportErrorCode values are exactly the four stable strings', () => { expect(Object.values(ReportErrorCode).sort()).toEqual( [ 'E_REPORT_ATTACHMENT_REJECTED', 'E_REPORT_BAD_ARGUMENT', + 'E_REPORT_CREATE_BUSY', 'E_REPORT_HANDLE_DEAD', ].sort(), ); diff --git a/packages/react-native/src/report/errors.ts b/packages/react-native/src/report/errors.ts index 78c207ba..cf9e2135 100644 --- a/packages/react-native/src/report/errors.ts +++ b/packages/react-native/src/report/errors.ts @@ -1,5 +1,5 @@ /** - * The three stable codes a `BugseeReport` operation can fail with. + * The four stable codes a report operation can fail with. * * Stable, and worth stating: an app matches on `.code`, so these strings are * part of the public contract, not an implementation detail free to change @@ -8,15 +8,21 @@ export const ReportErrorCode = { /** * The handle's callback has already settled, or its deadline already - * passed. Raised locally -- without crossing the bridge -- for every - * operation after that point, and reproduced with the same code when a - * native rejection reports it instead. + * passed, or a created report has already been uploaded. Raised locally -- + * without crossing the bridge -- for every operation after that point, and + * reproduced with the same code when a native rejection reports it instead. */ HandleDead: 'E_REPORT_HANDLE_DEAD', /** Native declined an attachment (`addFileAttachment`/`addDataAttachment`). */ AttachmentRejected: 'E_REPORT_ATTACHMENT_REJECTED', /** JS-side validation rejected the argument before it crossed the bridge. */ BadArgument: 'E_REPORT_BAD_ARGUMENT', + /** + * A created report is already outstanding. A second `createReport()` rejects + * with this until that report is uploaded. iOS beta3 keeps created-report + * attributes in file-scope globals, so a second create would wipe the first. + */ + CreateBusy: 'E_REPORT_CREATE_BUSY', } as const; export type ReportErrorCode = diff --git a/packages/react-native/src/report/types.ts b/packages/react-native/src/report/types.ts index 1f208e8c..79794fd8 100644 --- a/packages/react-native/src/report/types.ts +++ b/packages/react-native/src/report/types.ts @@ -91,6 +91,31 @@ export interface BugseeReport { update(patch: ReportPatch): Promise; } +/** + * A report the app fills and then uploads, from `createReport()`. + * + * Unlike a handler `BugseeReport`, this has no id, no type and no deadline. + * `upload()` ends it: every later call rejects `E_REPORT_HANDLE_DEAD`. + */ +export interface BugseeCreatedReport { + read(): Promise; + update(patch: ReportPatch): Promise; + /** + * The file is copied when it is added. There is no `move`: a created report + * does not take the file away from the app. + */ + addFileAttachment( + path: string, + options: { name: string; mimeType?: string }, + ): Promise; + addDataAttachment( + base64: string, + options: { name: string; mimeType?: string }, + ): Promise; + /** Uploads; resolves the SDK's result. Every later call rejects E_REPORT_HANDLE_DEAD. */ + upload(): Promise; +} + /** * Registered through `setReportHandler`. Register before `launch()` to see * reports the SDK recovers at launch. @@ -100,7 +125,13 @@ export interface BugseeReport { * to `onAfterReportCreated`. `onAfterReportCreated` is at-least-once and MUST * be idempotent: prefer `setLabels`/`setAttribute` over appending, and check * `getAttachmentNames()` before adding an attachment a previous delivery may - * already have added. A mutation made after the callback settles, or after + * already have added. + * + * **Attachments.** A handler report takes up to 1000 attachments on both + * platforms. A file is captured when it is added, and `move` is honoured. + * `onAfterReportCreated` must check `getAttachmentNames()` before adding. + * + * A mutation made after the callback settles, or after * its handle's deadline passes, does not reach the report -- the deadline is * the SDK's own, not something the app negotiates. *