@usero/sdk 1.1.10 → 1.1.12
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/plugins/session-replay.cjs +71 -3
- package/dist/plugins/session-replay.cjs.map +1 -1
- package/dist/plugins/session-replay.d.cts +8 -0
- package/dist/plugins/session-replay.d.ts +8 -0
- package/dist/plugins/session-replay.js +71 -3
- package/dist/plugins/session-replay.js.map +1 -1
- package/dist/plugins/user-test.cjs +536 -247
- package/dist/plugins/user-test.cjs.map +1 -1
- package/dist/plugins/user-test.d.cts +50 -2
- package/dist/plugins/user-test.d.ts +50 -2
- package/dist/plugins/user-test.js +536 -247
- package/dist/plugins/user-test.js.map +1 -1
- package/dist/react.cjs +11 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +11 -1
- package/dist/react.js.map +1 -1
- package/dist/usero.iife.js +20 -20
- package/dist/usero.iife.js.map +1 -1
- package/dist/vanilla.cjs +26 -1
- package/dist/vanilla.cjs.map +1 -1
- package/dist/vanilla.d.cts +41 -1
- package/dist/vanilla.d.ts +41 -1
- package/dist/vanilla.js +26 -2
- package/dist/vanilla.js.map +1 -1
- package/package.json +1 -1
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// src/types.ts
|
|
4
4
|
var DEFAULT_API_URL = "https://usero.io";
|
|
5
5
|
|
|
6
|
-
// src/
|
|
6
|
+
// src/identity.ts
|
|
7
|
+
function isValidSdkSessionId(id) {
|
|
8
|
+
return /^[a-z0-9-]{8,}$/i.test(id);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/plugins/user-test/shared.ts
|
|
7
12
|
var DEFAULT_OPTIONS = {
|
|
8
13
|
queryParam: "usero_test",
|
|
9
14
|
// 10s (not 30) so at most ~10s of audio is at risk if the tab is torn
|
|
@@ -18,8 +23,96 @@ var DEFAULT_OPTIONS = {
|
|
|
18
23
|
};
|
|
19
24
|
var TESTER_NAME_STORAGE_KEY = "usero:user-test:tester-name";
|
|
20
25
|
var TASKS_PANEL_OPEN_STORAGE_KEY = "usero:user-test:tasks-panel-open";
|
|
26
|
+
var ACTIVE_SESSION_STORAGE_KEY = "usero:user-test:active-session";
|
|
27
|
+
var ACTIVE_SESSION_MAX_AGE_MS = 2 * 60 * 60 * 1e3;
|
|
28
|
+
var RESUME_MAX_IDLE_MS = 30 * 60 * 1e3;
|
|
21
29
|
var IDB_NAME = "usero-user-test";
|
|
22
30
|
var IDB_STORE = "pending-chunks";
|
|
31
|
+
var SILENCE_RMS_DB_THRESHOLD = -60;
|
|
32
|
+
var SILENCE_FLOOR_DB = -100;
|
|
33
|
+
var SILENCE_SUSTAINED_MS = 1800;
|
|
34
|
+
var SILENCE_POLL_MS = 250;
|
|
35
|
+
var MIC_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="13" height="13"><path d="M8 1.5a2 2 0 0 0-2 2v4a2 2 0 1 0 4 0v-4a2 2 0 0 0-2-2Z" fill="currentColor"/><path d="M4 7.5a4 4 0 0 0 8 0M8 11.5v3M5.5 14.5h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
|
|
36
|
+
var MIC_MUTED_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="13" height="13"><path d="M8 1.5a2 2 0 0 0-2 2v3.2L10 11V3.5a2 2 0 0 0-2-2Z" fill="currentColor"/><path d="M4 7.5a4 4 0 0 0 6.5 3.12M12 7.5a4 4 0 0 1-.3 1.5M8 11.5v3M5.5 14.5h5M2 2l12 12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
|
|
37
|
+
var NOTE_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path d="M3 3.5A1.5 1.5 0 0 1 4.5 2h7A1.5 1.5 0 0 1 13 3.5V10a1.5 1.5 0 0 1-1.5 1.5H7L4 14v-2.5h-.5A1.5 1.5 0 0 1 2 10V3.5A1.5 1.5 0 0 1 3.5 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
38
|
+
var TICK_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 12.5 10 17.5 19 7" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
39
|
+
var TICK_SM_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 8.5 6.5 11.5 12.5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
40
|
+
var CLOCK_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="8.4" stroke="currentColor" stroke-width="2"/><path d="M12 7.5V12l3 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
41
|
+
var SPARK_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 1.5 9.5 6.5 14.5 8 9.5 9.5 8 14.5 6.5 9.5 1.5 8 6.5 6.5Z" fill="currentColor"/></svg>`;
|
|
42
|
+
var FLAG_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 21V4M6 4.5h9.5l-1.6 3.2 1.6 3.2H6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
43
|
+
|
|
44
|
+
// src/plugins/user-test/session.ts
|
|
45
|
+
function parseActiveSession(raw) {
|
|
46
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
47
|
+
const s = raw;
|
|
48
|
+
if (typeof s.slug !== "string" || !s.slug) return null;
|
|
49
|
+
if (typeof s.sessionId !== "string" || !s.sessionId) return null;
|
|
50
|
+
if (typeof s.nextChunkIndex !== "number" || !Number.isInteger(s.nextChunkIndex) || s.nextChunkIndex < 0) return null;
|
|
51
|
+
if (typeof s.startedAt !== "number" || !Number.isFinite(s.startedAt)) return null;
|
|
52
|
+
const status = s.status === "paused" ? "paused" : "active";
|
|
53
|
+
const result = {
|
|
54
|
+
slug: s.slug,
|
|
55
|
+
sessionId: s.sessionId,
|
|
56
|
+
nextChunkIndex: s.nextChunkIndex,
|
|
57
|
+
startedAt: s.startedAt,
|
|
58
|
+
status
|
|
59
|
+
};
|
|
60
|
+
if (typeof s.sdkSessionId === "string" && isValidSdkSessionId(s.sdkSessionId)) {
|
|
61
|
+
result.sdkSessionId = s.sdkSessionId;
|
|
62
|
+
}
|
|
63
|
+
if (typeof s.pausedAt === "number" && Number.isFinite(s.pausedAt)) {
|
|
64
|
+
result.pausedAt = s.pausedAt;
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
function readActiveSession() {
|
|
69
|
+
try {
|
|
70
|
+
const raw = window.localStorage?.getItem(ACTIVE_SESSION_STORAGE_KEY);
|
|
71
|
+
if (!raw) return null;
|
|
72
|
+
const parsed = parseActiveSession(JSON.parse(raw));
|
|
73
|
+
if (!parsed) return null;
|
|
74
|
+
if (Date.now() - parsed.startedAt > ACTIVE_SESSION_MAX_AGE_MS) {
|
|
75
|
+
clearActiveSession();
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (parsed.status === "paused" && typeof parsed.pausedAt === "number") {
|
|
79
|
+
if (Date.now() - parsed.pausedAt > RESUME_MAX_IDLE_MS) {
|
|
80
|
+
clearActiveSession();
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return parsed;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function writeActiveSession(state) {
|
|
90
|
+
try {
|
|
91
|
+
window.localStorage?.setItem(ACTIVE_SESSION_STORAGE_KEY, JSON.stringify(state));
|
|
92
|
+
} catch {
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function clearActiveSession() {
|
|
96
|
+
try {
|
|
97
|
+
window.localStorage?.removeItem(ACTIVE_SESSION_STORAGE_KEY);
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function persistActiveSession(store, status) {
|
|
102
|
+
if (!store.sessionId) return;
|
|
103
|
+
const state = {
|
|
104
|
+
slug: store.slug,
|
|
105
|
+
sessionId: store.sessionId,
|
|
106
|
+
nextChunkIndex: store.chunkIndex,
|
|
107
|
+
startedAt: store.startedAt,
|
|
108
|
+
status
|
|
109
|
+
};
|
|
110
|
+
if (store.sdkSessionId) state.sdkSessionId = store.sdkSessionId;
|
|
111
|
+
if (status === "paused") {
|
|
112
|
+
state.pausedAt = Date.now();
|
|
113
|
+
}
|
|
114
|
+
writeActiveSession(state);
|
|
115
|
+
}
|
|
23
116
|
function readTesterName(override) {
|
|
24
117
|
if (override) return override;
|
|
25
118
|
try {
|
|
@@ -55,118 +148,153 @@ function getTestSlug(queryParam) {
|
|
|
55
148
|
return null;
|
|
56
149
|
}
|
|
57
150
|
}
|
|
58
|
-
function
|
|
59
|
-
|
|
151
|
+
function parseTasks(raw) {
|
|
152
|
+
if (!Array.isArray(raw)) return [];
|
|
153
|
+
const out = raw.flatMap((item) => {
|
|
154
|
+
const t = item;
|
|
155
|
+
if (!t || typeof t.id !== "string" || typeof t.prompt !== "string" || typeof t.sortOrder !== "number") return [];
|
|
156
|
+
return [{ id: t.id, prompt: t.prompt, sortOrder: t.sortOrder }];
|
|
157
|
+
});
|
|
158
|
+
out.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
159
|
+
return out;
|
|
60
160
|
}
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
161
|
+
async function createSession(apiUrl, slug, testerName) {
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { "Content-Type": "application/json" },
|
|
166
|
+
body: JSON.stringify({ slug, ...testerName ? { testerName } : {} })
|
|
167
|
+
});
|
|
168
|
+
if (!res.ok) return null;
|
|
169
|
+
const json = await res.json();
|
|
170
|
+
if (typeof json.sessionId !== "string" || typeof json.clientId !== "string") return null;
|
|
171
|
+
return { sessionId: json.sessionId, clientId: json.clientId, tasks: parseTasks(json.tasks) };
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
67
174
|
}
|
|
68
|
-
return void 0;
|
|
69
175
|
}
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
};
|
|
84
|
-
req.onsuccess = () => resolve(req.result);
|
|
85
|
-
req.onerror = () => resolve(null);
|
|
86
|
-
} catch {
|
|
87
|
-
resolve(null);
|
|
88
|
-
}
|
|
89
|
-
});
|
|
176
|
+
async function adoptSession(apiUrl, sessionId) {
|
|
177
|
+
try {
|
|
178
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/adopt`, {
|
|
179
|
+
method: "GET"
|
|
180
|
+
});
|
|
181
|
+
if (res.status === 409 || res.status === 410) return { kind: "closed" };
|
|
182
|
+
if (!res.ok) return { kind: "error" };
|
|
183
|
+
const json = await res.json();
|
|
184
|
+
if (typeof json.sessionId !== "string" || typeof json.clientId !== "string") return { kind: "error" };
|
|
185
|
+
return { kind: "ok", sessionId: json.sessionId, clientId: json.clientId, tasks: parseTasks(json.tasks) };
|
|
186
|
+
} catch {
|
|
187
|
+
return { kind: "error" };
|
|
188
|
+
}
|
|
90
189
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
resolve();
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
db.close();
|
|
190
|
+
function parsePaymentSummary(raw) {
|
|
191
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
192
|
+
const p = raw;
|
|
193
|
+
if (typeof p.qualified !== "boolean") return null;
|
|
194
|
+
return {
|
|
195
|
+
qualified: p.qualified,
|
|
196
|
+
reward: typeof p.reward === "string" ? p.reward : null,
|
|
197
|
+
payoutEmail: typeof p.payoutEmail === "string" ? p.payoutEmail : null,
|
|
198
|
+
tasksDone: typeof p.tasksDone === "number" ? p.tasksDone : 0,
|
|
199
|
+
tasksTotal: typeof p.tasksTotal === "number" ? p.tasksTotal : 0
|
|
200
|
+
};
|
|
106
201
|
}
|
|
107
|
-
async function
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
tx.oncomplete = () => resolve();
|
|
115
|
-
tx.onerror = () => resolve();
|
|
116
|
-
tx.onabort = () => resolve();
|
|
117
|
-
} catch {
|
|
118
|
-
resolve();
|
|
202
|
+
async function finaliseSession(apiUrl, sessionId, durationSeconds, extras = {}) {
|
|
203
|
+
try {
|
|
204
|
+
const body = {
|
|
205
|
+
durationSeconds: Math.max(0, Math.round(durationSeconds))
|
|
206
|
+
};
|
|
207
|
+
if (extras.mutedSegments && extras.mutedSegments.length > 0) {
|
|
208
|
+
body.mutedSegments = extras.mutedSegments;
|
|
119
209
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
210
|
+
const trimmedEndNote = extras.endNote?.trim();
|
|
211
|
+
if (trimmedEndNote) body.endNote = trimmedEndNote;
|
|
212
|
+
if (extras.notes && extras.notes.length > 0) {
|
|
213
|
+
body.notes = extras.notes.slice(0, 200).map((n) => ({
|
|
214
|
+
atMs: Math.max(0, Math.round(n.atMs)),
|
|
215
|
+
text: n.text
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
if (extras.sdkSessionId) body.sdkSessionId = extras.sdkSessionId;
|
|
219
|
+
if (typeof extras.replayOffsetMs === "number") {
|
|
220
|
+
body.replayOffsetMs = Math.max(0, Math.round(extras.replayOffsetMs));
|
|
221
|
+
}
|
|
222
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/finalise`, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: { "Content-Type": "application/json" },
|
|
225
|
+
body: JSON.stringify(body),
|
|
226
|
+
keepalive: true
|
|
227
|
+
});
|
|
228
|
+
if (!res.ok) return { ok: false, payment: null };
|
|
229
|
+
let payment = null;
|
|
127
230
|
try {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
req.onsuccess = () => {
|
|
131
|
-
const all = req.result ?? [];
|
|
132
|
-
resolve(all.filter((c) => c.sessionId === sessionId));
|
|
133
|
-
};
|
|
134
|
-
req.onerror = () => resolve([]);
|
|
231
|
+
const json = await res.json();
|
|
232
|
+
payment = parsePaymentSummary(json.payment);
|
|
135
233
|
} catch {
|
|
136
|
-
resolve([]);
|
|
137
234
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
235
|
+
return { ok: true, payment };
|
|
236
|
+
} catch {
|
|
237
|
+
return { ok: false, payment: null };
|
|
238
|
+
}
|
|
141
239
|
}
|
|
142
|
-
async function
|
|
143
|
-
const url = `${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/
|
|
144
|
-
|
|
145
|
-
|
|
240
|
+
async function postPayout(apiUrl, sessionId, destination, logger) {
|
|
241
|
+
const url = `${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/payout`;
|
|
242
|
+
const body = { method: "email" };
|
|
243
|
+
if (destination) body.destination = destination;
|
|
244
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
146
245
|
try {
|
|
147
246
|
const res = await fetch(url, {
|
|
148
|
-
method: "
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
keepalive:
|
|
152
|
-
// browsers cap keepalive bodies
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: { "Content-Type": "application/json" },
|
|
249
|
+
body: JSON.stringify(body),
|
|
250
|
+
keepalive: true
|
|
153
251
|
});
|
|
154
252
|
if (res.ok) return true;
|
|
155
|
-
if (res.status >= 400 && res.status < 500
|
|
156
|
-
logger.
|
|
253
|
+
if (res.status >= 400 && res.status < 500) {
|
|
254
|
+
logger.warn(`payout rejected with ${res.status}`);
|
|
157
255
|
return false;
|
|
158
256
|
}
|
|
159
257
|
} catch (err) {
|
|
160
|
-
logger.warn(`
|
|
258
|
+
logger.warn(`payout attempt ${attempt + 1} failed`, err);
|
|
161
259
|
}
|
|
162
|
-
|
|
163
|
-
const backoff = Math.min(15e3, 500 * 2 ** attempt) + Math.floor(Math.random() * 250);
|
|
164
|
-
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
260
|
+
await new Promise((resolve) => setTimeout(resolve, 400 + Math.floor(Math.random() * 200)));
|
|
165
261
|
}
|
|
166
262
|
return false;
|
|
167
263
|
}
|
|
264
|
+
async function postNoteOnce(apiUrl, sessionId, atMs, text, logger) {
|
|
265
|
+
try {
|
|
266
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/notes`, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: { "Content-Type": "application/json" },
|
|
269
|
+
body: JSON.stringify({ atMs: Math.max(0, Math.round(atMs)), text }),
|
|
270
|
+
keepalive: true
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok) {
|
|
273
|
+
logger.warn(`note POST rejected with ${res.status}`);
|
|
274
|
+
return { ok: false, transient: res.status >= 500 || res.status === 408 || res.status === 429 };
|
|
275
|
+
}
|
|
276
|
+
let id;
|
|
277
|
+
try {
|
|
278
|
+
const json = await res.json();
|
|
279
|
+
if (typeof json.id === "string") id = json.id;
|
|
280
|
+
} catch {
|
|
281
|
+
}
|
|
282
|
+
return { ok: true, id, transient: false };
|
|
283
|
+
} catch (err) {
|
|
284
|
+
logger.warn("note POST failed", err);
|
|
285
|
+
return { ok: false, transient: true };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async function postNoteWithRetry(apiUrl, sessionId, atMs, text, logger) {
|
|
289
|
+
const first = await postNoteOnce(apiUrl, sessionId, atMs, text, logger);
|
|
290
|
+
if (first.ok || !first.transient) return first;
|
|
291
|
+
await new Promise((resolve) => setTimeout(resolve, 400 + Math.floor(Math.random() * 200)));
|
|
292
|
+
return postNoteOnce(apiUrl, sessionId, atMs, text, logger);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/plugins/user-test/ui.ts
|
|
168
296
|
function buildIndicator(host, store, callbacks) {
|
|
169
|
-
const root = host.attachShadow({ mode: "
|
|
297
|
+
const root = host.attachShadow({ mode: "open" });
|
|
170
298
|
const style = document.createElement("style");
|
|
171
299
|
style.textContent = `
|
|
172
300
|
:host { all: initial; }
|
|
@@ -332,6 +460,28 @@ function buildIndicator(host, store, callbacks) {
|
|
|
332
460
|
to { opacity: 0; transform: translateY(4px); }
|
|
333
461
|
}
|
|
334
462
|
|
|
463
|
+
/* "Recording resumed" confirmation: same pill footprint as the mute toast,
|
|
464
|
+
but carries the live-record red accent (not the amber warning treatment)
|
|
465
|
+
so it reads as reassurance, not a problem. Compact, inline, auto-dismisses.
|
|
466
|
+
Leads with the same pulsing record dot used on the bar's mic chip. */
|
|
467
|
+
.resume-toast {
|
|
468
|
+
display: inline-flex; align-items: center; gap: 8px;
|
|
469
|
+
background: rgba(17,17,17,0.92);
|
|
470
|
+
border: 1px solid rgba(239, 68, 68, 0.42);
|
|
471
|
+
color: #fff; font-weight: 500; letter-spacing: 0.01em;
|
|
472
|
+
padding: 8px 13px; border-radius: 999px;
|
|
473
|
+
box-shadow: 0 12px 28px rgba(0,0,0,0.28);
|
|
474
|
+
white-space: nowrap;
|
|
475
|
+
animation: toast-in 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
476
|
+
}
|
|
477
|
+
.resume-toast[data-leaving="true"] { animation: toast-out 0.24s ease forwards; }
|
|
478
|
+
.resume-toast .dot {
|
|
479
|
+
width: 7px; height: 7px; border-radius: 50%;
|
|
480
|
+
background: #ef4444; flex-shrink: 0;
|
|
481
|
+
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6);
|
|
482
|
+
animation: pulse 1.6s ease-out infinite;
|
|
483
|
+
}
|
|
484
|
+
|
|
335
485
|
/* Notes popover */
|
|
336
486
|
.note-popover {
|
|
337
487
|
background: rgba(17,17,17,0.94);
|
|
@@ -434,6 +584,12 @@ function buildIndicator(host, store, callbacks) {
|
|
|
434
584
|
color: #ea580c;
|
|
435
585
|
}
|
|
436
586
|
.thanks .check.early svg { width: 24px; height: 24px; }
|
|
587
|
+
.thanks .check.ended {
|
|
588
|
+
background: #f5f5f4;
|
|
589
|
+
box-shadow: inset 0 0 0 1px rgba(120,113,108,0.20);
|
|
590
|
+
color: #78716c;
|
|
591
|
+
}
|
|
592
|
+
.thanks .check.ended svg { width: 24px; height: 24px; }
|
|
437
593
|
|
|
438
594
|
/* Verified-checks list (complete) / progress list (ended early) */
|
|
439
595
|
.thanks .checks {
|
|
@@ -605,7 +761,8 @@ function buildIndicator(host, store, callbacks) {
|
|
|
605
761
|
}
|
|
606
762
|
@media (prefers-reduced-motion: reduce) {
|
|
607
763
|
.dot { animation: none; }
|
|
608
|
-
.toast, .note-popover { animation: none; }
|
|
764
|
+
.toast, .note-popover, .resume-toast { animation: none; }
|
|
765
|
+
.resume-toast[data-leaving="true"] { opacity: 0; }
|
|
609
766
|
}
|
|
610
767
|
`;
|
|
611
768
|
const anchor = document.createElement("div");
|
|
@@ -670,13 +827,6 @@ function buildIndicator(host, store, callbacks) {
|
|
|
670
827
|
root.appendChild(anchor);
|
|
671
828
|
return root;
|
|
672
829
|
}
|
|
673
|
-
var MIC_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="13" height="13"><path d="M8 1.5a2 2 0 0 0-2 2v4a2 2 0 1 0 4 0v-4a2 2 0 0 0-2-2Z" fill="currentColor"/><path d="M4 7.5a4 4 0 0 0 8 0M8 11.5v3M5.5 14.5h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
|
|
674
|
-
var MIC_MUTED_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="13" height="13"><path d="M8 1.5a2 2 0 0 0-2 2v3.2L10 11V3.5a2 2 0 0 0-2-2Z" fill="currentColor"/><path d="M4 7.5a4 4 0 0 0 6.5 3.12M12 7.5a4 4 0 0 1-.3 1.5M8 11.5v3M5.5 14.5h5M2 2l12 12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
|
|
675
|
-
var NOTE_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path d="M3 3.5A1.5 1.5 0 0 1 4.5 2h7A1.5 1.5 0 0 1 13 3.5V10a1.5 1.5 0 0 1-1.5 1.5H7L4 14v-2.5h-.5A1.5 1.5 0 0 1 2 10V3.5A1.5 1.5 0 0 1 3.5 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
676
|
-
var TICK_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 12.5 10 17.5 19 7" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
677
|
-
var TICK_SM_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 8.5 6.5 11.5 12.5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
678
|
-
var CLOCK_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="8.4" stroke="currentColor" stroke-width="2"/><path d="M12 7.5V12l3 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
679
|
-
var SPARK_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 1.5 9.5 6.5 14.5 8 9.5 9.5 8 14.5 6.5 9.5 1.5 8 6.5 6.5Z" fill="currentColor"/></svg>`;
|
|
680
830
|
function installTasksToggle(bar, finishBtn, store, onToggleTasks) {
|
|
681
831
|
const tasksBtn = document.createElement("button");
|
|
682
832
|
tasksBtn.type = "button";
|
|
@@ -854,6 +1004,36 @@ function showMuteToast(store) {
|
|
|
854
1004
|
}, 3e3);
|
|
855
1005
|
store.muteToastTimers.push(outer);
|
|
856
1006
|
}
|
|
1007
|
+
function showResumedToast(store) {
|
|
1008
|
+
if (!store.resumed) return;
|
|
1009
|
+
store.resumed = false;
|
|
1010
|
+
if (!store.hasMicPermission || store.indicatorState === "no-audio") return;
|
|
1011
|
+
const root = store.indicatorRoot;
|
|
1012
|
+
if (!root) return;
|
|
1013
|
+
const slot = root.querySelector(".toast-slot");
|
|
1014
|
+
if (!(slot instanceof HTMLElement)) return;
|
|
1015
|
+
slot.innerHTML = "";
|
|
1016
|
+
const toast = document.createElement("div");
|
|
1017
|
+
toast.className = "resume-toast";
|
|
1018
|
+
toast.setAttribute("role", "status");
|
|
1019
|
+
const dot = document.createElement("span");
|
|
1020
|
+
dot.className = "dot";
|
|
1021
|
+
dot.setAttribute("aria-hidden", "true");
|
|
1022
|
+
const label = document.createElement("span");
|
|
1023
|
+
label.textContent = "Recording resumed";
|
|
1024
|
+
toast.appendChild(dot);
|
|
1025
|
+
toast.appendChild(label);
|
|
1026
|
+
slot.appendChild(toast);
|
|
1027
|
+
const outer = window.setTimeout(() => {
|
|
1028
|
+
if (!toast.isConnected) return;
|
|
1029
|
+
toast.setAttribute("data-leaving", "true");
|
|
1030
|
+
const inner = window.setTimeout(() => {
|
|
1031
|
+
if (toast.isConnected) toast.remove();
|
|
1032
|
+
}, 260);
|
|
1033
|
+
store.resumeToastTimers.push(inner);
|
|
1034
|
+
}, 3200);
|
|
1035
|
+
store.resumeToastTimers.push(outer);
|
|
1036
|
+
}
|
|
857
1037
|
function openNotePopover(store, onSave, onCancel) {
|
|
858
1038
|
const root = store.indicatorRoot;
|
|
859
1039
|
if (!root) return;
|
|
@@ -953,11 +1133,30 @@ function showThanksScreen(root, opts) {
|
|
|
953
1133
|
card.className = "thanks-card";
|
|
954
1134
|
overlay.appendChild(card);
|
|
955
1135
|
root.appendChild(overlay);
|
|
956
|
-
if (opts.payment && !opts.payment.qualified) {
|
|
957
|
-
renderEndedEarly(card, opts);
|
|
958
|
-
return;
|
|
959
|
-
}
|
|
960
|
-
renderComplete(card, opts);
|
|
1136
|
+
if (opts.payment && !opts.payment.qualified) {
|
|
1137
|
+
renderEndedEarly(card, opts);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
renderComplete(card, opts);
|
|
1141
|
+
}
|
|
1142
|
+
function showSessionEndedScreen(root) {
|
|
1143
|
+
if (root.querySelector(".thanks")) return;
|
|
1144
|
+
const overlay = document.createElement("div");
|
|
1145
|
+
overlay.className = "thanks";
|
|
1146
|
+
overlay.setAttribute("role", "dialog");
|
|
1147
|
+
overlay.setAttribute("aria-modal", "true");
|
|
1148
|
+
const card = document.createElement("div");
|
|
1149
|
+
card.className = "thanks-card";
|
|
1150
|
+
const head = document.createElement("div");
|
|
1151
|
+
head.className = "head";
|
|
1152
|
+
head.innerHTML = `
|
|
1153
|
+
<div class="check ended" aria-hidden="true">${FLAG_ICON_SVG}</div>
|
|
1154
|
+
<h2>This test session ended</h2>
|
|
1155
|
+
<p class="lede">Thanks for taking part. Your earlier responses were saved. You can close this tab.</p>
|
|
1156
|
+
`;
|
|
1157
|
+
card.appendChild(head);
|
|
1158
|
+
overlay.appendChild(card);
|
|
1159
|
+
root.appendChild(overlay);
|
|
961
1160
|
}
|
|
962
1161
|
function checksList(rows) {
|
|
963
1162
|
const items = rows.map((r) => {
|
|
@@ -1208,166 +1407,173 @@ function appendNoteSection(card, opts, prompt) {
|
|
|
1208
1407
|
ta.focus({ preventScroll: true });
|
|
1209
1408
|
});
|
|
1210
1409
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
if (!t || typeof t.id !== "string" || typeof t.prompt !== "string" || typeof t.sortOrder !== "number") return [];
|
|
1216
|
-
return [{ id: t.id, prompt: t.prompt, sortOrder: t.sortOrder }];
|
|
1217
|
-
});
|
|
1218
|
-
out.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
1219
|
-
return out;
|
|
1220
|
-
}
|
|
1221
|
-
async function createSession(apiUrl, slug, testerName) {
|
|
1222
|
-
try {
|
|
1223
|
-
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions`, {
|
|
1224
|
-
method: "POST",
|
|
1225
|
-
headers: { "Content-Type": "application/json" },
|
|
1226
|
-
body: JSON.stringify({ slug, ...testerName ? { testerName } : {} })
|
|
1227
|
-
});
|
|
1228
|
-
if (!res.ok) return null;
|
|
1229
|
-
const json = await res.json();
|
|
1230
|
-
if (typeof json.sessionId !== "string" || typeof json.clientId !== "string") return null;
|
|
1231
|
-
return { sessionId: json.sessionId, clientId: json.clientId, tasks: parseTasks(json.tasks) };
|
|
1232
|
-
} catch {
|
|
1233
|
-
return null;
|
|
1234
|
-
}
|
|
1410
|
+
|
|
1411
|
+
// src/plugins/user-test/recorder.ts
|
|
1412
|
+
function isMediaRecorderSupported() {
|
|
1413
|
+
return typeof window !== "undefined" && typeof window.MediaRecorder !== "undefined" && typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia;
|
|
1235
1414
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
const json = await res.json();
|
|
1243
|
-
if (typeof json.sessionId !== "string" || typeof json.clientId !== "string") return null;
|
|
1244
|
-
return { sessionId: json.sessionId, clientId: json.clientId, tasks: parseTasks(json.tasks) };
|
|
1245
|
-
} catch {
|
|
1246
|
-
return null;
|
|
1415
|
+
function pickMimeType() {
|
|
1416
|
+
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4"];
|
|
1417
|
+
for (const candidate of candidates) {
|
|
1418
|
+
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported?.(candidate)) {
|
|
1419
|
+
return candidate;
|
|
1420
|
+
}
|
|
1247
1421
|
}
|
|
1422
|
+
return void 0;
|
|
1248
1423
|
}
|
|
1249
|
-
function
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
qualified: p.qualified,
|
|
1255
|
-
reward: typeof p.reward === "string" ? p.reward : null,
|
|
1256
|
-
payoutEmail: typeof p.payoutEmail === "string" ? p.payoutEmail : null,
|
|
1257
|
-
tasksDone: typeof p.tasksDone === "number" ? p.tasksDone : 0,
|
|
1258
|
-
tasksTotal: typeof p.tasksTotal === "number" ? p.tasksTotal : 0
|
|
1259
|
-
};
|
|
1260
|
-
}
|
|
1261
|
-
async function finaliseSession(apiUrl, sessionId, durationSeconds, extras = {}) {
|
|
1262
|
-
try {
|
|
1263
|
-
const body = {
|
|
1264
|
-
durationSeconds: Math.max(0, Math.round(durationSeconds))
|
|
1265
|
-
};
|
|
1266
|
-
if (extras.mutedSegments && extras.mutedSegments.length > 0) {
|
|
1267
|
-
body.mutedSegments = extras.mutedSegments;
|
|
1424
|
+
function idbOpen() {
|
|
1425
|
+
return new Promise((resolve) => {
|
|
1426
|
+
if (typeof indexedDB === "undefined") {
|
|
1427
|
+
resolve(null);
|
|
1428
|
+
return;
|
|
1268
1429
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1430
|
+
try {
|
|
1431
|
+
const req = indexedDB.open(IDB_NAME, 1);
|
|
1432
|
+
req.onupgradeneeded = () => {
|
|
1433
|
+
const db = req.result;
|
|
1434
|
+
if (!db.objectStoreNames.contains(IDB_STORE)) {
|
|
1435
|
+
db.createObjectStore(IDB_STORE, { keyPath: "id" });
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
req.onsuccess = () => resolve(req.result);
|
|
1439
|
+
req.onerror = () => resolve(null);
|
|
1440
|
+
} catch {
|
|
1441
|
+
resolve(null);
|
|
1276
1442
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
async function idbStashChunk(chunk) {
|
|
1446
|
+
const db = await idbOpen();
|
|
1447
|
+
if (!db) return;
|
|
1448
|
+
await new Promise((resolve) => {
|
|
1449
|
+
try {
|
|
1450
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
1451
|
+
tx.objectStore(IDB_STORE).put(chunk);
|
|
1452
|
+
tx.oncomplete = () => resolve();
|
|
1453
|
+
tx.onerror = () => resolve();
|
|
1454
|
+
tx.onabort = () => resolve();
|
|
1455
|
+
} catch {
|
|
1456
|
+
resolve();
|
|
1280
1457
|
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
let payment = null;
|
|
1458
|
+
});
|
|
1459
|
+
db.close();
|
|
1460
|
+
}
|
|
1461
|
+
async function idbDeleteChunk(id) {
|
|
1462
|
+
const db = await idbOpen();
|
|
1463
|
+
if (!db) return;
|
|
1464
|
+
await new Promise((resolve) => {
|
|
1289
1465
|
try {
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1466
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
1467
|
+
tx.objectStore(IDB_STORE).delete(id);
|
|
1468
|
+
tx.oncomplete = () => resolve();
|
|
1469
|
+
tx.onerror = () => resolve();
|
|
1470
|
+
tx.onabort = () => resolve();
|
|
1292
1471
|
} catch {
|
|
1472
|
+
resolve();
|
|
1293
1473
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1474
|
+
});
|
|
1475
|
+
db.close();
|
|
1476
|
+
}
|
|
1477
|
+
async function idbListChunks(sessionId) {
|
|
1478
|
+
const db = await idbOpen();
|
|
1479
|
+
if (!db) return [];
|
|
1480
|
+
const items = await new Promise((resolve) => {
|
|
1481
|
+
try {
|
|
1482
|
+
const tx = db.transaction(IDB_STORE, "readonly");
|
|
1483
|
+
const req = tx.objectStore(IDB_STORE).getAll();
|
|
1484
|
+
req.onsuccess = () => {
|
|
1485
|
+
const all = req.result ?? [];
|
|
1486
|
+
resolve(all.filter((c) => c.sessionId === sessionId));
|
|
1487
|
+
};
|
|
1488
|
+
req.onerror = () => resolve([]);
|
|
1489
|
+
} catch {
|
|
1490
|
+
resolve([]);
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
db.close();
|
|
1494
|
+
return items;
|
|
1495
|
+
}
|
|
1496
|
+
function classifyChunkResponse(status, body) {
|
|
1497
|
+
if (status >= 200 && status < 300) return "ok";
|
|
1498
|
+
if (status === 409 && typeof body === "object" && body !== null && body.closeResume === true) {
|
|
1499
|
+
return "closed";
|
|
1297
1500
|
}
|
|
1501
|
+
if (status >= 500 || status === 408 || status === 429) return "retry";
|
|
1502
|
+
if (status >= 400) return "fatal";
|
|
1503
|
+
return "retry";
|
|
1298
1504
|
}
|
|
1299
|
-
async function
|
|
1300
|
-
const url = `${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1505
|
+
async function uploadChunkWithRetry(apiUrl, sessionId, index, blob, logger, maxAttempts = 5) {
|
|
1506
|
+
const url = `${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/chunk?index=${index}`;
|
|
1507
|
+
let attempt = 0;
|
|
1508
|
+
while (attempt < maxAttempts) {
|
|
1304
1509
|
try {
|
|
1305
1510
|
const res = await fetch(url, {
|
|
1306
|
-
method: "
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
keepalive:
|
|
1511
|
+
method: "PUT",
|
|
1512
|
+
body: blob,
|
|
1513
|
+
headers: { "Content-Type": blob.type || "audio/webm" },
|
|
1514
|
+
keepalive: blob.size <= 60 * 1024
|
|
1515
|
+
// browsers cap keepalive bodies
|
|
1310
1516
|
});
|
|
1311
|
-
if (res.ok) return
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1517
|
+
if (res.ok) return "ok";
|
|
1518
|
+
let body = null;
|
|
1519
|
+
try {
|
|
1520
|
+
body = await res.json();
|
|
1521
|
+
} catch {
|
|
1522
|
+
body = null;
|
|
1523
|
+
}
|
|
1524
|
+
const klass = classifyChunkResponse(res.status, body);
|
|
1525
|
+
if (klass === "closed") {
|
|
1526
|
+
logger.info(`chunk ${index}: server reports session closed; stopping`);
|
|
1527
|
+
return "closed";
|
|
1528
|
+
}
|
|
1529
|
+
if (klass === "fatal") {
|
|
1530
|
+
logger.error(`chunk ${index} rejected with ${res.status}`);
|
|
1531
|
+
return "failed";
|
|
1315
1532
|
}
|
|
1316
1533
|
} catch (err) {
|
|
1317
|
-
logger.warn(`
|
|
1318
|
-
}
|
|
1319
|
-
await new Promise((resolve) => setTimeout(resolve, 400 + Math.floor(Math.random() * 200)));
|
|
1320
|
-
}
|
|
1321
|
-
return false;
|
|
1322
|
-
}
|
|
1323
|
-
async function postNoteOnce(apiUrl, sessionId, atMs, text, logger) {
|
|
1324
|
-
try {
|
|
1325
|
-
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/notes`, {
|
|
1326
|
-
method: "POST",
|
|
1327
|
-
headers: { "Content-Type": "application/json" },
|
|
1328
|
-
body: JSON.stringify({ atMs: Math.max(0, Math.round(atMs)), text }),
|
|
1329
|
-
keepalive: true
|
|
1330
|
-
});
|
|
1331
|
-
if (!res.ok) {
|
|
1332
|
-
logger.warn(`note POST rejected with ${res.status}`);
|
|
1333
|
-
return { ok: false, transient: res.status >= 500 || res.status === 408 || res.status === 429 };
|
|
1334
|
-
}
|
|
1335
|
-
let id;
|
|
1336
|
-
try {
|
|
1337
|
-
const json = await res.json();
|
|
1338
|
-
if (typeof json.id === "string") id = json.id;
|
|
1339
|
-
} catch {
|
|
1534
|
+
logger.warn(`chunk ${index} upload attempt ${attempt + 1} failed`, err);
|
|
1340
1535
|
}
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
return { ok: false, transient: true };
|
|
1536
|
+
attempt += 1;
|
|
1537
|
+
const backoff = Math.min(15e3, 500 * 2 ** attempt) + Math.floor(Math.random() * 250);
|
|
1538
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
1345
1539
|
}
|
|
1346
|
-
|
|
1347
|
-
async function postNoteWithRetry(apiUrl, sessionId, atMs, text, logger) {
|
|
1348
|
-
const first = await postNoteOnce(apiUrl, sessionId, atMs, text, logger);
|
|
1349
|
-
if (first.ok || !first.transient) return first;
|
|
1350
|
-
await new Promise((resolve) => setTimeout(resolve, 400 + Math.floor(Math.random() * 200)));
|
|
1351
|
-
return postNoteOnce(apiUrl, sessionId, atMs, text, logger);
|
|
1540
|
+
return "failed";
|
|
1352
1541
|
}
|
|
1353
1542
|
async function flushPendingFromIdb(store, ctx) {
|
|
1354
1543
|
if (!store.sessionId) return;
|
|
1355
1544
|
const pending = await idbListChunks(store.sessionId);
|
|
1356
1545
|
for (const chunk of pending) {
|
|
1357
|
-
const
|
|
1358
|
-
if (ok)
|
|
1546
|
+
const outcome = await uploadChunkWithRetry(chunk.apiUrl, chunk.sessionId, chunk.chunkIndex, chunk.blob, ctx.logger, 3);
|
|
1547
|
+
if (outcome === "ok") {
|
|
1548
|
+
await idbDeleteChunk(chunk.id);
|
|
1549
|
+
} else if (outcome === "closed") {
|
|
1550
|
+
handleSessionClosed(store);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1359
1553
|
}
|
|
1360
1554
|
}
|
|
1555
|
+
function handleSessionClosed(store) {
|
|
1556
|
+
if (store.sessionClosed) return;
|
|
1557
|
+
store.sessionClosed = true;
|
|
1558
|
+
store.onSessionClosed?.();
|
|
1559
|
+
}
|
|
1361
1560
|
function enqueueChunk(store, ctx, blob) {
|
|
1362
|
-
if (store.cancelled || !store.sessionId || blob.size === 0) return;
|
|
1561
|
+
if (store.cancelled || store.sessionClosed || !store.sessionId || blob.size === 0) return;
|
|
1363
1562
|
const index = store.chunkIndex;
|
|
1364
1563
|
store.chunkIndex += 1;
|
|
1564
|
+
persistActiveSession(store, store.paused || store.finishFlowRan ? "paused" : "active");
|
|
1365
1565
|
store.pendingUploads += 1;
|
|
1366
1566
|
const sessionId = store.sessionId;
|
|
1367
1567
|
const apiUrl = store.options.apiUrl;
|
|
1368
1568
|
store.uploadQueue = store.uploadQueue.then(async () => {
|
|
1369
|
-
|
|
1370
|
-
|
|
1569
|
+
if (store.sessionClosed) {
|
|
1570
|
+
store.pendingUploads -= 1;
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
const outcome = await uploadChunkWithRetry(apiUrl, sessionId, index, blob, ctx.logger);
|
|
1574
|
+
if (outcome === "closed") {
|
|
1575
|
+
handleSessionClosed(store);
|
|
1576
|
+
} else if (outcome === "failed") {
|
|
1371
1577
|
ctx.logger.warn(`chunk ${index} stashed for offline retry`);
|
|
1372
1578
|
await idbStashChunk({
|
|
1373
1579
|
id: `${sessionId}:${index}:${Date.now()}`,
|
|
@@ -1381,10 +1587,6 @@ function enqueueChunk(store, ctx, blob) {
|
|
|
1381
1587
|
store.pendingUploads -= 1;
|
|
1382
1588
|
});
|
|
1383
1589
|
}
|
|
1384
|
-
var SILENCE_RMS_DB_THRESHOLD = -60;
|
|
1385
|
-
var SILENCE_FLOOR_DB = -100;
|
|
1386
|
-
var SILENCE_SUSTAINED_MS = 1800;
|
|
1387
|
-
var SILENCE_POLL_MS = 250;
|
|
1388
1590
|
function rmsDbFromSamples(samples) {
|
|
1389
1591
|
const n = samples.length;
|
|
1390
1592
|
if (n === 0) return SILENCE_FLOOR_DB;
|
|
@@ -1584,6 +1786,17 @@ function stopRecording(store) {
|
|
|
1584
1786
|
store.stream = null;
|
|
1585
1787
|
}
|
|
1586
1788
|
}
|
|
1789
|
+
|
|
1790
|
+
// src/plugins/user-test/lifecycle.ts
|
|
1791
|
+
function pauseFlow(store) {
|
|
1792
|
+
if (store.cancelled) return;
|
|
1793
|
+
if (store.finishFlowRan || store.indicatorState === "finishing" || store.indicatorState === "done") return;
|
|
1794
|
+
if (!store.sessionId) return;
|
|
1795
|
+
store.paused = true;
|
|
1796
|
+
flushMuteIfActive(store);
|
|
1797
|
+
stopRecording(store);
|
|
1798
|
+
persistActiveSession(store, "paused");
|
|
1799
|
+
}
|
|
1587
1800
|
async function finishFlow(store, ctx, opts) {
|
|
1588
1801
|
if (store.cancelled) return;
|
|
1589
1802
|
if (store.finishFlowRan) return;
|
|
@@ -1617,11 +1830,12 @@ async function finishFlow(store, ctx, opts) {
|
|
|
1617
1830
|
}
|
|
1618
1831
|
}
|
|
1619
1832
|
store.indicatorState = result.ok ? "done" : "error";
|
|
1833
|
+
if (result.ok) clearActiveSession();
|
|
1620
1834
|
} else {
|
|
1621
1835
|
store.indicatorState = "error";
|
|
1622
1836
|
}
|
|
1623
1837
|
renderIndicatorState(store);
|
|
1624
|
-
if (
|
|
1838
|
+
if (store.indicatorRoot && store.indicatorState === "done") {
|
|
1625
1839
|
showThanksScreen(store.indicatorRoot, {
|
|
1626
1840
|
payment,
|
|
1627
1841
|
onPayout: async (destination) => {
|
|
@@ -1634,6 +1848,7 @@ async function finishFlow(store, ctx, opts) {
|
|
|
1634
1848
|
store.startedAt = Date.now();
|
|
1635
1849
|
store.muted = false;
|
|
1636
1850
|
store.mutedSinceMs = null;
|
|
1851
|
+
persistActiveSession(store, "active");
|
|
1637
1852
|
renderIndicatorState(store);
|
|
1638
1853
|
void startRecording(store, ctx);
|
|
1639
1854
|
},
|
|
@@ -1656,6 +1871,8 @@ async function finishFlow(store, ctx, opts) {
|
|
|
1656
1871
|
});
|
|
1657
1872
|
}
|
|
1658
1873
|
}
|
|
1874
|
+
|
|
1875
|
+
// src/plugins/user-test.ts
|
|
1659
1876
|
function userTest(options = {}) {
|
|
1660
1877
|
const merged = {
|
|
1661
1878
|
queryParam: options.queryParam ?? DEFAULT_OPTIONS.queryParam,
|
|
@@ -1668,8 +1885,18 @@ function userTest(options = {}) {
|
|
|
1668
1885
|
name: "user-test",
|
|
1669
1886
|
onInit(ctx) {
|
|
1670
1887
|
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
1671
|
-
const
|
|
1888
|
+
const urlSlug = getTestSlug(merged.queryParam);
|
|
1889
|
+
const resumeState = readActiveSession();
|
|
1890
|
+
const resumable = resumeState && (!urlSlug || urlSlug === resumeState.slug) ? resumeState : null;
|
|
1891
|
+
if (resumeState && !resumable) {
|
|
1892
|
+
clearActiveSession();
|
|
1893
|
+
}
|
|
1894
|
+
const slug = resumable?.slug ?? urlSlug;
|
|
1672
1895
|
if (!slug) return;
|
|
1896
|
+
const isResume = resumable !== null;
|
|
1897
|
+
if (isResume && resumable?.sdkSessionId && ctx.reseatSdkSessionId) {
|
|
1898
|
+
ctx.reseatSdkSessionId(resumable.sdkSessionId);
|
|
1899
|
+
}
|
|
1673
1900
|
const apiUrl = merged.apiUrl || ctx.baseUrl || DEFAULT_API_URL;
|
|
1674
1901
|
const store = {
|
|
1675
1902
|
cancelled: false,
|
|
@@ -1678,10 +1905,14 @@ function userTest(options = {}) {
|
|
|
1678
1905
|
clientId: null,
|
|
1679
1906
|
recorder: null,
|
|
1680
1907
|
stream: null,
|
|
1681
|
-
|
|
1908
|
+
// On resume, continue the chunk index so we never overwrite a chunk
|
|
1909
|
+
// already shipped in the pre-navigation leg.
|
|
1910
|
+
chunkIndex: resumable?.nextChunkIndex ?? 0,
|
|
1682
1911
|
uploadQueue: Promise.resolve(),
|
|
1683
1912
|
pendingUploads: 0,
|
|
1684
|
-
|
|
1913
|
+
// On resume, keep the ORIGINAL session start so duration + staleness
|
|
1914
|
+
// stay anchored to when the test actually began, not the return moment.
|
|
1915
|
+
startedAt: resumable?.startedAt ?? Date.now(),
|
|
1685
1916
|
indicator: null,
|
|
1686
1917
|
indicatorRoot: null,
|
|
1687
1918
|
indicatorState: "recording",
|
|
@@ -1702,16 +1933,32 @@ function userTest(options = {}) {
|
|
|
1702
1933
|
silenceMonitor: null,
|
|
1703
1934
|
muteToastShown: false,
|
|
1704
1935
|
muteToastTimers: [],
|
|
1936
|
+
resumeToastTimers: [],
|
|
1705
1937
|
notes: [],
|
|
1706
1938
|
notesPopoverOpen: false,
|
|
1707
1939
|
notePopoverAtMs: null,
|
|
1708
1940
|
endNote: "",
|
|
1709
1941
|
finishFlowRan: false,
|
|
1942
|
+
sessionClosed: false,
|
|
1943
|
+
onSessionClosed: null,
|
|
1944
|
+
paused: false,
|
|
1945
|
+
resumed: isResume,
|
|
1946
|
+
sdkSessionId: null,
|
|
1710
1947
|
replayOffsetAtStartMs: null
|
|
1711
1948
|
};
|
|
1712
1949
|
ctx.setStore(store);
|
|
1950
|
+
store.onSessionClosed = () => {
|
|
1951
|
+
if (store.cancelled) return;
|
|
1952
|
+
ctx.logger.info("user-test session closed by server during upload; stopping recording");
|
|
1953
|
+
stopRecording(store);
|
|
1954
|
+
clearActiveSession();
|
|
1955
|
+
store.indicatorState = "done";
|
|
1956
|
+
if (store.indicatorRoot && !merged.hideIndicator) {
|
|
1957
|
+
showSessionEndedScreen(store.indicatorRoot);
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1713
1960
|
const onFinish = () => {
|
|
1714
|
-
void finishFlow(store, ctx
|
|
1961
|
+
void finishFlow(store, ctx);
|
|
1715
1962
|
};
|
|
1716
1963
|
const setPanelOpen = (open) => {
|
|
1717
1964
|
if (store.tasksPanelOpen === open) return;
|
|
@@ -1799,28 +2046,52 @@ function userTest(options = {}) {
|
|
|
1799
2046
|
document.addEventListener("pointerdown", outsidePointer, true);
|
|
1800
2047
|
document.addEventListener("keydown", onKeydown);
|
|
1801
2048
|
const pageHide = () => {
|
|
1802
|
-
|
|
2049
|
+
pauseFlow(store);
|
|
1803
2050
|
};
|
|
1804
2051
|
store.pageHideHandler = pageHide;
|
|
1805
2052
|
window.addEventListener("pagehide", pageHide);
|
|
1806
2053
|
const onVisibilityChange = () => {
|
|
1807
2054
|
if (document.visibilityState !== "hidden") return;
|
|
1808
|
-
|
|
2055
|
+
pauseFlow(store);
|
|
1809
2056
|
};
|
|
1810
2057
|
store.visibilityHandler = onVisibilityChange;
|
|
1811
2058
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
1812
2059
|
void (async () => {
|
|
1813
|
-
const adoptId = getAdoptSessionId();
|
|
1814
|
-
|
|
1815
|
-
if (
|
|
2060
|
+
const adoptId = resumable?.sessionId ?? getAdoptSessionId();
|
|
2061
|
+
let created;
|
|
2062
|
+
if (adoptId) {
|
|
2063
|
+
const adopted = await adoptSession(apiUrl, adoptId);
|
|
2064
|
+
if (store.cancelled) return;
|
|
2065
|
+
if (adopted.kind === "closed") {
|
|
2066
|
+
ctx.logger.info("user-test session already closed on adopt; not resuming");
|
|
2067
|
+
clearActiveSession();
|
|
2068
|
+
if (store.indicatorRoot && !merged.hideIndicator) {
|
|
2069
|
+
showSessionEndedScreen(store.indicatorRoot);
|
|
2070
|
+
}
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
if (adopted.kind === "error") {
|
|
2074
|
+
ctx.logger.warn("user-test adopt failed transiently on resume; keeping resume state for retry");
|
|
2075
|
+
store.indicatorState = "error";
|
|
2076
|
+
renderIndicatorState(store);
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
created = adopted.kind === "ok" ? adopted : null;
|
|
2080
|
+
} else {
|
|
2081
|
+
created = await createSession(apiUrl, slug, readTesterName(merged.testerName));
|
|
2082
|
+
if (store.cancelled) return;
|
|
2083
|
+
}
|
|
1816
2084
|
if (!created) {
|
|
1817
2085
|
ctx.logger.error(adoptId ? "failed to adopt user-test session" : "failed to create user-test session");
|
|
2086
|
+
if (isResume) clearActiveSession();
|
|
1818
2087
|
store.indicatorState = "error";
|
|
1819
2088
|
renderIndicatorState(store);
|
|
1820
2089
|
return;
|
|
1821
2090
|
}
|
|
1822
2091
|
store.sessionId = created.sessionId;
|
|
1823
2092
|
store.clientId = created.clientId;
|
|
2093
|
+
store.sdkSessionId = ctx.getSdkSessionId ? ctx.getSdkSessionId() : null;
|
|
2094
|
+
persistActiveSession(store, "active");
|
|
1824
2095
|
const replayStartMs = ctx.getReplayStartMs ? ctx.getReplayStartMs() : null;
|
|
1825
2096
|
store.replayOffsetAtStartMs = replayStartMs === null ? null : Math.max(0, store.startedAt - replayStartMs);
|
|
1826
2097
|
store.tasks = created.tasks;
|
|
@@ -1834,6 +2105,7 @@ function userTest(options = {}) {
|
|
|
1834
2105
|
}
|
|
1835
2106
|
await startRecording(store, ctx);
|
|
1836
2107
|
renderIndicatorState(store);
|
|
2108
|
+
showResumedToast(store);
|
|
1837
2109
|
})();
|
|
1838
2110
|
},
|
|
1839
2111
|
onDestroy(ctx) {
|
|
@@ -1864,6 +2136,13 @@ function userTest(options = {}) {
|
|
|
1864
2136
|
}
|
|
1865
2137
|
}
|
|
1866
2138
|
store.muteToastTimers = [];
|
|
2139
|
+
for (const id of store.resumeToastTimers) {
|
|
2140
|
+
try {
|
|
2141
|
+
window.clearTimeout(id);
|
|
2142
|
+
} catch {
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
store.resumeToastTimers = [];
|
|
1867
2146
|
if (store.indicator && store.indicator.parentNode) {
|
|
1868
2147
|
store.indicator.parentNode.removeChild(store.indicator);
|
|
1869
2148
|
}
|
|
@@ -1876,11 +2155,21 @@ var __test__ = {
|
|
|
1876
2155
|
getTestSlug,
|
|
1877
2156
|
pickMimeType,
|
|
1878
2157
|
isMediaRecorderSupported,
|
|
2158
|
+
classifyChunkResponse,
|
|
2159
|
+
handleSessionClosed,
|
|
1879
2160
|
micChipState,
|
|
1880
2161
|
isStreamSilent,
|
|
1881
2162
|
rmsDbFromSamples,
|
|
1882
2163
|
SILENCE_RMS_DB_THRESHOLD,
|
|
1883
|
-
SILENCE_FLOOR_DB
|
|
2164
|
+
SILENCE_FLOOR_DB,
|
|
2165
|
+
parseActiveSession,
|
|
2166
|
+
readActiveSession,
|
|
2167
|
+
clearActiveSession,
|
|
2168
|
+
persistActiveSession,
|
|
2169
|
+
adoptSession,
|
|
2170
|
+
ACTIVE_SESSION_MAX_AGE_MS,
|
|
2171
|
+
RESUME_MAX_IDLE_MS,
|
|
2172
|
+
ACTIVE_SESSION_STORAGE_KEY
|
|
1884
2173
|
};
|
|
1885
2174
|
|
|
1886
2175
|
exports.__test__ = __test__;
|