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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions spec/bugsplat.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import {
appendAttachment,
BugSplat,
createStandardizedCallStack,
tryParseResponseJson,
Expand Down Expand Up @@ -369,3 +370,65 @@ describe('BugSplat', function () {
expect(result.error!.message).toEqual('BugSplat Error: Invalid response received');
});
});

describe('appendAttachment', () => {
let append: Mock;
let body: FormData;

beforeEach(() => {
append = vi.fn();
body = { append } as unknown as FormData;
});

it('wraps a Uint8Array in a Blob before appending', () => {
const bytes = new Uint8Array([0x42, 0x53]);
appendAttachment(body, { filename: 'data.bin', data: bytes });
expect(append).toHaveBeenCalledTimes(1);
const [filename, value, postedName] = append.mock.calls[0];
expect(filename).toBe('data.bin');
expect(value).toBeInstanceOf(Blob);
expect(postedName).toBe('data.bin');
});

it('uploads only the bytes covered by a Uint8Array view, not the whole backing buffer', async () => {
// Build a Uint8Array that is a subarray view into a larger buffer.
const underlying = new Uint8Array([0x00, 0x00, 0x42, 0x53, 0x00, 0x00]);
const view = underlying.subarray(2, 4); // just [0x42, 0x53]
appendAttachment(body, { filename: 'data.bin', data: view });
const [, value] = append.mock.calls[0];
expect(value).toBeInstanceOf(Blob);
expect((value as Blob).size).toBe(2);
const bytes = new Uint8Array(await (value as Blob).arrayBuffer());
expect(Array.from(bytes)).toEqual([0x42, 0x53]);
});

it('passes a Blob through with filename', () => {
const blob = new Blob(['hello']);
appendAttachment(body, { filename: 'hello.txt', data: blob });
expect(append).toHaveBeenCalledWith('hello.txt', blob, 'hello.txt');
});

it('appends a React Native file ref in {uri, name, type} shape', () => {
appendAttachment(body, {
filename: 'screenshot.png',
data: { uri: 'file:///tmp/shot.png', type: 'image/png' },
});
expect(append).toHaveBeenCalledWith(
'screenshot.png',
{ uri: 'file:///tmp/shot.png', type: 'image/png', name: 'screenshot.png' },
'screenshot.png'
);
});

it('handles a file ref with no type', () => {
appendAttachment(body, {
filename: 'log.txt',
data: { uri: 'file:///tmp/log.txt' },
});
expect(append).toHaveBeenCalledWith(
'log.txt',
{ uri: 'file:///tmp/log.txt', type: undefined, name: 'log.txt' },
'log.txt'
);
});
});
27 changes: 25 additions & 2 deletions src/bugsplat-options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
/**
* A reference to a file on the local filesystem.
*
* Used on React Native, where FormData streams files from disk via their URI
* (e.g. `file://`, `content://`, `ph://`) instead of reading them into JS memory.
* Most RN file libraries (`expo-image-picker`, `react-native-view-shot`,
* `expo-screen-capture`, etc.) already return values in this shape.
*/
export interface BugSplatFileRef {
/**
* The URI the runtime can read the file from.
*/
uri: string;
/**
* The MIME type, e.g. `image/png`. Optional but recommended.
*/
type?: string;
}

/**
* A file attachment to include in the upload zip.
*/
Expand All @@ -7,9 +26,13 @@ export interface BugSplatAttachment {
*/
filename: string;
/**
* The file contents
* The file contents.
*
* - `Blob` — browser `File`/`Blob` from an `<input type="file">` or `canvas.toBlob()`.
* - `Uint8Array` — raw binary buffer (wrapped in a `Blob` before upload).
* - `BugSplatFileRef` — `{ uri, type? }` reference for React Native file uploads.
*/
data: Blob | Uint8Array;
data: Blob | Uint8Array | BugSplatFileRef;
}

/**
Expand Down
51 changes: 42 additions & 9 deletions src/bugsplat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { BugSplatOptions } from './bugsplat-options';
import type { BugSplatAttachment, BugSplatFileRef, BugSplatOptions } from './bugsplat-options';
import {
type BugSplatResponse,
type BugSplatResponseBody,
Expand Down Expand Up @@ -32,6 +32,45 @@ export async function tryParseResponseJson(response: {

const isError = (val: unknown): val is Error => Boolean((val as Error)?.stack);

function isFileRef(data: unknown): data is BugSplatFileRef {
return typeof data === 'object' && data !== null && typeof (data as BugSplatFileRef).uri === 'string';
}

/**
* Append an attachment to a multipart body in whatever shape the runtime's
* FormData expects:
*
* - `Uint8Array` → wrapped in a `Blob` so browsers send it as a file part.
* - `BugSplatFileRef` → React Native's `{ uri, type, name }` file-upload
* shape; RN's fetch streams the file from the URI. Not supported in
* browsers — pass a `Blob` there instead.
* - `Blob` → appended with filename so the part has a `Content-Disposition`
* `filename=` header and reaches the server as an upload.
*/
export function appendAttachment(body: FormData, attachment: BugSplatAttachment): void {
const { filename, data } = attachment;

if (data instanceof Uint8Array) {
// Pass the view itself, not `data.buffer`, so subarray views upload only
// their own bytes (byteOffset..byteOffset+byteLength) rather than the
// entire underlying ArrayBuffer. Cast narrows the buffer type to
// ArrayBuffer — SharedArrayBuffer-backed views are not expected here.
body.append(filename, new Blob([data as Uint8Array<ArrayBuffer>]), filename);
return;
}

if (isFileRef(data)) {
body.append(
filename,
{ uri: data.uri, type: data.type, name: filename } as unknown as Blob,
filename
);
Comment thread
bobbyg603 marked this conversation as resolved.
return;
}

body.append(filename, data, filename);
}

/**
* BugSplat crash and feedback posting client.
*/
Expand Down Expand Up @@ -84,10 +123,7 @@ export class BugSplat {
body.append('attributes', JSON.stringify(attributes));
}
for (const attachment of options.attachments || []) {
const blob = attachment.data instanceof Uint8Array
? new Blob([attachment.data.buffer as ArrayBuffer])
: attachment.data;
body.append(attachment.filename, blob, attachment.filename);
appendAttachment(body, attachment);
}

console.log('BugSplat Error:', errorToPost);
Expand Down Expand Up @@ -167,10 +203,7 @@ export class BugSplat {
body.append('attributes', JSON.stringify(attributes));
}
for (const attachment of options.attachments || []) {
const blob = attachment.data instanceof Uint8Array
? new Blob([attachment.data.buffer as ArrayBuffer])
: attachment.data;
body.append(attachment.filename, blob, attachment.filename);
appendAttachment(body, attachment);
}

console.log('BugSplat Feedback:', title);
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { BugSplat } from './bugsplat';
export type { BugSplatAttachment, BugSplatOptions } from './bugsplat-options';
export type { BugSplatAttachment, BugSplatFileRef, BugSplatOptions } from './bugsplat-options';
export type { BugSplatResponse, BugSplatResponseBody, BugSplatResponseType, validateResponseBody } from './bugsplat-response';
Loading