@bbearai/core 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -22,10 +22,13 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  BUG_CATEGORIES: () => BUG_CATEGORIES,
24
24
  BugBearClient: () => BugBearClient,
25
+ LocalStorageAdapter: () => LocalStorageAdapter,
26
+ OfflineQueue: () => OfflineQueue,
25
27
  captureError: () => captureError,
26
28
  contextCapture: () => contextCapture,
27
29
  createBugBear: () => createBugBear,
28
- isBugCategory: () => isBugCategory
30
+ isBugCategory: () => isBugCategory,
31
+ isNetworkError: () => isNetworkError
29
32
  });
30
33
  module.exports = __toCommonJS(index_exports);
31
34
 
@@ -280,32 +283,312 @@ function captureError(error, errorInfo) {
280
283
  };
281
284
  }
282
285
 
286
+ // src/offline-queue.ts
287
+ var LocalStorageAdapter = class {
288
+ constructor() {
289
+ this.fallback = /* @__PURE__ */ new Map();
290
+ }
291
+ get isAvailable() {
292
+ try {
293
+ const key = "__bugbear_test__";
294
+ localStorage.setItem(key, "1");
295
+ localStorage.removeItem(key);
296
+ return true;
297
+ } catch {
298
+ return false;
299
+ }
300
+ }
301
+ async getItem(key) {
302
+ if (this.isAvailable) return localStorage.getItem(key);
303
+ return this.fallback.get(key) ?? null;
304
+ }
305
+ async setItem(key, value) {
306
+ if (this.isAvailable) {
307
+ localStorage.setItem(key, value);
308
+ } else {
309
+ this.fallback.set(key, value);
310
+ }
311
+ }
312
+ async removeItem(key) {
313
+ if (this.isAvailable) {
314
+ localStorage.removeItem(key);
315
+ } else {
316
+ this.fallback.delete(key);
317
+ }
318
+ }
319
+ };
320
+ var OfflineQueue = class {
321
+ constructor(config) {
322
+ this.items = [];
323
+ this.storageKey = "bugbear_offline_queue";
324
+ this.flushing = false;
325
+ this.handlers = /* @__PURE__ */ new Map();
326
+ this.maxItems = config.maxItems ?? 50;
327
+ this.maxRetries = config.maxRetries ?? 5;
328
+ this.storage = config.storage ?? new LocalStorageAdapter();
329
+ }
330
+ // ── Flush handler registration ──────────────────────────────
331
+ /** Register a handler that replays a queued operation. */
332
+ registerHandler(type, handler) {
333
+ this.handlers.set(type, handler);
334
+ }
335
+ // ── Change listener ─────────────────────────────────────────
336
+ /** Subscribe to queue count changes (for UI badges). */
337
+ onChange(callback) {
338
+ this.listener = callback;
339
+ }
340
+ notify() {
341
+ this.listener?.(this.items.length);
342
+ }
343
+ // ── Persistence ─────────────────────────────────────────────
344
+ /** Load queue from persistent storage. Call once after construction. */
345
+ async load() {
346
+ try {
347
+ const raw = await this.storage.getItem(this.storageKey);
348
+ if (raw) {
349
+ this.items = JSON.parse(raw);
350
+ }
351
+ } catch {
352
+ this.items = [];
353
+ }
354
+ this.notify();
355
+ }
356
+ async save() {
357
+ try {
358
+ await this.storage.setItem(this.storageKey, JSON.stringify(this.items));
359
+ } catch (err) {
360
+ console.error("BugBear: Failed to persist offline queue", err);
361
+ }
362
+ this.notify();
363
+ }
364
+ // ── Enqueue ─────────────────────────────────────────────────
365
+ /** Add a failed operation to the queue. Returns the item ID. */
366
+ async enqueue(type, payload) {
367
+ if (this.items.length >= this.maxItems) {
368
+ this.items.shift();
369
+ }
370
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
371
+ this.items.push({ id, type, payload, createdAt: Date.now(), retries: 0 });
372
+ await this.save();
373
+ return id;
374
+ }
375
+ // ── Accessors ───────────────────────────────────────────────
376
+ /** Number of items waiting to be flushed. */
377
+ get count() {
378
+ return this.items.length;
379
+ }
380
+ /** Read-only snapshot of pending items. */
381
+ get pending() {
382
+ return [...this.items];
383
+ }
384
+ /** Whether a flush is currently in progress. */
385
+ get isFlushing() {
386
+ return this.flushing;
387
+ }
388
+ // ── Flush ───────────────────────────────────────────────────
389
+ /**
390
+ * Process all queued items in FIFO order.
391
+ * Stops early if a network error is encountered (still offline).
392
+ */
393
+ async flush() {
394
+ if (this.flushing || this.items.length === 0) {
395
+ return { flushed: 0, failed: 0 };
396
+ }
397
+ this.flushing = true;
398
+ let flushed = 0;
399
+ let failed = 0;
400
+ const snapshot = [...this.items];
401
+ for (const item of snapshot) {
402
+ const handler = this.handlers.get(item.type);
403
+ if (!handler) {
404
+ failed++;
405
+ continue;
406
+ }
407
+ try {
408
+ const result = await handler(item.payload);
409
+ if (result.success) {
410
+ this.items = this.items.filter((i) => i.id !== item.id);
411
+ flushed++;
412
+ } else if (isNetworkError(result.error)) {
413
+ break;
414
+ } else {
415
+ const idx = this.items.findIndex((i) => i.id === item.id);
416
+ if (idx !== -1) {
417
+ this.items[idx].retries++;
418
+ if (this.items[idx].retries >= this.maxRetries) {
419
+ this.items.splice(idx, 1);
420
+ }
421
+ }
422
+ failed++;
423
+ }
424
+ } catch {
425
+ break;
426
+ }
427
+ }
428
+ await this.save();
429
+ this.flushing = false;
430
+ return { flushed, failed };
431
+ }
432
+ // ── Clear ───────────────────────────────────────────────────
433
+ /** Drop all queued items. */
434
+ async clear() {
435
+ this.items = [];
436
+ await this.save();
437
+ }
438
+ };
439
+ function isNetworkError(error) {
440
+ if (!error) return false;
441
+ const msg = error.toLowerCase();
442
+ return msg.includes("failed to fetch") || msg.includes("networkerror") || msg.includes("network request failed") || msg.includes("timeout") || msg.includes("econnrefused") || msg.includes("enotfound") || msg.includes("load failed") || // Safari
443
+ msg.includes("the internet connection appears to be offline") || msg.includes("a]server with the specified hostname could not be found");
444
+ }
445
+
283
446
  // src/client.ts
284
447
  var formatPgError = (e) => {
285
448
  if (!e || typeof e !== "object") return { raw: e };
286
449
  const { message, code, details, hint } = e;
287
450
  return { message, code, details, hint };
288
451
  };
289
- var DEFAULT_SUPABASE_URL = "https://kyxgzjnqgvapvlnvqawz.supabase.co";
290
- var getEnvVar = (key) => {
291
- try {
292
- if (typeof process !== "undefined" && process.env) {
293
- return process.env[key];
294
- }
295
- } catch {
296
- }
297
- return void 0;
298
- };
299
- var HOSTED_BUGBEAR_ANON_KEY = getEnvVar("BUGBEAR_ANON_KEY") || getEnvVar("NEXT_PUBLIC_BUGBEAR_ANON_KEY") || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt5eGd6am5xZ3ZhcHZsbnZxYXd6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjkyNjgwNDIsImV4cCI6MjA4NDg0NDA0Mn0.NUkAlCHLFjeRoisbmNUVoGb4R6uQ8xs5LAEIX1BWTwU";
300
452
  var BugBearClient = class {
301
453
  constructor(config) {
302
454
  this.navigationHistory = [];
303
455
  this.reportSubmitInFlight = false;
456
+ /** Offline queue — only created when config.offlineQueue.enabled is true. */
457
+ this._queue = null;
458
+ /** Active Realtime channel references for cleanup. */
459
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
460
+ this.realtimeChannels = [];
461
+ if (!config.supabaseUrl) {
462
+ throw new Error("BugBear: supabaseUrl is required. Get it from your BugBear project settings.");
463
+ }
464
+ if (!config.supabaseAnonKey) {
465
+ throw new Error("BugBear: supabaseAnonKey is required. Get it from your BugBear project settings.");
466
+ }
304
467
  this.config = config;
305
- this.supabase = (0, import_supabase_js.createClient)(
306
- config.supabaseUrl || DEFAULT_SUPABASE_URL,
307
- config.supabaseAnonKey || HOSTED_BUGBEAR_ANON_KEY
308
- );
468
+ this.supabase = (0, import_supabase_js.createClient)(config.supabaseUrl, config.supabaseAnonKey);
469
+ if (config.offlineQueue?.enabled) {
470
+ this._queue = new OfflineQueue({
471
+ enabled: true,
472
+ maxItems: config.offlineQueue.maxItems,
473
+ maxRetries: config.offlineQueue.maxRetries
474
+ });
475
+ this.registerQueueHandlers();
476
+ }
477
+ }
478
+ // ── Offline Queue ─────────────────────────────────────────
479
+ /**
480
+ * Access the offline queue (if enabled).
481
+ * Use this to check queue.count, subscribe to changes, or trigger flush.
482
+ */
483
+ get queue() {
484
+ return this._queue;
485
+ }
486
+ /**
487
+ * Initialize the offline queue with a platform-specific storage adapter.
488
+ * Must be called after construction for React Native (which supplies AsyncStorage).
489
+ * Web callers can skip this — LocalStorageAdapter is the default.
490
+ */
491
+ async initQueue(storage) {
492
+ if (!this._queue) return;
493
+ if (storage) {
494
+ this._queue = new OfflineQueue({
495
+ enabled: true,
496
+ maxItems: this.config.offlineQueue?.maxItems,
497
+ maxRetries: this.config.offlineQueue?.maxRetries,
498
+ storage
499
+ });
500
+ this.registerQueueHandlers();
501
+ }
502
+ await this._queue.load();
503
+ }
504
+ registerQueueHandlers() {
505
+ if (!this._queue) return;
506
+ this._queue.registerHandler("report", async (payload) => {
507
+ const { error } = await this.supabase.from("reports").insert(payload).select("id").single();
508
+ if (error) return { success: false, error: error.message };
509
+ return { success: true };
510
+ });
511
+ this._queue.registerHandler("message", async (payload) => {
512
+ const { error } = await this.supabase.from("discussion_messages").insert(payload);
513
+ if (error) return { success: false, error: error.message };
514
+ return { success: true };
515
+ });
516
+ this._queue.registerHandler("feedback", async (payload) => {
517
+ const { error } = await this.supabase.from("test_feedback").insert(payload);
518
+ if (error) return { success: false, error: error.message };
519
+ return { success: true };
520
+ });
521
+ }
522
+ // ── Realtime Subscriptions ─────────────────────────────────
523
+ /** Whether realtime is enabled in config. */
524
+ get realtimeEnabled() {
525
+ return !!this.config.realtime?.enabled;
526
+ }
527
+ /**
528
+ * Subscribe to postgres_changes on relevant tables.
529
+ * Each callback fires when the corresponding table has changes —
530
+ * the provider should call its refresh function in response.
531
+ * Returns a cleanup function that unsubscribes all channels.
532
+ */
533
+ subscribeToChanges(callbacks) {
534
+ this.unsubscribeAll();
535
+ const projectId = this.config.projectId;
536
+ const debounce = (fn, ms = 500) => {
537
+ let timer;
538
+ return () => {
539
+ clearTimeout(timer);
540
+ timer = setTimeout(fn, ms);
541
+ };
542
+ };
543
+ if (callbacks.onAssignmentChange) {
544
+ const debouncedCb = debounce(callbacks.onAssignmentChange);
545
+ const channel = this.supabase.channel("bugbear-assignments").on("postgres_changes", {
546
+ event: "*",
547
+ schema: "public",
548
+ table: "test_assignments",
549
+ filter: `project_id=eq.${projectId}`
550
+ }, debouncedCb).subscribe((status) => {
551
+ if (status === "CHANNEL_ERROR") {
552
+ console.warn("BugBear: Realtime subscription failed for test_assignments");
553
+ }
554
+ });
555
+ this.realtimeChannels.push(channel);
556
+ }
557
+ if (callbacks.onMessageChange) {
558
+ const debouncedCb = debounce(callbacks.onMessageChange);
559
+ const channel = this.supabase.channel("bugbear-messages").on("postgres_changes", {
560
+ event: "INSERT",
561
+ schema: "public",
562
+ table: "discussion_messages"
563
+ }, debouncedCb).subscribe((status) => {
564
+ if (status === "CHANNEL_ERROR") {
565
+ console.warn("BugBear: Realtime subscription failed for discussion_messages");
566
+ }
567
+ });
568
+ this.realtimeChannels.push(channel);
569
+ }
570
+ if (callbacks.onReportChange) {
571
+ const debouncedCb = debounce(callbacks.onReportChange);
572
+ const channel = this.supabase.channel("bugbear-reports").on("postgres_changes", {
573
+ event: "UPDATE",
574
+ schema: "public",
575
+ table: "reports",
576
+ filter: `project_id=eq.${projectId}`
577
+ }, debouncedCb).subscribe((status) => {
578
+ if (status === "CHANNEL_ERROR") {
579
+ console.warn("BugBear: Realtime subscription failed for reports");
580
+ }
581
+ });
582
+ this.realtimeChannels.push(channel);
583
+ }
584
+ return () => this.unsubscribeAll();
585
+ }
586
+ /** Remove all active Realtime channels. */
587
+ unsubscribeAll() {
588
+ for (const channel of this.realtimeChannels) {
589
+ this.supabase.removeChannel(channel);
590
+ }
591
+ this.realtimeChannels = [];
309
592
  }
310
593
  /**
311
594
  * Track navigation for context.
@@ -365,6 +648,7 @@ var BugBearClient = class {
365
648
  return { success: false, error: "A report is already being submitted" };
366
649
  }
367
650
  this.reportSubmitInFlight = true;
651
+ let fullReport;
368
652
  try {
369
653
  const validationError = this.validateReport(report);
370
654
  if (validationError) {
@@ -381,7 +665,7 @@ var BugBearClient = class {
381
665
  return { success: false, error: "User not authenticated" };
382
666
  }
383
667
  const testerInfo = await this.getTesterInfo();
384
- const fullReport = {
668
+ fullReport = {
385
669
  project_id: this.config.projectId,
386
670
  reporter_id: userInfo.id,
387
671
  // User ID from host app (required)
@@ -408,6 +692,10 @@ var BugBearClient = class {
408
692
  };
409
693
  const { data, error } = await this.supabase.from("reports").insert(fullReport).select("id").single();
410
694
  if (error) {
695
+ if (this._queue && isNetworkError(error.message)) {
696
+ await this._queue.enqueue("report", fullReport);
697
+ return { success: false, queued: true, error: "Queued \u2014 will send when online" };
698
+ }
411
699
  console.error("BugBear: Failed to submit report", error.message);
412
700
  return { success: false, error: error.message };
413
701
  }
@@ -417,6 +705,10 @@ var BugBearClient = class {
417
705
  return { success: true, reportId: data.id };
418
706
  } catch (err) {
419
707
  const message = err instanceof Error ? err.message : "Unknown error";
708
+ if (this._queue && fullReport && isNetworkError(message)) {
709
+ await this._queue.enqueue("report", fullReport);
710
+ return { success: false, queued: true, error: "Queued \u2014 will send when online" };
711
+ }
420
712
  return { success: false, error: message };
421
713
  } finally {
422
714
  this.reportSubmitInFlight = false;
@@ -426,10 +718,13 @@ var BugBearClient = class {
426
718
  * Get assigned tests for current user
427
719
  * First looks up the tester by email, then fetches their assignments
428
720
  */
429
- async getAssignedTests() {
721
+ async getAssignedTests(options) {
430
722
  try {
431
723
  const testerInfo = await this.getTesterInfo();
432
724
  if (!testerInfo) return [];
725
+ const pageSize = Math.min(options?.pageSize ?? 100, 100);
726
+ const from = (options?.page ?? 0) * pageSize;
727
+ const to = from + pageSize - 1;
433
728
  const { data, error } = await this.supabase.from("test_assignments").select(`
434
729
  id,
435
730
  status,
@@ -470,7 +765,7 @@ var BugBearClient = class {
470
765
  login_hint
471
766
  )
472
767
  )
473
- `).eq("project_id", this.config.projectId).eq("tester_id", testerInfo.id).in("status", ["pending", "in_progress"]).order("created_at", { ascending: true }).limit(100);
768
+ `).eq("project_id", this.config.projectId).eq("tester_id", testerInfo.id).in("status", ["pending", "in_progress"]).order("created_at", { ascending: true }).range(from, to);
474
769
  if (error) {
475
770
  console.error("BugBear: Failed to fetch assignments", formatPgError(error));
476
771
  return [];
@@ -729,6 +1024,7 @@ var BugBearClient = class {
729
1024
  * This empowers testers to shape better tests over time
730
1025
  */
731
1026
  async submitTestFeedback(options) {
1027
+ let feedbackPayload;
732
1028
  try {
733
1029
  const testerInfo = await this.getTesterInfo();
734
1030
  if (!testerInfo) {
@@ -748,7 +1044,7 @@ var BugBearClient = class {
748
1044
  return { success: false, error: `${name} must be between 1 and 5` };
749
1045
  }
750
1046
  }
751
- const { error: feedbackError } = await this.supabase.from("test_feedback").insert({
1047
+ feedbackPayload = {
752
1048
  project_id: this.config.projectId,
753
1049
  test_case_id: testCaseId,
754
1050
  assignment_id: assignmentId || null,
@@ -766,8 +1062,13 @@ var BugBearClient = class {
766
1062
  platform: this.getDeviceInfo().platform,
767
1063
  time_to_complete_seconds: timeToCompleteSeconds || null,
768
1064
  screenshot_urls: screenshotUrls || []
769
- });
1065
+ };
1066
+ const { error: feedbackError } = await this.supabase.from("test_feedback").insert(feedbackPayload);
770
1067
  if (feedbackError) {
1068
+ if (this._queue && isNetworkError(feedbackError.message)) {
1069
+ await this._queue.enqueue("feedback", feedbackPayload);
1070
+ return { success: false, queued: true, error: "Queued \u2014 will send when online" };
1071
+ }
771
1072
  console.error("BugBear: Failed to submit feedback", feedbackError);
772
1073
  return { success: false, error: feedbackError.message };
773
1074
  }
@@ -784,6 +1085,10 @@ var BugBearClient = class {
784
1085
  return { success: true };
785
1086
  } catch (err) {
786
1087
  const message = err instanceof Error ? err.message : "Unknown error";
1088
+ if (this._queue && feedbackPayload && isNetworkError(message)) {
1089
+ await this._queue.enqueue("feedback", feedbackPayload);
1090
+ return { success: false, queued: true, error: "Queued \u2014 will send when online" };
1091
+ }
787
1092
  console.error("BugBear: Error submitting feedback", err);
788
1093
  return { success: false, error: message };
789
1094
  }
@@ -1418,6 +1723,7 @@ var BugBearClient = class {
1418
1723
  * Send a message to a thread
1419
1724
  */
1420
1725
  async sendMessage(threadId, content, attachments) {
1726
+ let insertData;
1421
1727
  try {
1422
1728
  const testerInfo = await this.getTesterInfo();
1423
1729
  if (!testerInfo) {
@@ -1429,7 +1735,7 @@ var BugBearClient = class {
1429
1735
  console.error("BugBear: Rate limit exceeded for messages");
1430
1736
  return false;
1431
1737
  }
1432
- const insertData = {
1738
+ insertData = {
1433
1739
  thread_id: threadId,
1434
1740
  sender_type: "tester",
1435
1741
  sender_tester_id: testerInfo.id,
@@ -1444,12 +1750,21 @@ var BugBearClient = class {
1444
1750
  }
1445
1751
  const { error } = await this.supabase.from("discussion_messages").insert(insertData);
1446
1752
  if (error) {
1753
+ if (this._queue && isNetworkError(error.message)) {
1754
+ await this._queue.enqueue("message", insertData);
1755
+ return false;
1756
+ }
1447
1757
  console.error("BugBear: Failed to send message", formatPgError(error));
1448
1758
  return false;
1449
1759
  }
1450
1760
  await this.markThreadAsRead(threadId);
1451
1761
  return true;
1452
1762
  } catch (err) {
1763
+ const message = err instanceof Error ? err.message : "Unknown error";
1764
+ if (this._queue && insertData && isNetworkError(message)) {
1765
+ await this._queue.enqueue("message", insertData);
1766
+ return false;
1767
+ }
1453
1768
  console.error("BugBear: Error sending message", err);
1454
1769
  return false;
1455
1770
  }
@@ -1786,8 +2101,11 @@ function createBugBear(config) {
1786
2101
  0 && (module.exports = {
1787
2102
  BUG_CATEGORIES,
1788
2103
  BugBearClient,
2104
+ LocalStorageAdapter,
2105
+ OfflineQueue,
1789
2106
  captureError,
1790
2107
  contextCapture,
1791
2108
  createBugBear,
1792
- isBugCategory
2109
+ isBugCategory,
2110
+ isNetworkError
1793
2111
  });