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