@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.mjs CHANGED
@@ -249,32 +249,312 @@ function captureError(error, errorInfo) {
249
249
  };
250
250
  }
251
251
 
252
+ // src/offline-queue.ts
253
+ var LocalStorageAdapter = class {
254
+ constructor() {
255
+ this.fallback = /* @__PURE__ */ new Map();
256
+ }
257
+ get isAvailable() {
258
+ try {
259
+ const key = "__bugbear_test__";
260
+ localStorage.setItem(key, "1");
261
+ localStorage.removeItem(key);
262
+ return true;
263
+ } catch {
264
+ return false;
265
+ }
266
+ }
267
+ async getItem(key) {
268
+ if (this.isAvailable) return localStorage.getItem(key);
269
+ return this.fallback.get(key) ?? null;
270
+ }
271
+ async setItem(key, value) {
272
+ if (this.isAvailable) {
273
+ localStorage.setItem(key, value);
274
+ } else {
275
+ this.fallback.set(key, value);
276
+ }
277
+ }
278
+ async removeItem(key) {
279
+ if (this.isAvailable) {
280
+ localStorage.removeItem(key);
281
+ } else {
282
+ this.fallback.delete(key);
283
+ }
284
+ }
285
+ };
286
+ var OfflineQueue = class {
287
+ constructor(config) {
288
+ this.items = [];
289
+ this.storageKey = "bugbear_offline_queue";
290
+ this.flushing = false;
291
+ this.handlers = /* @__PURE__ */ new Map();
292
+ this.maxItems = config.maxItems ?? 50;
293
+ this.maxRetries = config.maxRetries ?? 5;
294
+ this.storage = config.storage ?? new LocalStorageAdapter();
295
+ }
296
+ // ── Flush handler registration ──────────────────────────────
297
+ /** Register a handler that replays a queued operation. */
298
+ registerHandler(type, handler) {
299
+ this.handlers.set(type, handler);
300
+ }
301
+ // ── Change listener ─────────────────────────────────────────
302
+ /** Subscribe to queue count changes (for UI badges). */
303
+ onChange(callback) {
304
+ this.listener = callback;
305
+ }
306
+ notify() {
307
+ this.listener?.(this.items.length);
308
+ }
309
+ // ── Persistence ─────────────────────────────────────────────
310
+ /** Load queue from persistent storage. Call once after construction. */
311
+ async load() {
312
+ try {
313
+ const raw = await this.storage.getItem(this.storageKey);
314
+ if (raw) {
315
+ this.items = JSON.parse(raw);
316
+ }
317
+ } catch {
318
+ this.items = [];
319
+ }
320
+ this.notify();
321
+ }
322
+ async save() {
323
+ try {
324
+ await this.storage.setItem(this.storageKey, JSON.stringify(this.items));
325
+ } catch (err) {
326
+ console.error("BugBear: Failed to persist offline queue", err);
327
+ }
328
+ this.notify();
329
+ }
330
+ // ── Enqueue ─────────────────────────────────────────────────
331
+ /** Add a failed operation to the queue. Returns the item ID. */
332
+ async enqueue(type, payload) {
333
+ if (this.items.length >= this.maxItems) {
334
+ this.items.shift();
335
+ }
336
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
337
+ this.items.push({ id, type, payload, createdAt: Date.now(), retries: 0 });
338
+ await this.save();
339
+ return id;
340
+ }
341
+ // ── Accessors ───────────────────────────────────────────────
342
+ /** Number of items waiting to be flushed. */
343
+ get count() {
344
+ return this.items.length;
345
+ }
346
+ /** Read-only snapshot of pending items. */
347
+ get pending() {
348
+ return [...this.items];
349
+ }
350
+ /** Whether a flush is currently in progress. */
351
+ get isFlushing() {
352
+ return this.flushing;
353
+ }
354
+ // ── Flush ───────────────────────────────────────────────────
355
+ /**
356
+ * Process all queued items in FIFO order.
357
+ * Stops early if a network error is encountered (still offline).
358
+ */
359
+ async flush() {
360
+ if (this.flushing || this.items.length === 0) {
361
+ return { flushed: 0, failed: 0 };
362
+ }
363
+ this.flushing = true;
364
+ let flushed = 0;
365
+ let failed = 0;
366
+ const snapshot = [...this.items];
367
+ for (const item of snapshot) {
368
+ const handler = this.handlers.get(item.type);
369
+ if (!handler) {
370
+ failed++;
371
+ continue;
372
+ }
373
+ try {
374
+ const result = await handler(item.payload);
375
+ if (result.success) {
376
+ this.items = this.items.filter((i) => i.id !== item.id);
377
+ flushed++;
378
+ } else if (isNetworkError(result.error)) {
379
+ break;
380
+ } else {
381
+ const idx = this.items.findIndex((i) => i.id === item.id);
382
+ if (idx !== -1) {
383
+ this.items[idx].retries++;
384
+ if (this.items[idx].retries >= this.maxRetries) {
385
+ this.items.splice(idx, 1);
386
+ }
387
+ }
388
+ failed++;
389
+ }
390
+ } catch {
391
+ break;
392
+ }
393
+ }
394
+ await this.save();
395
+ this.flushing = false;
396
+ return { flushed, failed };
397
+ }
398
+ // ── Clear ───────────────────────────────────────────────────
399
+ /** Drop all queued items. */
400
+ async clear() {
401
+ this.items = [];
402
+ await this.save();
403
+ }
404
+ };
405
+ function isNetworkError(error) {
406
+ if (!error) return false;
407
+ const msg = error.toLowerCase();
408
+ 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
409
+ msg.includes("the internet connection appears to be offline") || msg.includes("a]server with the specified hostname could not be found");
410
+ }
411
+
252
412
  // src/client.ts
253
413
  var formatPgError = (e) => {
254
414
  if (!e || typeof e !== "object") return { raw: e };
255
415
  const { message, code, details, hint } = e;
256
416
  return { message, code, details, hint };
257
417
  };
258
- var DEFAULT_SUPABASE_URL = "https://kyxgzjnqgvapvlnvqawz.supabase.co";
259
- var getEnvVar = (key) => {
260
- try {
261
- if (typeof process !== "undefined" && process.env) {
262
- return process.env[key];
263
- }
264
- } catch {
265
- }
266
- return void 0;
267
- };
268
- var HOSTED_BUGBEAR_ANON_KEY = getEnvVar("BUGBEAR_ANON_KEY") || getEnvVar("NEXT_PUBLIC_BUGBEAR_ANON_KEY") || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt5eGd6am5xZ3ZhcHZsbnZxYXd6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjkyNjgwNDIsImV4cCI6MjA4NDg0NDA0Mn0.NUkAlCHLFjeRoisbmNUVoGb4R6uQ8xs5LAEIX1BWTwU";
269
418
  var BugBearClient = class {
270
419
  constructor(config) {
271
420
  this.navigationHistory = [];
272
421
  this.reportSubmitInFlight = false;
422
+ /** Offline queue — only created when config.offlineQueue.enabled is true. */
423
+ this._queue = null;
424
+ /** Active Realtime channel references for cleanup. */
425
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
426
+ this.realtimeChannels = [];
427
+ if (!config.supabaseUrl) {
428
+ throw new Error("BugBear: supabaseUrl is required. Get it from your BugBear project settings.");
429
+ }
430
+ if (!config.supabaseAnonKey) {
431
+ throw new Error("BugBear: supabaseAnonKey is required. Get it from your BugBear project settings.");
432
+ }
273
433
  this.config = config;
274
- this.supabase = createClient(
275
- config.supabaseUrl || DEFAULT_SUPABASE_URL,
276
- config.supabaseAnonKey || HOSTED_BUGBEAR_ANON_KEY
277
- );
434
+ this.supabase = createClient(config.supabaseUrl, config.supabaseAnonKey);
435
+ if (config.offlineQueue?.enabled) {
436
+ this._queue = new OfflineQueue({
437
+ enabled: true,
438
+ maxItems: config.offlineQueue.maxItems,
439
+ maxRetries: config.offlineQueue.maxRetries
440
+ });
441
+ this.registerQueueHandlers();
442
+ }
443
+ }
444
+ // ── Offline Queue ─────────────────────────────────────────
445
+ /**
446
+ * Access the offline queue (if enabled).
447
+ * Use this to check queue.count, subscribe to changes, or trigger flush.
448
+ */
449
+ get queue() {
450
+ return this._queue;
451
+ }
452
+ /**
453
+ * Initialize the offline queue with a platform-specific storage adapter.
454
+ * Must be called after construction for React Native (which supplies AsyncStorage).
455
+ * Web callers can skip this — LocalStorageAdapter is the default.
456
+ */
457
+ async initQueue(storage) {
458
+ if (!this._queue) return;
459
+ if (storage) {
460
+ this._queue = new OfflineQueue({
461
+ enabled: true,
462
+ maxItems: this.config.offlineQueue?.maxItems,
463
+ maxRetries: this.config.offlineQueue?.maxRetries,
464
+ storage
465
+ });
466
+ this.registerQueueHandlers();
467
+ }
468
+ await this._queue.load();
469
+ }
470
+ registerQueueHandlers() {
471
+ if (!this._queue) return;
472
+ this._queue.registerHandler("report", async (payload) => {
473
+ const { error } = await this.supabase.from("reports").insert(payload).select("id").single();
474
+ if (error) return { success: false, error: error.message };
475
+ return { success: true };
476
+ });
477
+ this._queue.registerHandler("message", async (payload) => {
478
+ const { error } = await this.supabase.from("discussion_messages").insert(payload);
479
+ if (error) return { success: false, error: error.message };
480
+ return { success: true };
481
+ });
482
+ this._queue.registerHandler("feedback", async (payload) => {
483
+ const { error } = await this.supabase.from("test_feedback").insert(payload);
484
+ if (error) return { success: false, error: error.message };
485
+ return { success: true };
486
+ });
487
+ }
488
+ // ── Realtime Subscriptions ─────────────────────────────────
489
+ /** Whether realtime is enabled in config. */
490
+ get realtimeEnabled() {
491
+ return !!this.config.realtime?.enabled;
492
+ }
493
+ /**
494
+ * Subscribe to postgres_changes on relevant tables.
495
+ * Each callback fires when the corresponding table has changes —
496
+ * the provider should call its refresh function in response.
497
+ * Returns a cleanup function that unsubscribes all channels.
498
+ */
499
+ subscribeToChanges(callbacks) {
500
+ this.unsubscribeAll();
501
+ const projectId = this.config.projectId;
502
+ const debounce = (fn, ms = 500) => {
503
+ let timer;
504
+ return () => {
505
+ clearTimeout(timer);
506
+ timer = setTimeout(fn, ms);
507
+ };
508
+ };
509
+ if (callbacks.onAssignmentChange) {
510
+ const debouncedCb = debounce(callbacks.onAssignmentChange);
511
+ const channel = this.supabase.channel("bugbear-assignments").on("postgres_changes", {
512
+ event: "*",
513
+ schema: "public",
514
+ table: "test_assignments",
515
+ filter: `project_id=eq.${projectId}`
516
+ }, debouncedCb).subscribe((status) => {
517
+ if (status === "CHANNEL_ERROR") {
518
+ console.warn("BugBear: Realtime subscription failed for test_assignments");
519
+ }
520
+ });
521
+ this.realtimeChannels.push(channel);
522
+ }
523
+ if (callbacks.onMessageChange) {
524
+ const debouncedCb = debounce(callbacks.onMessageChange);
525
+ const channel = this.supabase.channel("bugbear-messages").on("postgres_changes", {
526
+ event: "INSERT",
527
+ schema: "public",
528
+ table: "discussion_messages"
529
+ }, debouncedCb).subscribe((status) => {
530
+ if (status === "CHANNEL_ERROR") {
531
+ console.warn("BugBear: Realtime subscription failed for discussion_messages");
532
+ }
533
+ });
534
+ this.realtimeChannels.push(channel);
535
+ }
536
+ if (callbacks.onReportChange) {
537
+ const debouncedCb = debounce(callbacks.onReportChange);
538
+ const channel = this.supabase.channel("bugbear-reports").on("postgres_changes", {
539
+ event: "UPDATE",
540
+ schema: "public",
541
+ table: "reports",
542
+ filter: `project_id=eq.${projectId}`
543
+ }, debouncedCb).subscribe((status) => {
544
+ if (status === "CHANNEL_ERROR") {
545
+ console.warn("BugBear: Realtime subscription failed for reports");
546
+ }
547
+ });
548
+ this.realtimeChannels.push(channel);
549
+ }
550
+ return () => this.unsubscribeAll();
551
+ }
552
+ /** Remove all active Realtime channels. */
553
+ unsubscribeAll() {
554
+ for (const channel of this.realtimeChannels) {
555
+ this.supabase.removeChannel(channel);
556
+ }
557
+ this.realtimeChannels = [];
278
558
  }
279
559
  /**
280
560
  * Track navigation for context.
@@ -334,6 +614,7 @@ var BugBearClient = class {
334
614
  return { success: false, error: "A report is already being submitted" };
335
615
  }
336
616
  this.reportSubmitInFlight = true;
617
+ let fullReport;
337
618
  try {
338
619
  const validationError = this.validateReport(report);
339
620
  if (validationError) {
@@ -350,7 +631,7 @@ var BugBearClient = class {
350
631
  return { success: false, error: "User not authenticated" };
351
632
  }
352
633
  const testerInfo = await this.getTesterInfo();
353
- const fullReport = {
634
+ fullReport = {
354
635
  project_id: this.config.projectId,
355
636
  reporter_id: userInfo.id,
356
637
  // User ID from host app (required)
@@ -377,6 +658,10 @@ var BugBearClient = class {
377
658
  };
378
659
  const { data, error } = await this.supabase.from("reports").insert(fullReport).select("id").single();
379
660
  if (error) {
661
+ if (this._queue && isNetworkError(error.message)) {
662
+ await this._queue.enqueue("report", fullReport);
663
+ return { success: false, queued: true, error: "Queued \u2014 will send when online" };
664
+ }
380
665
  console.error("BugBear: Failed to submit report", error.message);
381
666
  return { success: false, error: error.message };
382
667
  }
@@ -386,6 +671,10 @@ var BugBearClient = class {
386
671
  return { success: true, reportId: data.id };
387
672
  } catch (err) {
388
673
  const message = err instanceof Error ? err.message : "Unknown error";
674
+ if (this._queue && fullReport && isNetworkError(message)) {
675
+ await this._queue.enqueue("report", fullReport);
676
+ return { success: false, queued: true, error: "Queued \u2014 will send when online" };
677
+ }
389
678
  return { success: false, error: message };
390
679
  } finally {
391
680
  this.reportSubmitInFlight = false;
@@ -395,10 +684,13 @@ var BugBearClient = class {
395
684
  * Get assigned tests for current user
396
685
  * First looks up the tester by email, then fetches their assignments
397
686
  */
398
- async getAssignedTests() {
687
+ async getAssignedTests(options) {
399
688
  try {
400
689
  const testerInfo = await this.getTesterInfo();
401
690
  if (!testerInfo) return [];
691
+ const pageSize = Math.min(options?.pageSize ?? 100, 100);
692
+ const from = (options?.page ?? 0) * pageSize;
693
+ const to = from + pageSize - 1;
402
694
  const { data, error } = await this.supabase.from("test_assignments").select(`
403
695
  id,
404
696
  status,
@@ -439,7 +731,7 @@ var BugBearClient = class {
439
731
  login_hint
440
732
  )
441
733
  )
442
- `).eq("project_id", this.config.projectId).eq("tester_id", testerInfo.id).in("status", ["pending", "in_progress"]).order("created_at", { ascending: true }).limit(100);
734
+ `).eq("project_id", this.config.projectId).eq("tester_id", testerInfo.id).in("status", ["pending", "in_progress"]).order("created_at", { ascending: true }).range(from, to);
443
735
  if (error) {
444
736
  console.error("BugBear: Failed to fetch assignments", formatPgError(error));
445
737
  return [];
@@ -698,6 +990,7 @@ var BugBearClient = class {
698
990
  * This empowers testers to shape better tests over time
699
991
  */
700
992
  async submitTestFeedback(options) {
993
+ let feedbackPayload;
701
994
  try {
702
995
  const testerInfo = await this.getTesterInfo();
703
996
  if (!testerInfo) {
@@ -717,7 +1010,7 @@ var BugBearClient = class {
717
1010
  return { success: false, error: `${name} must be between 1 and 5` };
718
1011
  }
719
1012
  }
720
- const { error: feedbackError } = await this.supabase.from("test_feedback").insert({
1013
+ feedbackPayload = {
721
1014
  project_id: this.config.projectId,
722
1015
  test_case_id: testCaseId,
723
1016
  assignment_id: assignmentId || null,
@@ -735,8 +1028,13 @@ var BugBearClient = class {
735
1028
  platform: this.getDeviceInfo().platform,
736
1029
  time_to_complete_seconds: timeToCompleteSeconds || null,
737
1030
  screenshot_urls: screenshotUrls || []
738
- });
1031
+ };
1032
+ const { error: feedbackError } = await this.supabase.from("test_feedback").insert(feedbackPayload);
739
1033
  if (feedbackError) {
1034
+ if (this._queue && isNetworkError(feedbackError.message)) {
1035
+ await this._queue.enqueue("feedback", feedbackPayload);
1036
+ return { success: false, queued: true, error: "Queued \u2014 will send when online" };
1037
+ }
740
1038
  console.error("BugBear: Failed to submit feedback", feedbackError);
741
1039
  return { success: false, error: feedbackError.message };
742
1040
  }
@@ -753,6 +1051,10 @@ var BugBearClient = class {
753
1051
  return { success: true };
754
1052
  } catch (err) {
755
1053
  const message = err instanceof Error ? err.message : "Unknown error";
1054
+ if (this._queue && feedbackPayload && isNetworkError(message)) {
1055
+ await this._queue.enqueue("feedback", feedbackPayload);
1056
+ return { success: false, queued: true, error: "Queued \u2014 will send when online" };
1057
+ }
756
1058
  console.error("BugBear: Error submitting feedback", err);
757
1059
  return { success: false, error: message };
758
1060
  }
@@ -1387,6 +1689,7 @@ var BugBearClient = class {
1387
1689
  * Send a message to a thread
1388
1690
  */
1389
1691
  async sendMessage(threadId, content, attachments) {
1692
+ let insertData;
1390
1693
  try {
1391
1694
  const testerInfo = await this.getTesterInfo();
1392
1695
  if (!testerInfo) {
@@ -1398,7 +1701,7 @@ var BugBearClient = class {
1398
1701
  console.error("BugBear: Rate limit exceeded for messages");
1399
1702
  return false;
1400
1703
  }
1401
- const insertData = {
1704
+ insertData = {
1402
1705
  thread_id: threadId,
1403
1706
  sender_type: "tester",
1404
1707
  sender_tester_id: testerInfo.id,
@@ -1413,12 +1716,21 @@ var BugBearClient = class {
1413
1716
  }
1414
1717
  const { error } = await this.supabase.from("discussion_messages").insert(insertData);
1415
1718
  if (error) {
1719
+ if (this._queue && isNetworkError(error.message)) {
1720
+ await this._queue.enqueue("message", insertData);
1721
+ return false;
1722
+ }
1416
1723
  console.error("BugBear: Failed to send message", formatPgError(error));
1417
1724
  return false;
1418
1725
  }
1419
1726
  await this.markThreadAsRead(threadId);
1420
1727
  return true;
1421
1728
  } catch (err) {
1729
+ const message = err instanceof Error ? err.message : "Unknown error";
1730
+ if (this._queue && insertData && isNetworkError(message)) {
1731
+ await this._queue.enqueue("message", insertData);
1732
+ return false;
1733
+ }
1422
1734
  console.error("BugBear: Error sending message", err);
1423
1735
  return false;
1424
1736
  }
@@ -1754,8 +2066,11 @@ function createBugBear(config) {
1754
2066
  export {
1755
2067
  BUG_CATEGORIES,
1756
2068
  BugBearClient,
2069
+ LocalStorageAdapter,
2070
+ OfflineQueue,
1757
2071
  captureError,
1758
2072
  contextCapture,
1759
2073
  createBugBear,
1760
- isBugCategory
2074
+ isBugCategory,
2075
+ isNetworkError
1761
2076
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bbearai/core",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
4
4
  "description": "Core utilities and types for BugBear QA platform",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",