@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.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,12 +731,18 @@ 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 [];
446
738
  }
447
- const mapped = (data || []).map((item) => ({
739
+ const mapped = (data || []).filter((item) => {
740
+ if (!item.test_case) {
741
+ console.warn("BugBear: Assignment returned without test_case", { id: item.id });
742
+ return false;
743
+ }
744
+ return true;
745
+ }).map((item) => ({
448
746
  id: item.id,
449
747
  status: item.status,
450
748
  startedAt: item.started_at,
@@ -692,6 +990,7 @@ var BugBearClient = class {
692
990
  * This empowers testers to shape better tests over time
693
991
  */
694
992
  async submitTestFeedback(options) {
993
+ let feedbackPayload;
695
994
  try {
696
995
  const testerInfo = await this.getTesterInfo();
697
996
  if (!testerInfo) {
@@ -701,7 +1000,17 @@ var BugBearClient = class {
701
1000
  if (feedback.rating < 1 || feedback.rating > 5) {
702
1001
  return { success: false, error: "Rating must be between 1 and 5" };
703
1002
  }
704
- const { error: feedbackError } = await this.supabase.from("test_feedback").insert({
1003
+ const optionalRatings = [
1004
+ { name: "clarityRating", value: feedback.clarityRating },
1005
+ { name: "stepsRating", value: feedback.stepsRating },
1006
+ { name: "relevanceRating", value: feedback.relevanceRating }
1007
+ ];
1008
+ for (const { name, value } of optionalRatings) {
1009
+ if (value !== void 0 && value !== null && (value < 1 || value > 5)) {
1010
+ return { success: false, error: `${name} must be between 1 and 5` };
1011
+ }
1012
+ }
1013
+ feedbackPayload = {
705
1014
  project_id: this.config.projectId,
706
1015
  test_case_id: testCaseId,
707
1016
  assignment_id: assignmentId || null,
@@ -719,8 +1028,13 @@ var BugBearClient = class {
719
1028
  platform: this.getDeviceInfo().platform,
720
1029
  time_to_complete_seconds: timeToCompleteSeconds || null,
721
1030
  screenshot_urls: screenshotUrls || []
722
- });
1031
+ };
1032
+ const { error: feedbackError } = await this.supabase.from("test_feedback").insert(feedbackPayload);
723
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
+ }
724
1038
  console.error("BugBear: Failed to submit feedback", feedbackError);
725
1039
  return { success: false, error: feedbackError.message };
726
1040
  }
@@ -737,6 +1051,10 @@ var BugBearClient = class {
737
1051
  return { success: true };
738
1052
  } catch (err) {
739
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
+ }
740
1058
  console.error("BugBear: Error submitting feedback", err);
741
1059
  return { success: false, error: message };
742
1060
  }
@@ -1371,6 +1689,7 @@ var BugBearClient = class {
1371
1689
  * Send a message to a thread
1372
1690
  */
1373
1691
  async sendMessage(threadId, content, attachments) {
1692
+ let insertData;
1374
1693
  try {
1375
1694
  const testerInfo = await this.getTesterInfo();
1376
1695
  if (!testerInfo) {
@@ -1382,7 +1701,7 @@ var BugBearClient = class {
1382
1701
  console.error("BugBear: Rate limit exceeded for messages");
1383
1702
  return false;
1384
1703
  }
1385
- const insertData = {
1704
+ insertData = {
1386
1705
  thread_id: threadId,
1387
1706
  sender_type: "tester",
1388
1707
  sender_tester_id: testerInfo.id,
@@ -1397,12 +1716,21 @@ var BugBearClient = class {
1397
1716
  }
1398
1717
  const { error } = await this.supabase.from("discussion_messages").insert(insertData);
1399
1718
  if (error) {
1719
+ if (this._queue && isNetworkError(error.message)) {
1720
+ await this._queue.enqueue("message", insertData);
1721
+ return false;
1722
+ }
1400
1723
  console.error("BugBear: Failed to send message", formatPgError(error));
1401
1724
  return false;
1402
1725
  }
1403
1726
  await this.markThreadAsRead(threadId);
1404
1727
  return true;
1405
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
+ }
1406
1734
  console.error("BugBear: Error sending message", err);
1407
1735
  return false;
1408
1736
  }
@@ -1738,8 +2066,11 @@ function createBugBear(config) {
1738
2066
  export {
1739
2067
  BUG_CATEGORIES,
1740
2068
  BugBearClient,
2069
+ LocalStorageAdapter,
2070
+ OfflineQueue,
1741
2071
  captureError,
1742
2072
  contextCapture,
1743
2073
  createBugBear,
1744
- isBugCategory
2074
+ isBugCategory,
2075
+ isNetworkError
1745
2076
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bbearai/core",
3
- "version": "0.4.5",
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",