@bbearai/core 0.4.5 → 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,12 +765,18 @@ 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 [];
477
772
  }
478
- const mapped = (data || []).map((item) => ({
773
+ const mapped = (data || []).filter((item) => {
774
+ if (!item.test_case) {
775
+ console.warn("BugBear: Assignment returned without test_case", { id: item.id });
776
+ return false;
777
+ }
778
+ return true;
779
+ }).map((item) => ({
479
780
  id: item.id,
480
781
  status: item.status,
481
782
  startedAt: item.started_at,
@@ -723,6 +1024,7 @@ var BugBearClient = class {
723
1024
  * This empowers testers to shape better tests over time
724
1025
  */
725
1026
  async submitTestFeedback(options) {
1027
+ let feedbackPayload;
726
1028
  try {
727
1029
  const testerInfo = await this.getTesterInfo();
728
1030
  if (!testerInfo) {
@@ -732,7 +1034,17 @@ var BugBearClient = class {
732
1034
  if (feedback.rating < 1 || feedback.rating > 5) {
733
1035
  return { success: false, error: "Rating must be between 1 and 5" };
734
1036
  }
735
- const { error: feedbackError } = await this.supabase.from("test_feedback").insert({
1037
+ const optionalRatings = [
1038
+ { name: "clarityRating", value: feedback.clarityRating },
1039
+ { name: "stepsRating", value: feedback.stepsRating },
1040
+ { name: "relevanceRating", value: feedback.relevanceRating }
1041
+ ];
1042
+ for (const { name, value } of optionalRatings) {
1043
+ if (value !== void 0 && value !== null && (value < 1 || value > 5)) {
1044
+ return { success: false, error: `${name} must be between 1 and 5` };
1045
+ }
1046
+ }
1047
+ feedbackPayload = {
736
1048
  project_id: this.config.projectId,
737
1049
  test_case_id: testCaseId,
738
1050
  assignment_id: assignmentId || null,
@@ -750,8 +1062,13 @@ var BugBearClient = class {
750
1062
  platform: this.getDeviceInfo().platform,
751
1063
  time_to_complete_seconds: timeToCompleteSeconds || null,
752
1064
  screenshot_urls: screenshotUrls || []
753
- });
1065
+ };
1066
+ const { error: feedbackError } = await this.supabase.from("test_feedback").insert(feedbackPayload);
754
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
+ }
755
1072
  console.error("BugBear: Failed to submit feedback", feedbackError);
756
1073
  return { success: false, error: feedbackError.message };
757
1074
  }
@@ -768,6 +1085,10 @@ var BugBearClient = class {
768
1085
  return { success: true };
769
1086
  } catch (err) {
770
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
+ }
771
1092
  console.error("BugBear: Error submitting feedback", err);
772
1093
  return { success: false, error: message };
773
1094
  }
@@ -1402,6 +1723,7 @@ var BugBearClient = class {
1402
1723
  * Send a message to a thread
1403
1724
  */
1404
1725
  async sendMessage(threadId, content, attachments) {
1726
+ let insertData;
1405
1727
  try {
1406
1728
  const testerInfo = await this.getTesterInfo();
1407
1729
  if (!testerInfo) {
@@ -1413,7 +1735,7 @@ var BugBearClient = class {
1413
1735
  console.error("BugBear: Rate limit exceeded for messages");
1414
1736
  return false;
1415
1737
  }
1416
- const insertData = {
1738
+ insertData = {
1417
1739
  thread_id: threadId,
1418
1740
  sender_type: "tester",
1419
1741
  sender_tester_id: testerInfo.id,
@@ -1428,12 +1750,21 @@ var BugBearClient = class {
1428
1750
  }
1429
1751
  const { error } = await this.supabase.from("discussion_messages").insert(insertData);
1430
1752
  if (error) {
1753
+ if (this._queue && isNetworkError(error.message)) {
1754
+ await this._queue.enqueue("message", insertData);
1755
+ return false;
1756
+ }
1431
1757
  console.error("BugBear: Failed to send message", formatPgError(error));
1432
1758
  return false;
1433
1759
  }
1434
1760
  await this.markThreadAsRead(threadId);
1435
1761
  return true;
1436
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
+ }
1437
1768
  console.error("BugBear: Error sending message", err);
1438
1769
  return false;
1439
1770
  }
@@ -1770,8 +2101,11 @@ function createBugBear(config) {
1770
2101
  0 && (module.exports = {
1771
2102
  BUG_CATEGORIES,
1772
2103
  BugBearClient,
2104
+ LocalStorageAdapter,
2105
+ OfflineQueue,
1773
2106
  captureError,
1774
2107
  contextCapture,
1775
2108
  createBugBear,
1776
- isBugCategory
2109
+ isBugCategory,
2110
+ isNetworkError
1777
2111
  });