@testerpaykit/sdk 0.0.0-stage → 1.1.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/README.md +2 -2
- package/dist/index.d.mts +908 -0
- package/dist/index.d.ts +908 -0
- package/dist/index.js +2097 -0
- package/dist/index.mjs +2063 -0
- package/package.json +55 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2063 @@
|
|
|
1
|
+
// src/TesterPayKit.ts
|
|
2
|
+
import { COLLECTIONS as COLLECTIONS6, BUG_REPORT_TYPES, BUG_REPORT_CATEGORIES, BUG_REPORT_SEVERITIES } from "@testerpaykit/core";
|
|
3
|
+
|
|
4
|
+
// src/SessionManager.ts
|
|
5
|
+
import { COLLECTIONS } from "@testerpaykit/core";
|
|
6
|
+
import { v4 as uuidv4 } from "uuid";
|
|
7
|
+
var SessionManager = class {
|
|
8
|
+
// non-null while clock is running
|
|
9
|
+
constructor(storage) {
|
|
10
|
+
this.currentSessionId = null;
|
|
11
|
+
this.sessionStartTime = 0;
|
|
12
|
+
// Foreground (paid-time) clock
|
|
13
|
+
this.foregroundAccumulatedMs = 0;
|
|
14
|
+
this.foregroundClockStartMs = null;
|
|
15
|
+
this.storage = storage;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Start a new testing session.
|
|
19
|
+
*
|
|
20
|
+
* Creates a new session with a unique ID and stores it in the database.
|
|
21
|
+
* If there's an active session, it will be ended before starting the new one.
|
|
22
|
+
*
|
|
23
|
+
* @returns Promise resolving to the new session ID
|
|
24
|
+
*/
|
|
25
|
+
async startSession() {
|
|
26
|
+
if (this.hasActiveSession()) {
|
|
27
|
+
await this.endSession();
|
|
28
|
+
}
|
|
29
|
+
const sessionId = uuidv4();
|
|
30
|
+
this.currentSessionId = sessionId;
|
|
31
|
+
this.sessionStartTime = Date.now();
|
|
32
|
+
this.foregroundAccumulatedMs = 0;
|
|
33
|
+
this.foregroundClockStartMs = Date.now();
|
|
34
|
+
await this.storage.insert(COLLECTIONS.sessions, {
|
|
35
|
+
id: sessionId,
|
|
36
|
+
started_at: this.sessionStartTime,
|
|
37
|
+
screen_view_count: 0,
|
|
38
|
+
tap_count: 0,
|
|
39
|
+
ended_at: null,
|
|
40
|
+
end_reason: null,
|
|
41
|
+
duration_seconds: null,
|
|
42
|
+
foreground_seconds: null
|
|
43
|
+
});
|
|
44
|
+
return sessionId;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* End the current testing session.
|
|
48
|
+
*
|
|
49
|
+
* Calculates session duration and updates the database.
|
|
50
|
+
* After ending, no session will be active.
|
|
51
|
+
*
|
|
52
|
+
* @param endReason - Reason for ending the session (default: 'completed')
|
|
53
|
+
*/
|
|
54
|
+
async endSession(endReason = "completed") {
|
|
55
|
+
if (!this.currentSessionId) return;
|
|
56
|
+
this.pauseForegroundClock();
|
|
57
|
+
const endTime = Date.now();
|
|
58
|
+
const duration = Math.floor((endTime - this.sessionStartTime) / 1e3);
|
|
59
|
+
const foregroundSeconds = this.getForegroundSeconds();
|
|
60
|
+
await this.storage.update(
|
|
61
|
+
COLLECTIONS.sessions,
|
|
62
|
+
{ id: this.currentSessionId },
|
|
63
|
+
{
|
|
64
|
+
ended_at: endTime,
|
|
65
|
+
duration_seconds: duration,
|
|
66
|
+
end_reason: endReason,
|
|
67
|
+
foreground_seconds: foregroundSeconds
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
this.currentSessionId = null;
|
|
71
|
+
this.sessionStartTime = 0;
|
|
72
|
+
this.foregroundAccumulatedMs = 0;
|
|
73
|
+
this.foregroundClockStartMs = null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Pause the foreground (paid-time) clock.
|
|
77
|
+
* Call when the app moves to background. Has no effect if already paused.
|
|
78
|
+
*/
|
|
79
|
+
pauseForegroundClock() {
|
|
80
|
+
if (this.foregroundClockStartMs !== null) {
|
|
81
|
+
this.foregroundAccumulatedMs += Date.now() - this.foregroundClockStartMs;
|
|
82
|
+
this.foregroundClockStartMs = null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resume the foreground (paid-time) clock.
|
|
87
|
+
* Call when the app returns to foreground. Has no effect if already running.
|
|
88
|
+
*/
|
|
89
|
+
resumeForegroundClock() {
|
|
90
|
+
if (this.foregroundClockStartMs === null) {
|
|
91
|
+
this.foregroundClockStartMs = Date.now();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Get the accumulated foreground seconds for the current session.
|
|
96
|
+
*
|
|
97
|
+
* @returns Foreground time in whole seconds
|
|
98
|
+
*/
|
|
99
|
+
getForegroundSeconds() {
|
|
100
|
+
let totalMs = this.foregroundAccumulatedMs;
|
|
101
|
+
if (this.foregroundClockStartMs !== null) {
|
|
102
|
+
totalMs += Date.now() - this.foregroundClockStartMs;
|
|
103
|
+
}
|
|
104
|
+
return Math.floor(totalMs / 1e3);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Get the current session ID.
|
|
108
|
+
*
|
|
109
|
+
* @returns Current session ID or null if no session is active
|
|
110
|
+
*/
|
|
111
|
+
getCurrentSessionId() {
|
|
112
|
+
return this.currentSessionId;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Check if there's an active session.
|
|
116
|
+
*
|
|
117
|
+
* @returns true if a session is currently active
|
|
118
|
+
*/
|
|
119
|
+
hasActiveSession() {
|
|
120
|
+
return this.currentSessionId !== null;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Get the current session duration in seconds.
|
|
124
|
+
*
|
|
125
|
+
* @returns Duration in seconds, or 0 if no session is active
|
|
126
|
+
*/
|
|
127
|
+
getSessionDuration() {
|
|
128
|
+
if (!this.hasActiveSession()) return 0;
|
|
129
|
+
return Math.floor((Date.now() - this.sessionStartTime) / 1e3);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get the epoch-ms timestamp when the current session started.
|
|
133
|
+
*
|
|
134
|
+
* @returns Epoch ms, or null when no session is active
|
|
135
|
+
*/
|
|
136
|
+
getCurrentSessionStartedAt() {
|
|
137
|
+
if (!this.hasActiveSession()) return null;
|
|
138
|
+
return this.sessionStartTime;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Increment the screen view count for the current session.
|
|
142
|
+
* Internal method used by EventTracker.
|
|
143
|
+
*/
|
|
144
|
+
async incrementScreenViews() {
|
|
145
|
+
if (!this.currentSessionId) return;
|
|
146
|
+
const row = await this.storage.findOne(
|
|
147
|
+
COLLECTIONS.sessions,
|
|
148
|
+
{ id: this.currentSessionId }
|
|
149
|
+
);
|
|
150
|
+
if (!row) return;
|
|
151
|
+
await this.storage.update(
|
|
152
|
+
COLLECTIONS.sessions,
|
|
153
|
+
{ id: this.currentSessionId },
|
|
154
|
+
{ screen_view_count: (row.screen_view_count ?? 0) + 1 }
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Increment the tap count for the current session.
|
|
159
|
+
* Internal method used by EventTracker.
|
|
160
|
+
*/
|
|
161
|
+
async incrementTaps() {
|
|
162
|
+
if (!this.currentSessionId) return;
|
|
163
|
+
const row = await this.storage.findOne(
|
|
164
|
+
COLLECTIONS.sessions,
|
|
165
|
+
{ id: this.currentSessionId }
|
|
166
|
+
);
|
|
167
|
+
if (!row) return;
|
|
168
|
+
await this.storage.update(
|
|
169
|
+
COLLECTIONS.sessions,
|
|
170
|
+
{ id: this.currentSessionId },
|
|
171
|
+
{ tap_count: (row.tap_count ?? 0) + 1 }
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// src/EventTracker.ts
|
|
177
|
+
import { COLLECTIONS as COLLECTIONS2 } from "@testerpaykit/core";
|
|
178
|
+
import { v4 as uuidv42 } from "uuid";
|
|
179
|
+
var EventTracker = class {
|
|
180
|
+
constructor(storage, captureFlags) {
|
|
181
|
+
this.sessionId = null;
|
|
182
|
+
this.storage = storage;
|
|
183
|
+
this.captureFlags = {
|
|
184
|
+
captureScreenViews: true,
|
|
185
|
+
captureTapEvents: true,
|
|
186
|
+
captureErrors: true,
|
|
187
|
+
captureCrashes: true,
|
|
188
|
+
...captureFlags
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Set the current session ID.
|
|
193
|
+
* Must be called before tracking events.
|
|
194
|
+
*
|
|
195
|
+
* @param sessionId - The active session ID
|
|
196
|
+
*/
|
|
197
|
+
setSessionId(sessionId) {
|
|
198
|
+
this.sessionId = sessionId;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Track a screen view event.
|
|
202
|
+
* Gated by captureScreenViews flag.
|
|
203
|
+
*
|
|
204
|
+
* @param screenName - Name or route of the screen being viewed
|
|
205
|
+
* @param metadata - Optional additional metadata
|
|
206
|
+
*/
|
|
207
|
+
async trackScreenView(screenName, metadata) {
|
|
208
|
+
if (!this.captureFlags.captureScreenViews) return;
|
|
209
|
+
await this.trackEvent("screen_view", {
|
|
210
|
+
screenName,
|
|
211
|
+
...metadata
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Track a tap/click event.
|
|
216
|
+
* Gated by captureTapEvents flag.
|
|
217
|
+
*
|
|
218
|
+
* @param screenName - Name of the screen where the tap occurred
|
|
219
|
+
* @param elementId - Optional ID of the tapped element
|
|
220
|
+
*/
|
|
221
|
+
async trackTap(screenName, elementId) {
|
|
222
|
+
if (!this.captureFlags.captureTapEvents) return;
|
|
223
|
+
await this.trackEvent("tap", {
|
|
224
|
+
screenName,
|
|
225
|
+
elementId
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Track a custom event.
|
|
230
|
+
*
|
|
231
|
+
* @param eventType - Type of the event
|
|
232
|
+
* @param metadata - Optional event metadata
|
|
233
|
+
*/
|
|
234
|
+
async trackEvent(eventType, metadata) {
|
|
235
|
+
if (!this.sessionId) {
|
|
236
|
+
console.warn("No active session. Event not tracked:", eventType);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await this.storage.insert(COLLECTIONS2.events, {
|
|
240
|
+
id: uuidv42(),
|
|
241
|
+
session_id: this.sessionId,
|
|
242
|
+
timestamp: Date.now(),
|
|
243
|
+
event_type: eventType,
|
|
244
|
+
screen_name: metadata?.screenName ?? null,
|
|
245
|
+
element_id: metadata?.elementId ?? null,
|
|
246
|
+
metadata: metadata ? JSON.stringify(metadata) : null
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Track an error event.
|
|
251
|
+
* Gated by captureErrors flag.
|
|
252
|
+
*
|
|
253
|
+
* @param message - Error message
|
|
254
|
+
* @param stack - Optional stack trace
|
|
255
|
+
*/
|
|
256
|
+
async trackError(message, stack) {
|
|
257
|
+
if (!this.captureFlags.captureErrors) return;
|
|
258
|
+
await this.storeBugReport("error", message, stack);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Track a crash event.
|
|
262
|
+
* Gated by captureCrashes flag.
|
|
263
|
+
*
|
|
264
|
+
* @param message - Crash message
|
|
265
|
+
* @param stack - Stack trace
|
|
266
|
+
*/
|
|
267
|
+
async trackCrash(message, stack) {
|
|
268
|
+
if (!this.captureFlags.captureCrashes) return;
|
|
269
|
+
await this.storeBugReport("crash", message, stack);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Track a feedback submission.
|
|
273
|
+
*
|
|
274
|
+
* @param content - Feedback content
|
|
275
|
+
* @param rating - Optional numeric rating
|
|
276
|
+
*/
|
|
277
|
+
async trackFeedback(content, rating) {
|
|
278
|
+
if (!this.sessionId) {
|
|
279
|
+
console.warn("No active session. Feedback not tracked.");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const feedbackId = uuidv42();
|
|
283
|
+
await this.storage.insert(COLLECTIONS2.feedbacks, {
|
|
284
|
+
id: feedbackId,
|
|
285
|
+
session_id: this.sessionId,
|
|
286
|
+
content,
|
|
287
|
+
rating: rating ?? null,
|
|
288
|
+
created_at: Date.now()
|
|
289
|
+
});
|
|
290
|
+
await this.trackEvent("feedback_submit", {
|
|
291
|
+
feedbackId,
|
|
292
|
+
length: content.length,
|
|
293
|
+
hasRating: rating !== void 0
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Track task start event.
|
|
298
|
+
*
|
|
299
|
+
* @param taskId - Unique task identifier
|
|
300
|
+
* @param title - Task title
|
|
301
|
+
*/
|
|
302
|
+
async trackTaskStart(taskId, title) {
|
|
303
|
+
if (!this.sessionId) {
|
|
304
|
+
console.warn("No active session. Task start not tracked.");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
const existing = await this.storage.findOne(
|
|
309
|
+
COLLECTIONS2.tasks,
|
|
310
|
+
{ id: taskId }
|
|
311
|
+
);
|
|
312
|
+
if (existing) {
|
|
313
|
+
await this.storage.update(COLLECTIONS2.tasks, { id: taskId }, { started_at: now });
|
|
314
|
+
} else {
|
|
315
|
+
await this.storage.insert(COLLECTIONS2.tasks, {
|
|
316
|
+
id: taskId,
|
|
317
|
+
task_preset_id: taskId,
|
|
318
|
+
title,
|
|
319
|
+
started_at: now,
|
|
320
|
+
created_at: now,
|
|
321
|
+
is_completed: 0,
|
|
322
|
+
completed_at: null
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
await this.trackEvent("task_start", { taskId, title });
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Track task completion event.
|
|
329
|
+
*
|
|
330
|
+
* @param taskId - Unique task identifier
|
|
331
|
+
*/
|
|
332
|
+
async trackTaskComplete(taskId) {
|
|
333
|
+
if (!this.sessionId) {
|
|
334
|
+
console.warn("No active session. Task completion not tracked.");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
await this.storage.update(
|
|
338
|
+
COLLECTIONS2.tasks,
|
|
339
|
+
{ id: taskId },
|
|
340
|
+
{ completed_at: Date.now(), is_completed: 1 }
|
|
341
|
+
);
|
|
342
|
+
await this.trackEvent("task_complete", { taskId });
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Store a bug report (error or crash).
|
|
346
|
+
*
|
|
347
|
+
* @param severity - Severity level ('error' or 'crash')
|
|
348
|
+
* @param message - Error/crash message
|
|
349
|
+
* @param stack - Optional stack trace
|
|
350
|
+
*/
|
|
351
|
+
async storeBugReport(severity, message, stack) {
|
|
352
|
+
if (!this.sessionId) {
|
|
353
|
+
console.warn(`No active session. ${severity} not tracked.`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
await this.storage.insert(COLLECTIONS2.bugReports, {
|
|
357
|
+
id: uuidv42(),
|
|
358
|
+
session_id: this.sessionId,
|
|
359
|
+
timestamp: Date.now(),
|
|
360
|
+
message,
|
|
361
|
+
stack: stack ?? null,
|
|
362
|
+
severity
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Get event counts for a time period.
|
|
367
|
+
* Internal method for metrics aggregation.
|
|
368
|
+
*
|
|
369
|
+
* @param start - Start timestamp
|
|
370
|
+
* @param end - End timestamp
|
|
371
|
+
* @returns Map of event types to counts
|
|
372
|
+
*/
|
|
373
|
+
async getEventCounts(start, end) {
|
|
374
|
+
const events = await this.storage.find(
|
|
375
|
+
COLLECTIONS2.events
|
|
376
|
+
);
|
|
377
|
+
const counts = {};
|
|
378
|
+
for (const event of events) {
|
|
379
|
+
if (event.timestamp >= start && event.timestamp <= end) {
|
|
380
|
+
counts[event.event_type] = (counts[event.event_type] ?? 0) + 1;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return counts;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Get unique screen names for a time period.
|
|
387
|
+
* Internal method for metrics aggregation.
|
|
388
|
+
*
|
|
389
|
+
* @param start - Start timestamp
|
|
390
|
+
* @param end - End timestamp
|
|
391
|
+
* @returns Array of unique screen names
|
|
392
|
+
*/
|
|
393
|
+
async getUniqueScreens(start, end) {
|
|
394
|
+
const events = await this.storage.find(COLLECTIONS2.events);
|
|
395
|
+
const seen = /* @__PURE__ */ new Set();
|
|
396
|
+
for (const event of events) {
|
|
397
|
+
if (event.event_type === "screen_view" && event.screen_name !== null && event.screen_name !== void 0 && event.timestamp >= start && event.timestamp <= end) {
|
|
398
|
+
seen.add(event.screen_name);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return Array.from(seen);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// src/MetricsAggregator.ts
|
|
406
|
+
import {
|
|
407
|
+
COLLECTIONS as COLLECTIONS3
|
|
408
|
+
} from "@testerpaykit/core";
|
|
409
|
+
import { v4 as uuidv43 } from "uuid";
|
|
410
|
+
var ValidationConstants = {
|
|
411
|
+
minActiveMinutes: 10,
|
|
412
|
+
minUniqueScreens: 5,
|
|
413
|
+
maxTapsPerMinute: 100,
|
|
414
|
+
minSessions: 1
|
|
415
|
+
};
|
|
416
|
+
var MetricsAggregator = class {
|
|
417
|
+
constructor(storage, config) {
|
|
418
|
+
this.storage = storage;
|
|
419
|
+
this.config = config;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Aggregate a weekly summary for the past 7 days.
|
|
423
|
+
*
|
|
424
|
+
* @returns Promise resolving to the generated WeeklySummary
|
|
425
|
+
*/
|
|
426
|
+
async aggregateWeeklySummary() {
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
const weekAgo = now - 7 * 24 * 60 * 60 * 1e3;
|
|
429
|
+
return this.aggregateSummaryForPeriod(weekAgo, now);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Aggregate a summary for a specific time period.
|
|
433
|
+
*
|
|
434
|
+
* @param startTime - Start timestamp (milliseconds)
|
|
435
|
+
* @param endTime - End timestamp (milliseconds)
|
|
436
|
+
* @returns Promise resolving to the generated WeeklySummary
|
|
437
|
+
*/
|
|
438
|
+
async aggregateSummaryForPeriod(startTime, endTime) {
|
|
439
|
+
const sessions = await this.getSessions(startTime, endTime);
|
|
440
|
+
const uniqueScreens = await this.getUniqueScreens(startTime, endTime);
|
|
441
|
+
const feedbackStats = await this.getFeedbackStats(startTime, endTime);
|
|
442
|
+
const taskStats = await this.getTaskStats(startTime, endTime);
|
|
443
|
+
const bugReportStats = await this.getBugReportStats(startTime, endTime);
|
|
444
|
+
const totalSessions = sessions.length;
|
|
445
|
+
const totalDuration = sessions.reduce((sum, s) => sum + (s.duration_seconds || 0), 0);
|
|
446
|
+
const activeMinutes = Math.floor(totalDuration / 60);
|
|
447
|
+
const totalScreenViews = sessions.reduce((sum, s) => sum + s.screen_view_count, 0);
|
|
448
|
+
const totalTaps = sessions.reduce((sum, s) => sum + s.tap_count, 0);
|
|
449
|
+
const avgTapsPerMinute = activeMinutes > 0 ? totalTaps / activeMinutes : 0;
|
|
450
|
+
const sessionMetrics = {
|
|
451
|
+
totalSessions,
|
|
452
|
+
activeMinutes,
|
|
453
|
+
uniqueScreens: uniqueScreens.length,
|
|
454
|
+
totalScreenViews,
|
|
455
|
+
avgTapsPerMinute,
|
|
456
|
+
tasksStarted: taskStats.started,
|
|
457
|
+
tasksCompleted: taskStats.completed,
|
|
458
|
+
crashes: bugReportStats.crashes,
|
|
459
|
+
errors: bugReportStats.errors,
|
|
460
|
+
feedbackCount: feedbackStats.count,
|
|
461
|
+
avgFeedbackLength: feedbackStats.avgLength,
|
|
462
|
+
qualityFlags: this.calculateQualityFlags(
|
|
463
|
+
totalSessions,
|
|
464
|
+
activeMinutes,
|
|
465
|
+
uniqueScreens.length,
|
|
466
|
+
avgTapsPerMinute
|
|
467
|
+
)
|
|
468
|
+
};
|
|
469
|
+
const integrityHash = this.generateIntegrityHash(sessionMetrics);
|
|
470
|
+
const summary = {
|
|
471
|
+
id: uuidv43(),
|
|
472
|
+
projectId: this.config.projectId,
|
|
473
|
+
testerId: this.config.testerId,
|
|
474
|
+
deviceId: this.config.deviceId,
|
|
475
|
+
periodStart: new Date(startTime).toISOString(),
|
|
476
|
+
periodEnd: new Date(endTime).toISOString(),
|
|
477
|
+
metrics: sessionMetrics,
|
|
478
|
+
integrityHash,
|
|
479
|
+
deviceMetadata: this.config.deviceMetadata,
|
|
480
|
+
appMetadata: this.config.appMetadata,
|
|
481
|
+
status: "pending",
|
|
482
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
483
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
484
|
+
estimatedPayout: this.calculateEstimatedPayout(sessionMetrics)
|
|
485
|
+
};
|
|
486
|
+
return summary;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Store a weekly summary in the local database.
|
|
490
|
+
*
|
|
491
|
+
* @param summary - The summary to store
|
|
492
|
+
*/
|
|
493
|
+
async storeSummary(summary) {
|
|
494
|
+
await this.storage.insert(COLLECTIONS3.weeklySummaries, {
|
|
495
|
+
id: summary.id,
|
|
496
|
+
data: JSON.stringify(summary),
|
|
497
|
+
status: summary.status,
|
|
498
|
+
created_at: Date.now()
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Get pending summaries from the local database.
|
|
503
|
+
*
|
|
504
|
+
* @returns Array of pending WeeklySummary objects
|
|
505
|
+
*/
|
|
506
|
+
async getPendingSummaries() {
|
|
507
|
+
const results = await this.storage.find(
|
|
508
|
+
COLLECTIONS3.weeklySummaries,
|
|
509
|
+
{ status: "pending" }
|
|
510
|
+
);
|
|
511
|
+
return results.map((r) => JSON.parse(r.data));
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Update summary status.
|
|
515
|
+
*
|
|
516
|
+
* @param summaryId - ID of the summary
|
|
517
|
+
* @param status - New status
|
|
518
|
+
*/
|
|
519
|
+
async updateSummaryStatus(summaryId, status) {
|
|
520
|
+
await this.storage.update(
|
|
521
|
+
COLLECTIONS3.weeklySummaries,
|
|
522
|
+
{ id: summaryId },
|
|
523
|
+
{ status }
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
// Private helper methods
|
|
527
|
+
async getSessions(start, end) {
|
|
528
|
+
const all = await this.storage.find(COLLECTIONS3.sessions);
|
|
529
|
+
return all.filter((s) => s.started_at >= start && s.started_at <= end);
|
|
530
|
+
}
|
|
531
|
+
async getEventCounts(start, end) {
|
|
532
|
+
const all = await this.storage.find(
|
|
533
|
+
COLLECTIONS3.events
|
|
534
|
+
);
|
|
535
|
+
const counts = {};
|
|
536
|
+
for (const event of all) {
|
|
537
|
+
if (event.timestamp >= start && event.timestamp <= end) {
|
|
538
|
+
counts[event.event_type] = (counts[event.event_type] ?? 0) + 1;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return counts;
|
|
542
|
+
}
|
|
543
|
+
async getUniqueScreens(start, end) {
|
|
544
|
+
const all = await this.storage.find(COLLECTIONS3.events);
|
|
545
|
+
const seen = /* @__PURE__ */ new Set();
|
|
546
|
+
for (const event of all) {
|
|
547
|
+
if (event.event_type === "screen_view" && event.screen_name !== null && event.screen_name !== void 0 && event.timestamp >= start && event.timestamp <= end) {
|
|
548
|
+
seen.add(event.screen_name);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return Array.from(seen);
|
|
552
|
+
}
|
|
553
|
+
async getFeedbackStats(start, end) {
|
|
554
|
+
const all = await this.storage.find(COLLECTIONS3.feedbacks);
|
|
555
|
+
const feedbacks = all.filter((f) => f.created_at >= start && f.created_at <= end);
|
|
556
|
+
if (feedbacks.length === 0) {
|
|
557
|
+
return { count: 0, avgLength: 0 };
|
|
558
|
+
}
|
|
559
|
+
const totalLength = feedbacks.reduce((sum, f) => sum + f.content.length, 0);
|
|
560
|
+
return {
|
|
561
|
+
count: feedbacks.length,
|
|
562
|
+
avgLength: Math.round(totalLength / feedbacks.length)
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
async getTaskStats(start, end) {
|
|
566
|
+
const all = await this.storage.find(COLLECTIONS3.tasks);
|
|
567
|
+
const tasks = all.filter((t) => t.created_at >= start && t.created_at <= end);
|
|
568
|
+
const started = tasks.filter((t) => t.started_at !== null).length;
|
|
569
|
+
const completed = tasks.filter((t) => t.is_completed === 1).length;
|
|
570
|
+
return { started, completed };
|
|
571
|
+
}
|
|
572
|
+
async getBugReportStats(start, end) {
|
|
573
|
+
const all = await this.storage.find(COLLECTIONS3.bugReports);
|
|
574
|
+
const bugReports = all.filter((b) => b.timestamp >= start && b.timestamp <= end);
|
|
575
|
+
const errors = bugReports.filter((b) => b.severity === "error").length;
|
|
576
|
+
const crashes = bugReports.filter((b) => b.severity === "crash").length;
|
|
577
|
+
return { errors, crashes };
|
|
578
|
+
}
|
|
579
|
+
calculateQualityFlags(totalSessions, activeMinutes, uniqueScreens, avgTapsPerMinute) {
|
|
580
|
+
return {
|
|
581
|
+
minDwellOk: activeMinutes >= ValidationConstants.minActiveMinutes,
|
|
582
|
+
routeDepthOk: uniqueScreens >= ValidationConstants.minUniqueScreens,
|
|
583
|
+
tapSpam: avgTapsPerMinute > ValidationConstants.maxTapsPerMinute,
|
|
584
|
+
sufficientCoverage: totalSessions >= ValidationConstants.minSessions && uniqueScreens >= ValidationConstants.minUniqueScreens
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
generateIntegrityHash(metrics) {
|
|
588
|
+
const data = JSON.stringify(metrics);
|
|
589
|
+
let hash = 0;
|
|
590
|
+
for (let i = 0; i < data.length; i++) {
|
|
591
|
+
const char = data.charCodeAt(i);
|
|
592
|
+
hash = (hash << 5) - hash + char;
|
|
593
|
+
hash = hash & hash;
|
|
594
|
+
}
|
|
595
|
+
return Math.abs(hash).toString(16).padStart(64, "0");
|
|
596
|
+
}
|
|
597
|
+
calculateEstimatedPayout(metrics) {
|
|
598
|
+
const baseRate = 0.1;
|
|
599
|
+
let payout = metrics.activeMinutes * baseRate;
|
|
600
|
+
if (metrics.qualityFlags.minDwellOk) payout *= 1.1;
|
|
601
|
+
if (metrics.qualityFlags.routeDepthOk) payout *= 1.1;
|
|
602
|
+
if (metrics.qualityFlags.sufficientCoverage) payout *= 1.2;
|
|
603
|
+
if (metrics.qualityFlags.tapSpam) payout *= 0.8;
|
|
604
|
+
if (metrics.feedbackCount > 0) payout += metrics.feedbackCount * 0.5;
|
|
605
|
+
if (metrics.tasksCompleted > 0) payout += metrics.tasksCompleted * 1;
|
|
606
|
+
return Math.round(payout * 100) / 100;
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
// src/Outbox.ts
|
|
611
|
+
import { COLLECTIONS as COLLECTIONS4 } from "@testerpaykit/core";
|
|
612
|
+
import { v4 as uuidv44 } from "uuid";
|
|
613
|
+
var MAX_ATTEMPTS = 7;
|
|
614
|
+
var Outbox = class {
|
|
615
|
+
constructor(storage, apiClient) {
|
|
616
|
+
this.draining = false;
|
|
617
|
+
this.storage = storage;
|
|
618
|
+
this.apiClient = apiClient;
|
|
619
|
+
}
|
|
620
|
+
// -------------------------------------------------------------------------
|
|
621
|
+
// Schema — no-op: schema is the adapter's responsibility
|
|
622
|
+
// -------------------------------------------------------------------------
|
|
623
|
+
async initSchema() {
|
|
624
|
+
}
|
|
625
|
+
// -------------------------------------------------------------------------
|
|
626
|
+
// Crash recovery — reset in_flight rows left over from a killed process
|
|
627
|
+
// -------------------------------------------------------------------------
|
|
628
|
+
async recoverInFlight() {
|
|
629
|
+
await this.storage.update(
|
|
630
|
+
COLLECTIONS4.outbox,
|
|
631
|
+
{ state: "in_flight" },
|
|
632
|
+
{ state: "pending" }
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
// -------------------------------------------------------------------------
|
|
636
|
+
// Enqueue helpers
|
|
637
|
+
// -------------------------------------------------------------------------
|
|
638
|
+
/** Enqueue a bug_report item; returns the local UUID immediately. */
|
|
639
|
+
enqueueSync(type, payload) {
|
|
640
|
+
const localId = uuidv44();
|
|
641
|
+
this._insertRow(localId, type, payload).catch(() => {
|
|
642
|
+
});
|
|
643
|
+
return localId;
|
|
644
|
+
}
|
|
645
|
+
/** Enqueue and wait for the DB insert to complete (for async callers). */
|
|
646
|
+
async enqueue(type, payload) {
|
|
647
|
+
const localId = uuidv44();
|
|
648
|
+
await this._insertRow(localId, type, payload);
|
|
649
|
+
return localId;
|
|
650
|
+
}
|
|
651
|
+
async _insertRow(localId, type, payload) {
|
|
652
|
+
await this.storage.insert(COLLECTIONS4.outbox, {
|
|
653
|
+
local_id: localId,
|
|
654
|
+
type,
|
|
655
|
+
payload: JSON.stringify(payload),
|
|
656
|
+
state: "pending",
|
|
657
|
+
attempts: 0,
|
|
658
|
+
last_error: null,
|
|
659
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
// -------------------------------------------------------------------------
|
|
663
|
+
// Status query
|
|
664
|
+
// -------------------------------------------------------------------------
|
|
665
|
+
/**
|
|
666
|
+
* Map DB state → OutboxItemStatus.
|
|
667
|
+
* Returns null when the item is not found (means it was sent and deleted).
|
|
668
|
+
*/
|
|
669
|
+
async getStatus(localId) {
|
|
670
|
+
const row = await this.storage.findOne(
|
|
671
|
+
COLLECTIONS4.outbox,
|
|
672
|
+
{ local_id: localId }
|
|
673
|
+
);
|
|
674
|
+
if (!row) return null;
|
|
675
|
+
const state = row.state;
|
|
676
|
+
if (state === "pending") return "pending";
|
|
677
|
+
if (state === "in_flight") return "uploading";
|
|
678
|
+
if (state === "failed_permanent") return "failed";
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
// -------------------------------------------------------------------------
|
|
682
|
+
// Drainer — process pending items one by one
|
|
683
|
+
// -------------------------------------------------------------------------
|
|
684
|
+
/**
|
|
685
|
+
* Schedule a drain on the next microtask. Fire-and-forget.
|
|
686
|
+
* Callers (submitBugReport / createSubmission) call this after enqueue.
|
|
687
|
+
*/
|
|
688
|
+
scheduleDrain() {
|
|
689
|
+
Promise.resolve().then(() => this.drain()).catch(() => {
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Process all pending outbox items. Idempotent — safe to call concurrently
|
|
694
|
+
* (guarded by draining flag). No-ops when apiClient is absent.
|
|
695
|
+
*/
|
|
696
|
+
async drain() {
|
|
697
|
+
if (this.draining) return;
|
|
698
|
+
if (!this.apiClient) return;
|
|
699
|
+
this.draining = true;
|
|
700
|
+
try {
|
|
701
|
+
await this._drainLoop();
|
|
702
|
+
} finally {
|
|
703
|
+
this.draining = false;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
async _drainLoop() {
|
|
707
|
+
const rows = await this.storage.find(
|
|
708
|
+
COLLECTIONS4.outbox,
|
|
709
|
+
{ state: "pending" }
|
|
710
|
+
);
|
|
711
|
+
if (rows.length === 0) return;
|
|
712
|
+
let serverIds;
|
|
713
|
+
const hasSubmissions = rows.some((r) => r.type === "submission");
|
|
714
|
+
if (hasSubmissions && this.apiClient?.fetchServerSubmissionIds) {
|
|
715
|
+
serverIds = await this.apiClient.fetchServerSubmissionIds();
|
|
716
|
+
}
|
|
717
|
+
for (const row of rows) {
|
|
718
|
+
await this._processRow(row, serverIds);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
async _processRow(row, serverIds) {
|
|
722
|
+
await this.storage.update(
|
|
723
|
+
COLLECTIONS4.outbox,
|
|
724
|
+
{ local_id: row.local_id },
|
|
725
|
+
{ state: "in_flight" }
|
|
726
|
+
);
|
|
727
|
+
try {
|
|
728
|
+
const payload = JSON.parse(row.payload);
|
|
729
|
+
if (row.type === "bug_report") {
|
|
730
|
+
await this._uploadBugReport(row.local_id, payload);
|
|
731
|
+
} else if (row.type === "submission") {
|
|
732
|
+
await this._uploadSubmission(row.local_id, payload, serverIds);
|
|
733
|
+
}
|
|
734
|
+
await this.storage.delete(
|
|
735
|
+
COLLECTIONS4.outbox,
|
|
736
|
+
{ local_id: row.local_id }
|
|
737
|
+
);
|
|
738
|
+
} catch (err) {
|
|
739
|
+
const statusCode = this._extractStatusCode(err);
|
|
740
|
+
if (statusCode !== null && statusCode >= 400 && statusCode < 500) {
|
|
741
|
+
await this.storage.update(
|
|
742
|
+
COLLECTIONS4.outbox,
|
|
743
|
+
{ local_id: row.local_id },
|
|
744
|
+
{
|
|
745
|
+
state: "failed_permanent",
|
|
746
|
+
attempts: (row.attempts ?? 0) + 1,
|
|
747
|
+
last_error: String(err)
|
|
748
|
+
}
|
|
749
|
+
);
|
|
750
|
+
} else {
|
|
751
|
+
const nextAttempts = (row.attempts ?? 0) + 1;
|
|
752
|
+
await this.storage.update(
|
|
753
|
+
COLLECTIONS4.outbox,
|
|
754
|
+
{ local_id: row.local_id },
|
|
755
|
+
{
|
|
756
|
+
state: nextAttempts >= MAX_ATTEMPTS ? "failed_permanent" : "pending",
|
|
757
|
+
attempts: nextAttempts,
|
|
758
|
+
last_error: String(err)
|
|
759
|
+
}
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
async _uploadBugReport(localId, payload) {
|
|
765
|
+
if (!this.apiClient?.submitBugReport) {
|
|
766
|
+
throw new Error("submitBugReport not available on apiClient");
|
|
767
|
+
}
|
|
768
|
+
await this.apiClient.submitBugReport(payload, { idempotencyKey: localId });
|
|
769
|
+
}
|
|
770
|
+
async _uploadSubmission(localId, payload, serverIds) {
|
|
771
|
+
if (serverIds?.has(localId)) return;
|
|
772
|
+
await this.apiClient.submitSummary(payload);
|
|
773
|
+
}
|
|
774
|
+
// -------------------------------------------------------------------------
|
|
775
|
+
// Connectivity hook (browser only)
|
|
776
|
+
// -------------------------------------------------------------------------
|
|
777
|
+
/** Register a window.online listener that triggers a drain. */
|
|
778
|
+
registerOnlineListener() {
|
|
779
|
+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
780
|
+
window.addEventListener("online", () => {
|
|
781
|
+
this.drain().catch(() => {
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
// -------------------------------------------------------------------------
|
|
787
|
+
// Helpers
|
|
788
|
+
// -------------------------------------------------------------------------
|
|
789
|
+
_extractStatusCode(err) {
|
|
790
|
+
if (err && typeof err === "object") {
|
|
791
|
+
const axiosErr = err;
|
|
792
|
+
if (axiosErr.response?.status != null) return axiosErr.response.status;
|
|
793
|
+
const genericErr = err;
|
|
794
|
+
if (genericErr.statusCode != null) return genericErr.statusCode;
|
|
795
|
+
}
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
// src/DeviceIdManager.ts
|
|
801
|
+
import { COLLECTIONS as COLLECTIONS5 } from "@testerpaykit/core";
|
|
802
|
+
import { v4 as uuidv45 } from "uuid";
|
|
803
|
+
var DEVICE_ID_KEY = "device_id";
|
|
804
|
+
var DEVICE_ID_CREATED_AT_KEY = "device_id_created_at";
|
|
805
|
+
var THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
806
|
+
var DeviceIdManager = class {
|
|
807
|
+
constructor(storage) {
|
|
808
|
+
this.storage = storage;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Ensure the sdk_meta table exists. Schema is the adapter's responsibility;
|
|
812
|
+
* this is now a no-op kept for backwards-compatible call-sites.
|
|
813
|
+
*/
|
|
814
|
+
async initSchema() {
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Return the stored device id, creating or rotating it as needed.
|
|
818
|
+
*
|
|
819
|
+
* Rotation policy: if the id is older than 30 days, a new one is generated.
|
|
820
|
+
*/
|
|
821
|
+
async getOrCreate() {
|
|
822
|
+
const rows = await this.storage.find(
|
|
823
|
+
COLLECTIONS5.sdkMeta,
|
|
824
|
+
{ key: { in: [DEVICE_ID_KEY, DEVICE_ID_CREATED_AT_KEY] } }
|
|
825
|
+
);
|
|
826
|
+
const byKey = {};
|
|
827
|
+
for (const r of rows) {
|
|
828
|
+
byKey[r.key] = r.value;
|
|
829
|
+
}
|
|
830
|
+
const existingId = byKey[DEVICE_ID_KEY];
|
|
831
|
+
const createdAt = byKey[DEVICE_ID_CREATED_AT_KEY] ? parseInt(byKey[DEVICE_ID_CREATED_AT_KEY], 10) : null;
|
|
832
|
+
const now = Date.now();
|
|
833
|
+
const expired = createdAt === null || now - createdAt > THIRTY_DAYS_MS;
|
|
834
|
+
if (existingId && !expired) {
|
|
835
|
+
return existingId;
|
|
836
|
+
}
|
|
837
|
+
const newId = uuidv45();
|
|
838
|
+
await this._upsert(DEVICE_ID_KEY, newId);
|
|
839
|
+
await this._upsert(DEVICE_ID_CREATED_AT_KEY, String(now));
|
|
840
|
+
return newId;
|
|
841
|
+
}
|
|
842
|
+
async _upsert(key, value) {
|
|
843
|
+
const existing = await this.storage.findOne(
|
|
844
|
+
COLLECTIONS5.sdkMeta,
|
|
845
|
+
{ key }
|
|
846
|
+
);
|
|
847
|
+
if (existing) {
|
|
848
|
+
await this.storage.update(COLLECTIONS5.sdkMeta, { key }, { value });
|
|
849
|
+
} else {
|
|
850
|
+
await this.storage.insert(COLLECTIONS5.sdkMeta, { key, value });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
// src/TesterPayKit.ts
|
|
856
|
+
import { v4 as uuidv46 } from "uuid";
|
|
857
|
+
var LOCAL_PATTERNS = [
|
|
858
|
+
"localhost",
|
|
859
|
+
/^127\./,
|
|
860
|
+
/^10\./,
|
|
861
|
+
/^192\.168\./,
|
|
862
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
863
|
+
/\.local$/,
|
|
864
|
+
/\.lan$/
|
|
865
|
+
];
|
|
866
|
+
function deriveEnvironment(baseUrl) {
|
|
867
|
+
let host;
|
|
868
|
+
try {
|
|
869
|
+
host = new URL(baseUrl).hostname.toLowerCase();
|
|
870
|
+
} catch {
|
|
871
|
+
return "prod";
|
|
872
|
+
}
|
|
873
|
+
for (const p of LOCAL_PATTERNS) {
|
|
874
|
+
if (typeof p === "string" ? host === p : p.test(host)) {
|
|
875
|
+
return "local";
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return "prod";
|
|
879
|
+
}
|
|
880
|
+
var LAST_SUBMISSION_AT_KEY = "last_submission_at";
|
|
881
|
+
var SUBMISSION_PERIOD_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
882
|
+
var _TesterPayKit = class _TesterPayKit {
|
|
883
|
+
constructor(config) {
|
|
884
|
+
this.initialized = false;
|
|
885
|
+
// ── Resolved tester id (after anonymous login) ──────────────────────────────
|
|
886
|
+
// Populated by init() after a successful loginAnonymous. The server returns the
|
|
887
|
+
// real UUID for the tester row; submissions must use this, NOT the invite code.
|
|
888
|
+
this._resolvedTesterId = null;
|
|
889
|
+
// ── Auth token ──────────────────────────────────────────────────────────────
|
|
890
|
+
this._authToken = null;
|
|
891
|
+
// ── Server-side session sync ────────────────────────────────────────────────
|
|
892
|
+
// The local SessionManager only persists sessions in storage. To make a
|
|
893
|
+
// session appear in the coordinator dashboard we also create a SERVER session
|
|
894
|
+
// (once authed) and mirror screen-view events to it. Best-effort: never blocks
|
|
895
|
+
// or breaks the host app. Events that arrive before the server session exists
|
|
896
|
+
// are buffered, then flushed.
|
|
897
|
+
this._serverSessionId = null;
|
|
898
|
+
this._pendingSessionEvents = [];
|
|
899
|
+
this._logBuffer = [];
|
|
900
|
+
this._serverSessionInFlight = null;
|
|
901
|
+
this.config = {
|
|
902
|
+
autoStartSession: true,
|
|
903
|
+
debugMode: false,
|
|
904
|
+
weeklySubmissionDay: 7,
|
|
905
|
+
earningsBaseAmount: 1,
|
|
906
|
+
earningsPerMinuteRate: 0.1,
|
|
907
|
+
earningsCurrency: "EUR",
|
|
908
|
+
...config
|
|
909
|
+
};
|
|
910
|
+
this.environment = config.apiBaseUrl ? deriveEnvironment(config.apiBaseUrl) : "prod";
|
|
911
|
+
this._resolvedDeviceId = config.deviceId ?? "";
|
|
912
|
+
this.deviceIdManager = new DeviceIdManager(config.storage);
|
|
913
|
+
this.sessionManager = new SessionManager(config.storage);
|
|
914
|
+
this.eventTracker = new EventTracker(config.storage, {
|
|
915
|
+
captureScreenViews: config.captureScreenViews ?? true,
|
|
916
|
+
captureTapEvents: config.captureTapEvents ?? true,
|
|
917
|
+
captureErrors: config.captureErrors ?? true,
|
|
918
|
+
captureCrashes: config.captureCrashes ?? true
|
|
919
|
+
});
|
|
920
|
+
const aggregatorConfig = {
|
|
921
|
+
projectId: config.projectId,
|
|
922
|
+
testerId: config.testerId,
|
|
923
|
+
deviceId: config.deviceId ?? "",
|
|
924
|
+
deviceMetadata: config.deviceMetadata,
|
|
925
|
+
appMetadata: config.appMetadata
|
|
926
|
+
};
|
|
927
|
+
this.metricsAggregator = new MetricsAggregator(config.storage, aggregatorConfig);
|
|
928
|
+
this.outbox = new Outbox(config.storage, config.apiClient);
|
|
929
|
+
this.anonymousAuthReady = new Promise((resolve) => {
|
|
930
|
+
this._resolveAnonymousAuth = resolve;
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
// ── Alias (contract: lifecycle.readiness.onTokenReady) ─────────────────────
|
|
934
|
+
get onTokenReady() {
|
|
935
|
+
return this.anonymousAuthReady;
|
|
936
|
+
}
|
|
937
|
+
// ── Auth state (contract: lifecycle.readiness.isAuthenticated) ─────────────
|
|
938
|
+
get isAuthenticated() {
|
|
939
|
+
return this._authToken !== null;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Initialize the SDK.
|
|
943
|
+
*
|
|
944
|
+
* Must be called before using any other SDK methods.
|
|
945
|
+
* Initializes storage and optionally starts a session.
|
|
946
|
+
*/
|
|
947
|
+
async init() {
|
|
948
|
+
if (this.initialized) {
|
|
949
|
+
this.log("SDK already initialized");
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
await this.config.storage.init();
|
|
953
|
+
await this.deviceIdManager.initSchema();
|
|
954
|
+
await this.outbox.initSchema();
|
|
955
|
+
await this.outbox.recoverInFlight();
|
|
956
|
+
if (!this.config.deviceId) {
|
|
957
|
+
this._resolvedDeviceId = await this.deviceIdManager.getOrCreate();
|
|
958
|
+
this.metricsAggregator.config = {
|
|
959
|
+
...this.metricsAggregator.config,
|
|
960
|
+
deviceId: this._resolvedDeviceId
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
this.outbox.registerOnlineListener();
|
|
964
|
+
if (this.config.autoStartSession) {
|
|
965
|
+
const sessionId = await this.sessionManager.startSession();
|
|
966
|
+
this.eventTracker.setSessionId(sessionId);
|
|
967
|
+
this.log(`Session auto-started: ${sessionId}`);
|
|
968
|
+
}
|
|
969
|
+
this.initialized = true;
|
|
970
|
+
this.log("SDK initialized successfully");
|
|
971
|
+
const code = this.config.testerId;
|
|
972
|
+
const apiClient = this.config.apiClient;
|
|
973
|
+
if (code && apiClient?.loginAnonymous) {
|
|
974
|
+
void (async () => {
|
|
975
|
+
try {
|
|
976
|
+
const res = await apiClient.loginAnonymous(this.config.projectId, code);
|
|
977
|
+
if (res?.accessToken) {
|
|
978
|
+
this.setAuthToken(res.accessToken);
|
|
979
|
+
if (res.testerId) {
|
|
980
|
+
this._resolvedTesterId = res.testerId;
|
|
981
|
+
this.metricsAggregator.config = {
|
|
982
|
+
...this.metricsAggregator.config,
|
|
983
|
+
testerId: res.testerId
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
this.log("anonymous tester token acquired");
|
|
987
|
+
void this._ensureServerSession();
|
|
988
|
+
void this.runDurabilitySweep().catch(() => void 0);
|
|
989
|
+
}
|
|
990
|
+
} catch (e) {
|
|
991
|
+
this.log(`anonymous token acquisition failed: ${String(e)}`);
|
|
992
|
+
} finally {
|
|
993
|
+
this._resolveAnonymousAuth();
|
|
994
|
+
}
|
|
995
|
+
})();
|
|
996
|
+
} else {
|
|
997
|
+
this._resolveAnonymousAuth();
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Provide an auth token to the SDK (e.g. after anonymous login).
|
|
1002
|
+
* Makes isAuthenticated return true and enables runDurabilitySweep.
|
|
1003
|
+
*/
|
|
1004
|
+
setAuthToken(token) {
|
|
1005
|
+
this._authToken = token;
|
|
1006
|
+
this.config.apiClient?.setAccessToken(token);
|
|
1007
|
+
}
|
|
1008
|
+
// ── Registered-tester auth (for the tester-launcher widget) ──────────────────
|
|
1009
|
+
/**
|
|
1010
|
+
* Request a passwordless magic-link sign-in for a registered tester.
|
|
1011
|
+
* TesterPayKit tester auth is magic-link (no passwords): the server emails a
|
|
1012
|
+
* single-use link that returns to `returnUrl` (a first-party *.moinsen.dev
|
|
1013
|
+
* page) carrying a token, which the host consumes via consumeTesterMagicLink.
|
|
1014
|
+
* `country` (DE/AT/CH) lets an unknown email self-signup. Returns true if the
|
|
1015
|
+
* request was accepted. Never throws.
|
|
1016
|
+
*/
|
|
1017
|
+
async requestTesterMagicLink(email, returnUrl, country) {
|
|
1018
|
+
const api = this.config.apiClient;
|
|
1019
|
+
if (!api?.requestMagicLink) return false;
|
|
1020
|
+
try {
|
|
1021
|
+
await api.requestMagicLink({ email, returnUrl, country });
|
|
1022
|
+
return true;
|
|
1023
|
+
} catch (e) {
|
|
1024
|
+
this.log(`magic-link request failed: ${String(e)}`);
|
|
1025
|
+
return false;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Consume a magic-link token (from the returnUrl the tester landed on). On
|
|
1030
|
+
* success the real tester token supersedes the anonymous one, the tester is
|
|
1031
|
+
* enrolled in this project, and {accessToken, refreshToken} is returned so
|
|
1032
|
+
* the host can persist the session. Returns null on failure. Never throws.
|
|
1033
|
+
*/
|
|
1034
|
+
async consumeTesterMagicLink(token) {
|
|
1035
|
+
const api = this.config.apiClient;
|
|
1036
|
+
if (!api?.consumeMagicLink) return null;
|
|
1037
|
+
try {
|
|
1038
|
+
const res = await api.consumeMagicLink(token);
|
|
1039
|
+
if (res?.accessToken) {
|
|
1040
|
+
this.setAuthToken(res.accessToken);
|
|
1041
|
+
await this.enrollSelf();
|
|
1042
|
+
return {
|
|
1043
|
+
accessToken: res.accessToken,
|
|
1044
|
+
refreshToken: res.refreshToken ?? void 0
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
return null;
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
this.log(`magic-link consume failed: ${String(e)}`);
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Enroll the current tester into THIS project (idempotent, best-effort).
|
|
1055
|
+
* Used right after loginTester so the tester is a project participant.
|
|
1056
|
+
* Returns true if enrolled (or already enrolled), false on hard failure.
|
|
1057
|
+
*/
|
|
1058
|
+
async enrollSelf() {
|
|
1059
|
+
const api = this.config.apiClient;
|
|
1060
|
+
if (!api?.enrollInProject) return false;
|
|
1061
|
+
try {
|
|
1062
|
+
await api.enrollInProject(this.config.projectId);
|
|
1063
|
+
return true;
|
|
1064
|
+
} catch (e) {
|
|
1065
|
+
const msg = String(e);
|
|
1066
|
+
if (msg.includes("409") || msg.toLowerCase().includes("already")) {
|
|
1067
|
+
return true;
|
|
1068
|
+
}
|
|
1069
|
+
this.log(`enrollSelf failed: ${msg}`);
|
|
1070
|
+
return false;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
// ── Server-side session sync (best-effort) ───────────────────────────────────
|
|
1074
|
+
/** device_info payload for the server session, from configured metadata. */
|
|
1075
|
+
_sessionDeviceInfo() {
|
|
1076
|
+
const d = this.config.deviceMetadata;
|
|
1077
|
+
return {
|
|
1078
|
+
os: d.os,
|
|
1079
|
+
os_version: d.osVersion,
|
|
1080
|
+
device_model: d.deviceModel,
|
|
1081
|
+
...d.timezoneOffset != null && { timezone_offset: d.timezoneOffset },
|
|
1082
|
+
// The local device id lives in the FK-free jsonb (not the device_id
|
|
1083
|
+
// column, which references tester_devices) so grouping context is kept
|
|
1084
|
+
// without a registration round-trip.
|
|
1085
|
+
...this._resolvedDeviceId && { device_id: this._resolvedDeviceId }
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Lazily create the SERVER tester session once an anonymous token is in
|
|
1090
|
+
* hand, then flush any buffered events. This is what makes the session
|
|
1091
|
+
* show up in the coordinator dashboard. Best-effort: never throws.
|
|
1092
|
+
*/
|
|
1093
|
+
_ensureServerSession() {
|
|
1094
|
+
if (this._serverSessionId) return Promise.resolve();
|
|
1095
|
+
this._serverSessionInFlight ?? (this._serverSessionInFlight = this._createServerSession().finally(() => {
|
|
1096
|
+
this._serverSessionInFlight = null;
|
|
1097
|
+
}));
|
|
1098
|
+
return this._serverSessionInFlight;
|
|
1099
|
+
}
|
|
1100
|
+
async _createServerSession() {
|
|
1101
|
+
if (!this._authToken) return;
|
|
1102
|
+
const api = this.config.apiClient;
|
|
1103
|
+
if (!api?.startTesterSession) return;
|
|
1104
|
+
try {
|
|
1105
|
+
const res = await api.startTesterSession({
|
|
1106
|
+
projectId: this.config.projectId,
|
|
1107
|
+
deviceInfo: this._sessionDeviceInfo(),
|
|
1108
|
+
appVersion: this.config.appMetadata.version,
|
|
1109
|
+
sdkVersion: this.config.deviceMetadata.sdkVersion
|
|
1110
|
+
// NOTE: deliberately NOT sending device_id. On the server it's an FK
|
|
1111
|
+
// into tester_devices, and the TS SDK has no device-registration flow
|
|
1112
|
+
// yet, so an unregistered local UUID would 500 the session-create.
|
|
1113
|
+
// The deviceId still rides along in deviceInfo for grouping context.
|
|
1114
|
+
});
|
|
1115
|
+
if (res?.sdkUpdate && !isProductionBuild()) {
|
|
1116
|
+
const u = res.sdkUpdate;
|
|
1117
|
+
console.warn(
|
|
1118
|
+
`[testerpaykit] ${u.package} ${u.current} is outdated \u2014 ${u.latest} is out. Update: ${u.command}`
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
if (res?.id) {
|
|
1122
|
+
this._serverSessionId = res.id;
|
|
1123
|
+
this.log(`server session created: ${res.id}`);
|
|
1124
|
+
await this._flushServerSessionEvents();
|
|
1125
|
+
}
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
this.log(`server session create failed: ${String(e)}`);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
/** Flush buffered session events to the server (best-effort, re-queues on fail). */
|
|
1131
|
+
async _flushServerSessionEvents() {
|
|
1132
|
+
const api = this.config.apiClient;
|
|
1133
|
+
if (!this._serverSessionId || !api?.addSessionEvents) return;
|
|
1134
|
+
if (this._pendingSessionEvents.length === 0) return;
|
|
1135
|
+
const batch = this._pendingSessionEvents.splice(0);
|
|
1136
|
+
try {
|
|
1137
|
+
await api.addSessionEvents(this._serverSessionId, batch);
|
|
1138
|
+
} catch (e) {
|
|
1139
|
+
this._pendingSessionEvents.unshift(...batch);
|
|
1140
|
+
this.log(`session events flush failed: ${String(e)}`);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
/** End the server session (best-effort). Clears the id so it won't re-end. */
|
|
1144
|
+
async _endServerSession(endReason) {
|
|
1145
|
+
const api = this.config.apiClient;
|
|
1146
|
+
const id = this._serverSessionId;
|
|
1147
|
+
if (!id || !api?.endTesterSession) return;
|
|
1148
|
+
this._serverSessionId = null;
|
|
1149
|
+
try {
|
|
1150
|
+
await this._flushServerSessionEvents();
|
|
1151
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
1152
|
+
"completed",
|
|
1153
|
+
"lifecycle_flush",
|
|
1154
|
+
"timeout",
|
|
1155
|
+
"abandoned"
|
|
1156
|
+
]);
|
|
1157
|
+
await api.endTesterSession({
|
|
1158
|
+
sessionId: id,
|
|
1159
|
+
endReason: allowed.has(endReason) ? endReason : "completed"
|
|
1160
|
+
});
|
|
1161
|
+
this.log(`server session ended: ${id}`);
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
this.log(`server session end failed: ${String(e)}`);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
// ── payout_durability — contract: invariants.payout_durability ──────────────
|
|
1167
|
+
/**
|
|
1168
|
+
* Run a durability sweep to ensure testing work is persisted and uploaded.
|
|
1169
|
+
*
|
|
1170
|
+
* No-op when no auth token is held. Otherwise, best-effort (each step is
|
|
1171
|
+
* individually wrapped so one failure does not abort the rest):
|
|
1172
|
+
*
|
|
1173
|
+
* 1. Reconcile open sessions: find sessions in storage without an end_time
|
|
1174
|
+
* and close them with endReason='abandoned' so their duration counts.
|
|
1175
|
+
* 2. Auto-create a period submission when overdue: tracks the last submission
|
|
1176
|
+
* timestamp in sdk_meta. "Due" = more than 7 days since last enqueue.
|
|
1177
|
+
* (Heuristic — uses wall-clock elapsed time, not calendar periods.)
|
|
1178
|
+
* 3. Drain the outbox. The Outbox drain already skips server-known submission
|
|
1179
|
+
* ids (fetchServerSubmissionIds guard) so no double-pay occurs.
|
|
1180
|
+
*
|
|
1181
|
+
* Contract: api.submissions.runDurabilitySweep
|
|
1182
|
+
*/
|
|
1183
|
+
async runDurabilitySweep() {
|
|
1184
|
+
if (!this._authToken) {
|
|
1185
|
+
this.log("runDurabilitySweep: no auth token \u2014 skipping");
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
try {
|
|
1189
|
+
await this._reconcileOpenSessions();
|
|
1190
|
+
} catch (e) {
|
|
1191
|
+
this.log(`runDurabilitySweep step 1 failed: ${e}`);
|
|
1192
|
+
}
|
|
1193
|
+
try {
|
|
1194
|
+
await this._autoCreateSubmissionIfDue();
|
|
1195
|
+
} catch (e) {
|
|
1196
|
+
this.log(`runDurabilitySweep step 2 failed: ${e}`);
|
|
1197
|
+
}
|
|
1198
|
+
try {
|
|
1199
|
+
await this.outbox.drain();
|
|
1200
|
+
} catch (e) {
|
|
1201
|
+
this.log(`runDurabilitySweep step 3 failed: ${e}`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
async _reconcileOpenSessions() {
|
|
1205
|
+
const currentId = this.sessionManager.getCurrentSessionId() ?? "";
|
|
1206
|
+
await this.config.storage.update(
|
|
1207
|
+
COLLECTIONS6.sessions,
|
|
1208
|
+
{ ended_at: null, id: { ne: currentId } },
|
|
1209
|
+
{ ended_at: Date.now(), end_reason: "abandoned" }
|
|
1210
|
+
);
|
|
1211
|
+
this.log("runDurabilitySweep: open sessions reconciled");
|
|
1212
|
+
}
|
|
1213
|
+
async _autoCreateSubmissionIfDue() {
|
|
1214
|
+
const row = await this.config.storage.findOne(
|
|
1215
|
+
COLLECTIONS6.sdkMeta,
|
|
1216
|
+
{ key: LAST_SUBMISSION_AT_KEY }
|
|
1217
|
+
);
|
|
1218
|
+
const lastAt = row ? parseInt(row.value, 10) : null;
|
|
1219
|
+
const now = Date.now();
|
|
1220
|
+
if (lastAt === null) {
|
|
1221
|
+
await this.config.storage.insert(COLLECTIONS6.sdkMeta, {
|
|
1222
|
+
key: LAST_SUBMISSION_AT_KEY,
|
|
1223
|
+
value: String(now)
|
|
1224
|
+
});
|
|
1225
|
+
this.log("runDurabilitySweep: first run \u2014 period clock started, no submission yet");
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (now - lastAt < SUBMISSION_PERIOD_MS) {
|
|
1229
|
+
this.log("runDurabilitySweep: submission not due yet");
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
await this.config.storage.update(
|
|
1233
|
+
COLLECTIONS6.sdkMeta,
|
|
1234
|
+
{ key: LAST_SUBMISSION_AT_KEY },
|
|
1235
|
+
{ value: String(now) }
|
|
1236
|
+
);
|
|
1237
|
+
await this.createSubmission();
|
|
1238
|
+
this.log("runDurabilitySweep: auto-created period submission");
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Start a new testing session.
|
|
1242
|
+
*
|
|
1243
|
+
* @returns Promise resolving to the new session ID
|
|
1244
|
+
*/
|
|
1245
|
+
async startSession() {
|
|
1246
|
+
this.ensureInitialized();
|
|
1247
|
+
const sessionId = await this.sessionManager.startSession();
|
|
1248
|
+
this.eventTracker.setSessionId(sessionId);
|
|
1249
|
+
this.log(`Session started: ${sessionId}`);
|
|
1250
|
+
void this._ensureServerSession();
|
|
1251
|
+
return sessionId;
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* End the current testing session.
|
|
1255
|
+
*
|
|
1256
|
+
* @param endReason - Reason for ending (default: 'completed')
|
|
1257
|
+
*/
|
|
1258
|
+
async endSession(endReason = "completed") {
|
|
1259
|
+
this.ensureInitialized();
|
|
1260
|
+
await this.sessionManager.endSession(endReason);
|
|
1261
|
+
this.log("Session ended");
|
|
1262
|
+
await this._endServerSession(endReason);
|
|
1263
|
+
}
|
|
1264
|
+
// ── App-lifecycle hooks (contract: lifecycle.app_lifecycle_hooks) ─────────
|
|
1265
|
+
/**
|
|
1266
|
+
* Call when the app moves to the foreground / resumes.
|
|
1267
|
+
*
|
|
1268
|
+
* Contract behaviour:
|
|
1269
|
+
* - If NO active session exists → start a new one.
|
|
1270
|
+
* - If a session IS active → resume the foreground clock only.
|
|
1271
|
+
* MUST NOT end and restart the active session.
|
|
1272
|
+
*/
|
|
1273
|
+
async onAppResumed() {
|
|
1274
|
+
this.ensureInitialized();
|
|
1275
|
+
if (!this.sessionManager.hasActiveSession()) {
|
|
1276
|
+
const sessionId = await this.sessionManager.startSession();
|
|
1277
|
+
this.eventTracker.setSessionId(sessionId);
|
|
1278
|
+
this.log("onAppResumed: new session started");
|
|
1279
|
+
void this._ensureServerSession();
|
|
1280
|
+
} else {
|
|
1281
|
+
this.sessionManager.resumeForegroundClock();
|
|
1282
|
+
this.log("onAppResumed: foreground clock resumed");
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Call when the app moves to the background / is paused.
|
|
1287
|
+
*
|
|
1288
|
+
* Contract behaviour: freezes the foreground clock.
|
|
1289
|
+
* MUST NOT end the session.
|
|
1290
|
+
*/
|
|
1291
|
+
onAppPaused() {
|
|
1292
|
+
this.ensureInitialized();
|
|
1293
|
+
this.sessionManager.pauseForegroundClock();
|
|
1294
|
+
this.log("onAppPaused: foreground clock paused");
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Call when the app becomes inactive (e.g. phone call overlay, app-switcher).
|
|
1298
|
+
*
|
|
1299
|
+
* Contract behaviour: no-op for the session (may log only).
|
|
1300
|
+
*/
|
|
1301
|
+
onAppInactive() {
|
|
1302
|
+
this.ensureInitialized();
|
|
1303
|
+
this.log("onAppInactive: no-op");
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Call when the app process is about to be detached / destroyed.
|
|
1307
|
+
*
|
|
1308
|
+
* Contract behaviour: ends the active session with end_reason='lifecycle_flush'.
|
|
1309
|
+
*/
|
|
1310
|
+
async onAppDetached() {
|
|
1311
|
+
this.ensureInitialized();
|
|
1312
|
+
if (this.sessionManager.hasActiveSession()) {
|
|
1313
|
+
await this.sessionManager.endSession("lifecycle_flush");
|
|
1314
|
+
this.log("onAppDetached: session flushed");
|
|
1315
|
+
}
|
|
1316
|
+
await this._endServerSession("lifecycle_flush");
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Track a screen view event.
|
|
1320
|
+
*
|
|
1321
|
+
* @param screenName - Name or route of the screen
|
|
1322
|
+
* @param metadata - Optional additional metadata
|
|
1323
|
+
*/
|
|
1324
|
+
async trackScreenView(screenName, metadata) {
|
|
1325
|
+
this.ensureInitialized();
|
|
1326
|
+
await this.eventTracker.trackScreenView(screenName, metadata);
|
|
1327
|
+
await this.sessionManager.incrementScreenViews();
|
|
1328
|
+
this.log(`Screen view tracked: ${screenName}`);
|
|
1329
|
+
this._pendingSessionEvents.push({
|
|
1330
|
+
eventType: "screen_view",
|
|
1331
|
+
screenRoute: screenName,
|
|
1332
|
+
...metadata && { eventData: metadata },
|
|
1333
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1334
|
+
});
|
|
1335
|
+
if (this._serverSessionId) {
|
|
1336
|
+
void this._flushServerSessionEvents();
|
|
1337
|
+
} else {
|
|
1338
|
+
void this._ensureServerSession();
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Track a tap/click event.
|
|
1343
|
+
*
|
|
1344
|
+
* @param screenName - Name of the screen where the tap occurred
|
|
1345
|
+
* @param elementId - Optional ID of the tapped element
|
|
1346
|
+
*/
|
|
1347
|
+
async trackTap(screenName, elementId) {
|
|
1348
|
+
this.ensureInitialized();
|
|
1349
|
+
await this.eventTracker.trackTap(screenName, elementId);
|
|
1350
|
+
await this.sessionManager.incrementTaps();
|
|
1351
|
+
this.log(`Tap tracked: ${screenName}${elementId ? ` (${elementId})` : ""}`);
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Track a custom event.
|
|
1355
|
+
*
|
|
1356
|
+
* @param eventType - Type of the event
|
|
1357
|
+
* @param metadata - Optional event metadata
|
|
1358
|
+
*/
|
|
1359
|
+
async trackEvent(eventType, metadata) {
|
|
1360
|
+
this.ensureInitialized();
|
|
1361
|
+
await this.eventTracker.trackEvent(eventType, metadata);
|
|
1362
|
+
this.log(`Event tracked: ${eventType}`);
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Track an error.
|
|
1366
|
+
*
|
|
1367
|
+
* @param message - Error message
|
|
1368
|
+
* @param stack - Optional stack trace
|
|
1369
|
+
*/
|
|
1370
|
+
async trackError(message, stack) {
|
|
1371
|
+
this.ensureInitialized();
|
|
1372
|
+
await this.eventTracker.trackError(message, stack);
|
|
1373
|
+
this.log(`Error tracked: ${message}`);
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Track a crash.
|
|
1377
|
+
*
|
|
1378
|
+
* @param message - Crash message
|
|
1379
|
+
* @param stack - Stack trace
|
|
1380
|
+
*/
|
|
1381
|
+
async trackCrash(message, stack) {
|
|
1382
|
+
this.ensureInitialized();
|
|
1383
|
+
await this.eventTracker.trackCrash(message, stack);
|
|
1384
|
+
this.log(`Crash tracked: ${message}`);
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Track feedback submission.
|
|
1388
|
+
*
|
|
1389
|
+
* @param content - Feedback content
|
|
1390
|
+
* @param rating - Optional numeric rating
|
|
1391
|
+
*/
|
|
1392
|
+
async trackFeedback(content, rating) {
|
|
1393
|
+
this.ensureInitialized();
|
|
1394
|
+
await this.eventTracker.trackFeedback(content, rating);
|
|
1395
|
+
this.log(`Feedback tracked: ${content.length} characters`);
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Track task start.
|
|
1399
|
+
*
|
|
1400
|
+
* @param taskId - Unique task identifier
|
|
1401
|
+
* @param title - Task title
|
|
1402
|
+
*/
|
|
1403
|
+
async trackTaskStart(taskId, title) {
|
|
1404
|
+
this.ensureInitialized();
|
|
1405
|
+
await this.eventTracker.trackTaskStart(taskId, title);
|
|
1406
|
+
this.log(`Task started: ${title}`);
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Track task completion.
|
|
1410
|
+
*
|
|
1411
|
+
* @param taskId - Unique task identifier
|
|
1412
|
+
*/
|
|
1413
|
+
async trackTaskComplete(taskId) {
|
|
1414
|
+
this.ensureInitialized();
|
|
1415
|
+
await this.eventTracker.trackTaskComplete(taskId);
|
|
1416
|
+
this.log(`Task completed: ${taskId}`);
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Enqueue a bug report to the local outbox and return a local UUID
|
|
1420
|
+
* immediately. The upload happens in the background — this method
|
|
1421
|
+
* never awaits the network.
|
|
1422
|
+
*
|
|
1423
|
+
* Builds the full payload required by POST /v1/testers/me/bug-reports:
|
|
1424
|
+
* sessionId, type, severity, category, title, description,
|
|
1425
|
+
* currentScreen, deviceSnapshot, appSnapshot, projectId.
|
|
1426
|
+
* testerId is intentionally omitted — the endpoint is tester-namespaced
|
|
1427
|
+
* and derives the tester from the JWT.
|
|
1428
|
+
*
|
|
1429
|
+
* Contract: api.submissions.submitBugReport / invariants.local_first_outbox
|
|
1430
|
+
*
|
|
1431
|
+
* @param input - Structured bug report input
|
|
1432
|
+
* @returns A local UUID that can be passed to getSubmissionStatus()
|
|
1433
|
+
*/
|
|
1434
|
+
submitBugReport(input) {
|
|
1435
|
+
this.ensureInitialized();
|
|
1436
|
+
const sessionId = this.sessionManager.getCurrentSessionId() ?? uuidv46();
|
|
1437
|
+
const rawType = input.type ?? "bug";
|
|
1438
|
+
const type = BUG_REPORT_TYPES.includes(rawType) ? rawType : (() => {
|
|
1439
|
+
this.log(`[WARN] submitBugReport: invalid type "${rawType}", coercing to "bug"`);
|
|
1440
|
+
return "bug";
|
|
1441
|
+
})();
|
|
1442
|
+
const rawSeverity = input.severity ?? "medium";
|
|
1443
|
+
const severity = BUG_REPORT_SEVERITIES.includes(rawSeverity) ? rawSeverity : (() => {
|
|
1444
|
+
this.log(`[WARN] submitBugReport: invalid severity "${rawSeverity}", coercing to "medium"`);
|
|
1445
|
+
return "medium";
|
|
1446
|
+
})();
|
|
1447
|
+
const rawCategory = input.category ?? "functional";
|
|
1448
|
+
const category = BUG_REPORT_CATEGORIES.includes(rawCategory) ? rawCategory : (() => {
|
|
1449
|
+
this.log(`[WARN] submitBugReport: invalid category "${rawCategory}", coercing to "functional"`);
|
|
1450
|
+
return "functional";
|
|
1451
|
+
})();
|
|
1452
|
+
const payload = {
|
|
1453
|
+
projectId: this.config.projectId,
|
|
1454
|
+
sessionId,
|
|
1455
|
+
type,
|
|
1456
|
+
severity,
|
|
1457
|
+
category,
|
|
1458
|
+
title: input.title,
|
|
1459
|
+
description: input.description,
|
|
1460
|
+
currentScreen: input.currentScreen ?? "unknown",
|
|
1461
|
+
deviceSnapshot: {
|
|
1462
|
+
os: this.config.deviceMetadata.os,
|
|
1463
|
+
osVersion: this.config.deviceMetadata.osVersion,
|
|
1464
|
+
deviceModel: this.config.deviceMetadata.deviceModel
|
|
1465
|
+
},
|
|
1466
|
+
appSnapshot: {
|
|
1467
|
+
bundleId: this.config.appMetadata.bundleId,
|
|
1468
|
+
version: this.config.appMetadata.version,
|
|
1469
|
+
buildNumber: this.config.appMetadata.buildNumber,
|
|
1470
|
+
environment: this.environment
|
|
1471
|
+
},
|
|
1472
|
+
...input.metadata
|
|
1473
|
+
};
|
|
1474
|
+
if (input.screenshotPath != null) {
|
|
1475
|
+
payload.screenshotPath = input.screenshotPath;
|
|
1476
|
+
}
|
|
1477
|
+
if (input.expectedBehavior != null) {
|
|
1478
|
+
payload.expectedBehavior = input.expectedBehavior;
|
|
1479
|
+
}
|
|
1480
|
+
if (input.actualBehavior != null) {
|
|
1481
|
+
payload.actualBehavior = input.actualBehavior;
|
|
1482
|
+
}
|
|
1483
|
+
if (input.frequency != null) {
|
|
1484
|
+
payload.frequency = input.frequency;
|
|
1485
|
+
}
|
|
1486
|
+
if (input.reproductionSteps != null) {
|
|
1487
|
+
payload.reproductionSteps = input.reproductionSteps;
|
|
1488
|
+
}
|
|
1489
|
+
if (input.affectedFeature != null) {
|
|
1490
|
+
payload.affectedFeature = input.affectedFeature;
|
|
1491
|
+
}
|
|
1492
|
+
const localId = this.outbox.enqueueSync("bug_report", payload);
|
|
1493
|
+
this.outbox.scheduleDrain();
|
|
1494
|
+
this.log(`Bug report enqueued: ${localId}`);
|
|
1495
|
+
return localId;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Extract structured bug-report fields from a base64-encoded audio recording.
|
|
1499
|
+
*
|
|
1500
|
+
* Delegates to apiClient.voiceExtractBugFields. When `sourceLanguage` is
|
|
1501
|
+
* explicitly provided (ISO-639-1, e.g. 'de'), it is forwarded as-is.
|
|
1502
|
+
* When omitted, the language hint falls back to navigator.language
|
|
1503
|
+
* (best-effort, browser-only). Soft-fails to null when the apiClient does
|
|
1504
|
+
* not implement voiceExtractBugFields or the call throws.
|
|
1505
|
+
*
|
|
1506
|
+
* Contract: wire.bug_reports.voiceExtractBugFields
|
|
1507
|
+
*
|
|
1508
|
+
* @param audioBase64 - Raw base64 audio (no data: prefix)
|
|
1509
|
+
* @param mimeType - MIME type of the audio (e.g. 'audio/webm')
|
|
1510
|
+
* @param sourceLanguage - Optional explicit ISO-639-1 language code; when
|
|
1511
|
+
* omitted the SDK derives it from navigator.language
|
|
1512
|
+
* @returns Extracted fields, or null on any failure
|
|
1513
|
+
*/
|
|
1514
|
+
async extractBugFromVoice(audioBase64, mimeType, sourceLanguage) {
|
|
1515
|
+
const apiClient = this.config.apiClient;
|
|
1516
|
+
if (!apiClient?.voiceExtractBugFields) {
|
|
1517
|
+
this.log("extractBugFromVoice: apiClient does not implement voiceExtractBugFields \u2014 skipping");
|
|
1518
|
+
return null;
|
|
1519
|
+
}
|
|
1520
|
+
let resolvedLanguage = sourceLanguage;
|
|
1521
|
+
if (!resolvedLanguage) {
|
|
1522
|
+
try {
|
|
1523
|
+
if (typeof navigator !== "undefined" && navigator.language) {
|
|
1524
|
+
resolvedLanguage = navigator.language.split("-")[0].toLowerCase();
|
|
1525
|
+
}
|
|
1526
|
+
} catch {
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
try {
|
|
1530
|
+
const result = await apiClient.voiceExtractBugFields({
|
|
1531
|
+
audioBase64,
|
|
1532
|
+
mimeType,
|
|
1533
|
+
projectId: this.config.projectId,
|
|
1534
|
+
...resolvedLanguage ? { sourceLanguage: resolvedLanguage } : {}
|
|
1535
|
+
});
|
|
1536
|
+
return result;
|
|
1537
|
+
} catch (e) {
|
|
1538
|
+
this.log(`extractBugFromVoice: failed \u2014 ${String(e)}`);
|
|
1539
|
+
return null;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* One-tap report draft from a screenshot of the current screen — the
|
|
1544
|
+
* quick-action path next to voice. Returns null when the client does
|
|
1545
|
+
* not support it; throws on a server error so the UI can show it.
|
|
1546
|
+
*
|
|
1547
|
+
* Contract: wire.bug_reports.quickDraftBug
|
|
1548
|
+
*
|
|
1549
|
+
* @param screenshotDataUrl - `data:image/...;base64,...` of the screen
|
|
1550
|
+
*/
|
|
1551
|
+
async quickDraftBug(action, screenshotDataUrl, options = {}) {
|
|
1552
|
+
const apiClient = this.config.apiClient;
|
|
1553
|
+
if (!apiClient?.quickDraftBug) return null;
|
|
1554
|
+
const match = /^data:([^;]+);base64,(.*)$/.exec(screenshotDataUrl);
|
|
1555
|
+
return apiClient.quickDraftBug({
|
|
1556
|
+
action,
|
|
1557
|
+
screenshotBase64: match ? match[2] : screenshotDataUrl,
|
|
1558
|
+
mimeType: match ? match[1] : "image/png",
|
|
1559
|
+
...options.screenName ? { screenName: options.screenName } : {},
|
|
1560
|
+
...options.language ? { language: options.language } : {}
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Upload an arbitrary attachment (screenshot, screen recording, voice note, etc.)
|
|
1565
|
+
* to the standalone attachments endpoint (POST /v1/attachments).
|
|
1566
|
+
*
|
|
1567
|
+
* Returns the stored URL/path on success, null on any failure (best-effort,
|
|
1568
|
+
* never throws).
|
|
1569
|
+
*
|
|
1570
|
+
* Contract: wire.bug_reports.uploadAttachment (standalone path)
|
|
1571
|
+
*
|
|
1572
|
+
* @param input - Attachment data with base64 content, MIME type, file type, and optional file name
|
|
1573
|
+
* @returns The attachment url/path string, or null if upload failed or client unavailable
|
|
1574
|
+
*/
|
|
1575
|
+
async uploadAttachment(input) {
|
|
1576
|
+
const apiClient = this.config.apiClient;
|
|
1577
|
+
if (!apiClient?.uploadAttachment) {
|
|
1578
|
+
this.log("uploadAttachment: no api client with uploadAttachment \u2014 skipping");
|
|
1579
|
+
return null;
|
|
1580
|
+
}
|
|
1581
|
+
try {
|
|
1582
|
+
const fileName = input.fileName ?? `${input.fileType}-${Date.now()}.${input.mimeType.split("/")[1]?.split(";")[0] ?? "bin"}`;
|
|
1583
|
+
const fileSize = Math.ceil(input.base64.length * 3 / 4);
|
|
1584
|
+
const response = await apiClient.uploadAttachment({
|
|
1585
|
+
fileName,
|
|
1586
|
+
fileType: input.fileType,
|
|
1587
|
+
fileSize,
|
|
1588
|
+
fileData: input.base64,
|
|
1589
|
+
mimeType: input.mimeType
|
|
1590
|
+
});
|
|
1591
|
+
const path = response.url ?? response.id;
|
|
1592
|
+
this.log(`uploadAttachment: uploaded as ${path}`);
|
|
1593
|
+
return path ?? null;
|
|
1594
|
+
} catch (e) {
|
|
1595
|
+
this.log(`uploadAttachment: failed \u2014 ${String(e)}`);
|
|
1596
|
+
return null;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* Upload a screenshot dataURL to the standalone attachments endpoint.
|
|
1601
|
+
*
|
|
1602
|
+
* Converts the dataURL to a base64 payload and calls
|
|
1603
|
+
* POST /v1/attachments via the api-client. Returns the attachment
|
|
1604
|
+
* url/path on success, null on any failure (best-effort, never throws).
|
|
1605
|
+
*
|
|
1606
|
+
* Contract: wire.bug_reports.uploadAttachment (standalone path)
|
|
1607
|
+
*
|
|
1608
|
+
* @param dataUrl - A JPEG/PNG dataURL (e.g. from html2canvas or captureScreenshot)
|
|
1609
|
+
* @returns The attachment url string, or null if upload failed or client unavailable
|
|
1610
|
+
*/
|
|
1611
|
+
async uploadScreenshot(dataUrl) {
|
|
1612
|
+
const apiClient = this.config.apiClient;
|
|
1613
|
+
if (!apiClient?.uploadAttachment) {
|
|
1614
|
+
this.log("uploadScreenshot: no api client with uploadAttachment \u2014 skipping");
|
|
1615
|
+
return null;
|
|
1616
|
+
}
|
|
1617
|
+
try {
|
|
1618
|
+
const commaIdx = dataUrl.indexOf(",");
|
|
1619
|
+
const fileData = commaIdx >= 0 ? dataUrl.slice(commaIdx + 1) : dataUrl;
|
|
1620
|
+
const headerPart = commaIdx >= 0 ? dataUrl.slice(0, commaIdx) : "";
|
|
1621
|
+
const mimeMatch = headerPart.match(/data:([^;]+)/);
|
|
1622
|
+
const mimeType = mimeMatch ? mimeMatch[1] : "image/jpeg";
|
|
1623
|
+
const fileName = `screenshot-${Date.now()}.jpg`;
|
|
1624
|
+
const fileSize = Math.ceil(fileData.length * 3 / 4);
|
|
1625
|
+
const response = await apiClient.uploadAttachment({
|
|
1626
|
+
fileName,
|
|
1627
|
+
fileType: "screenshot",
|
|
1628
|
+
fileSize,
|
|
1629
|
+
fileData,
|
|
1630
|
+
mimeType
|
|
1631
|
+
});
|
|
1632
|
+
const path = response.filePath ?? response.id;
|
|
1633
|
+
this.log(`uploadScreenshot: uploaded as ${path}`);
|
|
1634
|
+
return path ?? null;
|
|
1635
|
+
} catch (e) {
|
|
1636
|
+
this.log(`uploadScreenshot: failed \u2014 ${String(e)}`);
|
|
1637
|
+
return null;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Fetch the bug reports this tester has submitted (newest first).
|
|
1642
|
+
* Delegates to apiClient.listMyBugReports. Soft-fails to an empty list
|
|
1643
|
+
* when the client lacks the method or the call throws.
|
|
1644
|
+
*
|
|
1645
|
+
* Requires a tester auth token — if unauthenticated the server 401s and
|
|
1646
|
+
* this returns the empty-list soft-fail result.
|
|
1647
|
+
*/
|
|
1648
|
+
async getMyBugReports(params) {
|
|
1649
|
+
const apiClient = this.config.apiClient;
|
|
1650
|
+
if (!apiClient?.listMyBugReports) {
|
|
1651
|
+
this.log("getMyBugReports: apiClient does not implement listMyBugReports \u2014 returning empty list");
|
|
1652
|
+
return { bugReports: [] };
|
|
1653
|
+
}
|
|
1654
|
+
try {
|
|
1655
|
+
return await apiClient.listMyBugReports(params);
|
|
1656
|
+
} catch (e) {
|
|
1657
|
+
this.log(`getMyBugReports: failed \u2014 ${String(e)}`);
|
|
1658
|
+
return { bugReports: [] };
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Fetch tester notifications + unread count.
|
|
1663
|
+
* Delegates to apiClient.getMyNotifications. Soft-fails to
|
|
1664
|
+
* { notifications: [], unreadCount: 0 } on any error or missing method.
|
|
1665
|
+
*
|
|
1666
|
+
* Requires a tester auth token.
|
|
1667
|
+
*/
|
|
1668
|
+
async getNotifications(params) {
|
|
1669
|
+
const apiClient = this.config.apiClient;
|
|
1670
|
+
if (!apiClient?.getMyNotifications) {
|
|
1671
|
+
this.log("getNotifications: apiClient does not implement getMyNotifications \u2014 returning empty");
|
|
1672
|
+
return { notifications: [], unreadCount: 0 };
|
|
1673
|
+
}
|
|
1674
|
+
try {
|
|
1675
|
+
return await apiClient.getMyNotifications(params);
|
|
1676
|
+
} catch (e) {
|
|
1677
|
+
this.log(`getNotifications: failed \u2014 ${String(e)}`);
|
|
1678
|
+
return { notifications: [], unreadCount: 0 };
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* Mark notifications read. Pass an id to mark one; omit to mark all.
|
|
1683
|
+
* Delegates to apiClient.markNotificationsRead. Soft-fails to null on
|
|
1684
|
+
* any error or missing method (caller should treat null as "no change").
|
|
1685
|
+
*
|
|
1686
|
+
* Requires a tester auth token.
|
|
1687
|
+
*
|
|
1688
|
+
* @returns New unread count, or null on soft-fail.
|
|
1689
|
+
*/
|
|
1690
|
+
async markNotificationsRead(id) {
|
|
1691
|
+
const apiClient = this.config.apiClient;
|
|
1692
|
+
if (!apiClient?.markNotificationsRead) {
|
|
1693
|
+
this.log("markNotificationsRead: apiClient does not implement markNotificationsRead \u2014 skipping");
|
|
1694
|
+
return null;
|
|
1695
|
+
}
|
|
1696
|
+
try {
|
|
1697
|
+
return await apiClient.markNotificationsRead(id ? { id } : void 0);
|
|
1698
|
+
} catch (e) {
|
|
1699
|
+
this.log(`markNotificationsRead: failed \u2014 ${String(e)}`);
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* Aggregate local metrics and enqueue a submission to the outbox.
|
|
1705
|
+
* Returns a local UUID immediately — the upload happens in the background.
|
|
1706
|
+
*
|
|
1707
|
+
* Contract: api.submissions.createSubmissionAsync / invariants.local_first_outbox
|
|
1708
|
+
*
|
|
1709
|
+
* @returns A local UUID that can be passed to getSubmissionStatus()
|
|
1710
|
+
*/
|
|
1711
|
+
async createSubmission() {
|
|
1712
|
+
this.ensureInitialized();
|
|
1713
|
+
const summary = await this.metricsAggregator.aggregateWeeklySummary();
|
|
1714
|
+
const localId = await this.outbox.enqueue("submission", summary);
|
|
1715
|
+
this.outbox.scheduleDrain();
|
|
1716
|
+
this.log(`Submission enqueued: ${localId}`);
|
|
1717
|
+
return localId;
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Query the outbox state for a previously-enqueued item.
|
|
1721
|
+
*
|
|
1722
|
+
* Returns:
|
|
1723
|
+
* 'pending' — enqueued, not yet uploaded
|
|
1724
|
+
* 'uploading' — upload in progress
|
|
1725
|
+
* 'failed' — permanently rejected (4xx); will not be retried
|
|
1726
|
+
* null — item not found; treat as successfully sent (deleted on success)
|
|
1727
|
+
*
|
|
1728
|
+
* Contract: api.submissions.getSubmissionStatus / enums.OutboxItemStatus
|
|
1729
|
+
*
|
|
1730
|
+
* @param localId - The id returned by submitBugReport() or createSubmission()
|
|
1731
|
+
*/
|
|
1732
|
+
async getSubmissionStatus(localId) {
|
|
1733
|
+
this.ensureInitialized();
|
|
1734
|
+
return this.outbox.getStatus(localId);
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Generate and store a weekly summary.
|
|
1738
|
+
*
|
|
1739
|
+
* Aggregates all metrics from the past 7 days and stores the summary
|
|
1740
|
+
* in the local database for later submission.
|
|
1741
|
+
*
|
|
1742
|
+
* @returns Promise resolving to the generated summary
|
|
1743
|
+
*/
|
|
1744
|
+
async generateWeeklySummary() {
|
|
1745
|
+
this.ensureInitialized();
|
|
1746
|
+
const summary = await this.metricsAggregator.aggregateWeeklySummary();
|
|
1747
|
+
await this.metricsAggregator.storeSummary(summary);
|
|
1748
|
+
this.log(`Weekly summary generated: ${summary.id}`);
|
|
1749
|
+
return summary;
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* Get all pending summaries.
|
|
1753
|
+
*
|
|
1754
|
+
* @returns Array of pending WeeklySummary objects
|
|
1755
|
+
*/
|
|
1756
|
+
async getPendingSummaries() {
|
|
1757
|
+
this.ensureInitialized();
|
|
1758
|
+
return this.metricsAggregator.getPendingSummaries();
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Submit pending summaries to the server.
|
|
1762
|
+
* Requires apiClient to be configured.
|
|
1763
|
+
*
|
|
1764
|
+
* @returns Number of summaries successfully submitted
|
|
1765
|
+
*/
|
|
1766
|
+
async submitPendingSummaries() {
|
|
1767
|
+
this.ensureInitialized();
|
|
1768
|
+
if (!this.config.apiClient) {
|
|
1769
|
+
throw new Error("API client not configured. Cannot submit summaries.");
|
|
1770
|
+
}
|
|
1771
|
+
const pendingSummaries = await this.metricsAggregator.getPendingSummaries();
|
|
1772
|
+
let successCount = 0;
|
|
1773
|
+
for (const summary of pendingSummaries) {
|
|
1774
|
+
try {
|
|
1775
|
+
await this.config.apiClient.submitSummary(summary);
|
|
1776
|
+
await this.metricsAggregator.updateSummaryStatus(summary.id, "submitted");
|
|
1777
|
+
successCount++;
|
|
1778
|
+
this.log(`Summary submitted: ${summary.id}`);
|
|
1779
|
+
} catch (error) {
|
|
1780
|
+
this.log(`Failed to submit summary ${summary.id}: ${error}`);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
return successCount;
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Get the current session ID.
|
|
1787
|
+
*
|
|
1788
|
+
* @returns Current session ID or null if no session is active
|
|
1789
|
+
*/
|
|
1790
|
+
getCurrentSessionId() {
|
|
1791
|
+
return this.sessionManager.getCurrentSessionId();
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Check if there's an active session.
|
|
1795
|
+
*
|
|
1796
|
+
* @returns true if a session is currently active
|
|
1797
|
+
*/
|
|
1798
|
+
hasActiveSession() {
|
|
1799
|
+
return this.sessionManager.hasActiveSession();
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Get the current session duration in seconds.
|
|
1803
|
+
*
|
|
1804
|
+
* @returns Duration in seconds, or 0 if no session is active
|
|
1805
|
+
*/
|
|
1806
|
+
getSessionDuration() {
|
|
1807
|
+
return this.sessionManager.getSessionDuration();
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* Close the SDK and clean up resources.
|
|
1811
|
+
*/
|
|
1812
|
+
async close() {
|
|
1813
|
+
if (this.sessionManager.hasActiveSession()) {
|
|
1814
|
+
await this.sessionManager.endSession();
|
|
1815
|
+
}
|
|
1816
|
+
await this.config.storage.close();
|
|
1817
|
+
this.initialized = false;
|
|
1818
|
+
this.log("SDK closed");
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* Ensure the SDK is initialized before use.
|
|
1822
|
+
* @throws Error if SDK is not initialized
|
|
1823
|
+
*/
|
|
1824
|
+
ensureInitialized() {
|
|
1825
|
+
if (!this.initialized) {
|
|
1826
|
+
throw new Error("TesterPayKit not initialized. Call init() first.");
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Log debug messages. Always pushes to the ring buffer (regardless of
|
|
1831
|
+
* debugMode) so TpkDebugLogsScreen has content. Console output stays
|
|
1832
|
+
* gated by debugMode.
|
|
1833
|
+
*/
|
|
1834
|
+
log(message) {
|
|
1835
|
+
this._logBuffer.push({ ts: Date.now(), message });
|
|
1836
|
+
if (this._logBuffer.length > _TesterPayKit.LOG_BUFFER_MAX) {
|
|
1837
|
+
this._logBuffer.shift();
|
|
1838
|
+
}
|
|
1839
|
+
if (this.config.debugMode) {
|
|
1840
|
+
console.log(`[TesterPayKit] ${message}`);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Get a copy of the debug log buffer, newest entry first.
|
|
1845
|
+
* The buffer accumulates up to 200 entries regardless of debugMode.
|
|
1846
|
+
*/
|
|
1847
|
+
getDebugLogs() {
|
|
1848
|
+
return this._logBuffer.slice().reverse();
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Clear the debug log buffer.
|
|
1852
|
+
*/
|
|
1853
|
+
clearDebugLogs() {
|
|
1854
|
+
this._logBuffer = [];
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1857
|
+
* Get the epoch-ms timestamp when the current session started.
|
|
1858
|
+
* Delegates to SessionManager.
|
|
1859
|
+
*
|
|
1860
|
+
* @returns Epoch ms, or null when no session is active
|
|
1861
|
+
*/
|
|
1862
|
+
getCurrentSessionStartedAt() {
|
|
1863
|
+
return this.sessionManager.getCurrentSessionStartedAt();
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* Compute estimated session earnings.
|
|
1867
|
+
*
|
|
1868
|
+
* engagedMinutes = floor(elapsed since session start in minutes).
|
|
1869
|
+
* amount = earningsBaseAmount + engagedMinutes * earningsPerMinuteRate.
|
|
1870
|
+
* currency = earningsCurrency from config.
|
|
1871
|
+
*
|
|
1872
|
+
* Soft-safe: never throws; returns base/0/currency when no session.
|
|
1873
|
+
* Max cap: 480 minutes (8 hours) to keep amounts sane.
|
|
1874
|
+
*/
|
|
1875
|
+
getSessionEarnings() {
|
|
1876
|
+
try {
|
|
1877
|
+
const base = this.config.earningsBaseAmount ?? 1;
|
|
1878
|
+
const rate = this.config.earningsPerMinuteRate ?? 0.1;
|
|
1879
|
+
const currency = this.config.earningsCurrency ?? "EUR";
|
|
1880
|
+
const startedAt = this.sessionManager.getCurrentSessionStartedAt();
|
|
1881
|
+
if (startedAt === null) {
|
|
1882
|
+
return { engagedMinutes: 0, amount: base, currency };
|
|
1883
|
+
}
|
|
1884
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1885
|
+
const engagedMinutes = Math.min(Math.floor(elapsedMs / 6e4), 480);
|
|
1886
|
+
const amount = base + engagedMinutes * rate;
|
|
1887
|
+
return { engagedMinutes, amount, currency };
|
|
1888
|
+
} catch {
|
|
1889
|
+
return {
|
|
1890
|
+
engagedMinutes: 0,
|
|
1891
|
+
amount: this.config.earningsBaseAmount ?? 1,
|
|
1892
|
+
currency: this.config.earningsCurrency ?? "EUR"
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Return the configured app metadata (bundleId, version, buildNumber, etc.).
|
|
1898
|
+
* Used by TpkWorkboardScreen to show the current build info block.
|
|
1899
|
+
*/
|
|
1900
|
+
getAppInfo() {
|
|
1901
|
+
return this.config.appMetadata;
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Fetch the tester's assigned tasks.
|
|
1905
|
+
* Delegates to apiClient.getMyTasks. Soft-fails to an empty TaskListResponse
|
|
1906
|
+
* when the client lacks the method or the call throws.
|
|
1907
|
+
*/
|
|
1908
|
+
async getMyTasks(params) {
|
|
1909
|
+
const apiClient = this.config.apiClient;
|
|
1910
|
+
if (!apiClient?.getMyTasks) {
|
|
1911
|
+
this.log("getMyTasks: apiClient does not implement getMyTasks \u2014 returning empty list");
|
|
1912
|
+
return { tasks: [], total: 0, summary: { pending: 0, inProgress: 0, completed: 0 } };
|
|
1913
|
+
}
|
|
1914
|
+
try {
|
|
1915
|
+
return await apiClient.getMyTasks(params);
|
|
1916
|
+
} catch (e) {
|
|
1917
|
+
this.log(`getMyTasks: failed \u2014 ${String(e)}`);
|
|
1918
|
+
return { tasks: [], total: 0, summary: { pending: 0, inProgress: 0, completed: 0 } };
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Mark a task assignment in-progress.
|
|
1923
|
+
* Delegates to apiClient.startTaskAssignment. Soft-fails silently on error.
|
|
1924
|
+
*/
|
|
1925
|
+
async startTask(assignmentId) {
|
|
1926
|
+
const apiClient = this.config.apiClient;
|
|
1927
|
+
if (!apiClient?.startTaskAssignment) {
|
|
1928
|
+
this.log("startTask: apiClient does not implement startTaskAssignment \u2014 skipping");
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
try {
|
|
1932
|
+
await apiClient.startTaskAssignment(assignmentId);
|
|
1933
|
+
} catch (e) {
|
|
1934
|
+
this.log(`startTask: failed \u2014 ${String(e)}`);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
/**
|
|
1938
|
+
* Complete a task assignment.
|
|
1939
|
+
* Delegates to apiClient.completeTaskAssignment. Soft-fails silently on error.
|
|
1940
|
+
*/
|
|
1941
|
+
async completeTask(assignmentId) {
|
|
1942
|
+
const apiClient = this.config.apiClient;
|
|
1943
|
+
if (!apiClient?.completeTaskAssignment) {
|
|
1944
|
+
this.log("completeTask: apiClient does not implement completeTaskAssignment \u2014 skipping");
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
try {
|
|
1948
|
+
await apiClient.completeTaskAssignment(assignmentId);
|
|
1949
|
+
} catch (e) {
|
|
1950
|
+
this.log(`completeTask: failed \u2014 ${String(e)}`);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Fetch the tester's retest queue.
|
|
1955
|
+
* Delegates to apiClient.getMyRetests. Soft-fails to an empty array.
|
|
1956
|
+
*/
|
|
1957
|
+
async getMyRetests(params) {
|
|
1958
|
+
const apiClient = this.config.apiClient;
|
|
1959
|
+
if (!apiClient?.getMyRetests) {
|
|
1960
|
+
this.log("getMyRetests: apiClient does not implement getMyRetests \u2014 returning empty list");
|
|
1961
|
+
return [];
|
|
1962
|
+
}
|
|
1963
|
+
try {
|
|
1964
|
+
return await apiClient.getMyRetests(params);
|
|
1965
|
+
} catch (e) {
|
|
1966
|
+
this.log(`getMyRetests: failed \u2014 ${String(e)}`);
|
|
1967
|
+
return [];
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Mark a retest as verified (bug is fixed).
|
|
1972
|
+
* Delegates to apiClient.verifyRetest. Soft-fails silently on error.
|
|
1973
|
+
*/
|
|
1974
|
+
async verifyRetest(bugId, stepVerdicts) {
|
|
1975
|
+
const apiClient = this.config.apiClient;
|
|
1976
|
+
if (!apiClient?.verifyRetest) {
|
|
1977
|
+
this.log("verifyRetest: apiClient does not implement verifyRetest \u2014 skipping");
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
try {
|
|
1981
|
+
await apiClient.verifyRetest(bugId, stepVerdicts);
|
|
1982
|
+
} catch (e) {
|
|
1983
|
+
this.log(`verifyRetest: failed \u2014 ${String(e)}`);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Reopen a retest (bug still present), optionally with a reason.
|
|
1988
|
+
* Delegates to apiClient.reopenRetest. Soft-fails silently on error.
|
|
1989
|
+
*/
|
|
1990
|
+
async reopenRetest(bugId, reason, stepVerdicts) {
|
|
1991
|
+
const apiClient = this.config.apiClient;
|
|
1992
|
+
if (!apiClient?.reopenRetest) {
|
|
1993
|
+
this.log("reopenRetest: apiClient does not implement reopenRetest \u2014 skipping");
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
try {
|
|
1997
|
+
await apiClient.reopenRetest(bugId, reason, stepVerdicts);
|
|
1998
|
+
} catch (e) {
|
|
1999
|
+
this.log(`reopenRetest: failed \u2014 ${String(e)}`);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
/**
|
|
2003
|
+
* Fetch the tester's profile.
|
|
2004
|
+
* Delegates to apiClient.getMyProfile. Soft-fails to null on error.
|
|
2005
|
+
*/
|
|
2006
|
+
async getMyProfile() {
|
|
2007
|
+
const apiClient = this.config.apiClient;
|
|
2008
|
+
if (!apiClient?.getMyProfile) {
|
|
2009
|
+
this.log("getMyProfile: apiClient does not implement getMyProfile \u2014 returning null");
|
|
2010
|
+
return null;
|
|
2011
|
+
}
|
|
2012
|
+
try {
|
|
2013
|
+
return await apiClient.getMyProfile();
|
|
2014
|
+
} catch (e) {
|
|
2015
|
+
this.log(`getMyProfile: failed \u2014 ${String(e)}`);
|
|
2016
|
+
return null;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
/**
|
|
2020
|
+
* Fetch the tester's points balance.
|
|
2021
|
+
* Delegates to apiClient.getMyPoints. Soft-fails to null on error.
|
|
2022
|
+
*/
|
|
2023
|
+
async getMyPoints() {
|
|
2024
|
+
const apiClient = this.config.apiClient;
|
|
2025
|
+
if (!apiClient?.getMyPoints) {
|
|
2026
|
+
this.log("getMyPoints: apiClient does not implement getMyPoints \u2014 returning null");
|
|
2027
|
+
return null;
|
|
2028
|
+
}
|
|
2029
|
+
try {
|
|
2030
|
+
return await apiClient.getMyPoints();
|
|
2031
|
+
} catch (e) {
|
|
2032
|
+
this.log(`getMyPoints: failed \u2014 ${String(e)}`);
|
|
2033
|
+
return null;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
};
|
|
2037
|
+
// ── Debug log ring buffer ────────────────────────────────────────────────────
|
|
2038
|
+
// Holds the most recent LOG_BUFFER_MAX entries regardless of debugMode.
|
|
2039
|
+
// Console output stays gated by debugMode.
|
|
2040
|
+
_TesterPayKit.LOG_BUFFER_MAX = 200;
|
|
2041
|
+
var TesterPayKit = _TesterPayKit;
|
|
2042
|
+
function isProductionBuild() {
|
|
2043
|
+
try {
|
|
2044
|
+
return typeof process !== "undefined" && process.env?.NODE_ENV === "production";
|
|
2045
|
+
} catch {
|
|
2046
|
+
return false;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
// src/index.ts
|
|
2051
|
+
import { BUG_REPORT_TYPES as BUG_REPORT_TYPES2, BUG_REPORT_CATEGORIES as BUG_REPORT_CATEGORIES2, BUG_REPORT_SEVERITIES as BUG_REPORT_SEVERITIES2 } from "@testerpaykit/core";
|
|
2052
|
+
export {
|
|
2053
|
+
BUG_REPORT_CATEGORIES2 as BUG_REPORT_CATEGORIES,
|
|
2054
|
+
BUG_REPORT_SEVERITIES2 as BUG_REPORT_SEVERITIES,
|
|
2055
|
+
BUG_REPORT_TYPES2 as BUG_REPORT_TYPES,
|
|
2056
|
+
DeviceIdManager,
|
|
2057
|
+
EventTracker,
|
|
2058
|
+
MetricsAggregator,
|
|
2059
|
+
Outbox,
|
|
2060
|
+
SessionManager,
|
|
2061
|
+
TesterPayKit,
|
|
2062
|
+
ValidationConstants
|
|
2063
|
+
};
|