botprint 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PRIVACY.md +94 -0
- package/README.md +221 -0
- package/docs/agent-usage.md +109 -0
- package/package.json +56 -0
- package/src/cdp.ts +267 -0
- package/src/cli.ts +758 -0
- package/src/coerce.ts +19 -0
- package/src/compare.ts +363 -0
- package/src/errors.ts +15 -0
- package/src/exit-codes.ts +10 -0
- package/src/findings.ts +342 -0
- package/src/guide.ts +152 -0
- package/src/probe.ts +700 -0
- package/src/profile.ts +188 -0
- package/src/render.ts +268 -0
- package/src/run.ts +432 -0
- package/src/user-agent.ts +50 -0
- package/src/version.ts +1 -0
package/src/probe.ts
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import { EXIT_CODES } from "./exit-codes";
|
|
2
|
+
import { BotprintError } from "./errors";
|
|
3
|
+
import type { BrowserVersion, CdpClient } from "./cdp";
|
|
4
|
+
import { booleanOrNull, isObject, numberValue, stringArray, stringValue } from "./coerce";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 2_000;
|
|
7
|
+
export const SPEECH_VOICES_WAIT_MS = 500;
|
|
8
|
+
|
|
9
|
+
// Fonts are checked by name against three generic fallback families; a font counts as
|
|
10
|
+
// detected when its rendered width differs from at least one fallback. The list mixes common
|
|
11
|
+
// Windows, macOS, and Linux faces so local-vs-Docker drift shows up as a changed set.
|
|
12
|
+
export const FONT_CHECK_LIST = [
|
|
13
|
+
"Arial",
|
|
14
|
+
"Arial Black",
|
|
15
|
+
"Calibri",
|
|
16
|
+
"Cambria",
|
|
17
|
+
"Cantarell",
|
|
18
|
+
"Comic Sans MS",
|
|
19
|
+
"Consolas",
|
|
20
|
+
"Courier New",
|
|
21
|
+
"DejaVu Sans",
|
|
22
|
+
"DejaVu Serif",
|
|
23
|
+
"Fira Sans",
|
|
24
|
+
"Georgia",
|
|
25
|
+
"Helvetica",
|
|
26
|
+
"Helvetica Neue",
|
|
27
|
+
"Impact",
|
|
28
|
+
"Inter",
|
|
29
|
+
"Liberation Sans",
|
|
30
|
+
"Liberation Serif",
|
|
31
|
+
"Lucida Grande",
|
|
32
|
+
"Menlo",
|
|
33
|
+
"Monaco",
|
|
34
|
+
"Noto Sans",
|
|
35
|
+
"Noto Serif",
|
|
36
|
+
"Open Sans",
|
|
37
|
+
"Roboto",
|
|
38
|
+
"Segoe UI",
|
|
39
|
+
"Tahoma",
|
|
40
|
+
"Times New Roman",
|
|
41
|
+
"Trebuchet MS",
|
|
42
|
+
"Ubuntu",
|
|
43
|
+
"Verdana",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function buildProbeExpression(timeoutMs: number): string {
|
|
47
|
+
const timeout = Number.isSafeInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_PROBE_TIMEOUT_MS;
|
|
48
|
+
return `(() => {
|
|
49
|
+
const PROBE_TIMEOUT_MS = ${timeout};
|
|
50
|
+
const SPEECH_VOICES_WAIT_MS = ${SPEECH_VOICES_WAIT_MS};
|
|
51
|
+
const FONT_LIST = ${JSON.stringify(FONT_CHECK_LIST)};
|
|
52
|
+
|
|
53
|
+
const availability = (fn) => {
|
|
54
|
+
try {
|
|
55
|
+
return fn() ? "available" : "unavailable";
|
|
56
|
+
} catch {
|
|
57
|
+
return "error";
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const withTimeout = (promise, onTimeout) => new Promise((resolve) => {
|
|
62
|
+
let settled = false;
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
if (!settled) {
|
|
65
|
+
settled = true;
|
|
66
|
+
resolve(onTimeout());
|
|
67
|
+
}
|
|
68
|
+
}, PROBE_TIMEOUT_MS);
|
|
69
|
+
promise.then(
|
|
70
|
+
(value) => {
|
|
71
|
+
if (!settled) {
|
|
72
|
+
settled = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
resolve(value);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
() => {
|
|
78
|
+
if (!settled) {
|
|
79
|
+
settled = true;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
resolve(onTimeout("error"));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const hashString = (value) => {
|
|
88
|
+
let hash = 5381;
|
|
89
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
90
|
+
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
|
|
91
|
+
}
|
|
92
|
+
return (hash >>> 0).toString(16);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const storageAvailable = (kind) => availability(() => {
|
|
96
|
+
const storage = window[kind];
|
|
97
|
+
const key = "__botprint_probe__";
|
|
98
|
+
storage.setItem(key, "1");
|
|
99
|
+
storage.removeItem(key);
|
|
100
|
+
return true;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const indexedDbAvailable = () => {
|
|
104
|
+
if (!("indexedDB" in window)) {
|
|
105
|
+
return Promise.resolve("unavailable");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const open = new Promise((resolve, reject) => {
|
|
109
|
+
try {
|
|
110
|
+
const request = indexedDB.open("__botprint_probe__", 1);
|
|
111
|
+
request.onerror = () => reject(new Error("open failed"));
|
|
112
|
+
request.onblocked = () => reject(new Error("open blocked"));
|
|
113
|
+
request.onsuccess = () => {
|
|
114
|
+
request.result.close();
|
|
115
|
+
indexedDB.deleteDatabase("__botprint_probe__");
|
|
116
|
+
resolve("available");
|
|
117
|
+
};
|
|
118
|
+
} catch (error) {
|
|
119
|
+
reject(error);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return withTimeout(open, (reason) => reason === "error" ? "error" : "timeout");
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const queryPermission = async (name) => {
|
|
127
|
+
if (!navigator.permissions || typeof navigator.permissions.query !== "function") {
|
|
128
|
+
return "unsupported";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const result = await navigator.permissions.query({ name });
|
|
133
|
+
return typeof result.state === "string" ? result.state : "unknown";
|
|
134
|
+
} catch {
|
|
135
|
+
return "error";
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const notificationApiPermission = () => {
|
|
140
|
+
if (typeof Notification === "undefined" || typeof Notification.permission !== "string") {
|
|
141
|
+
return "unsupported";
|
|
142
|
+
}
|
|
143
|
+
return Notification.permission === "default" ? "prompt" : Notification.permission;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const webglSignals = () => {
|
|
147
|
+
try {
|
|
148
|
+
const canvas = document.createElement("canvas");
|
|
149
|
+
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
|
150
|
+
if (!gl) {
|
|
151
|
+
return { status: "unavailable" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
|
|
155
|
+
const out = {
|
|
156
|
+
status: "available",
|
|
157
|
+
vendor: String(gl.getParameter(gl.VENDOR) || ""),
|
|
158
|
+
renderer: String(gl.getParameter(gl.RENDERER) || "")
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (debugInfo) {
|
|
162
|
+
out.unmaskedVendor = String(gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) || "");
|
|
163
|
+
out.unmaskedRenderer = String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || "");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return out;
|
|
167
|
+
} catch {
|
|
168
|
+
return { status: "error" };
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const canvasSignals = () => {
|
|
173
|
+
try {
|
|
174
|
+
const draw = () => {
|
|
175
|
+
const canvas = document.createElement("canvas");
|
|
176
|
+
canvas.width = 240;
|
|
177
|
+
canvas.height = 80;
|
|
178
|
+
const ctx = canvas.getContext("2d");
|
|
179
|
+
if (!ctx) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
ctx.textBaseline = "top";
|
|
184
|
+
ctx.fillStyle = "#f60";
|
|
185
|
+
ctx.fillRect(8, 8, 96, 32);
|
|
186
|
+
ctx.fillStyle = "#069";
|
|
187
|
+
ctx.font = "16px Arial";
|
|
188
|
+
ctx.fillText("Botprint canvas sample", 12, 16);
|
|
189
|
+
ctx.globalCompositeOperation = "multiply";
|
|
190
|
+
ctx.fillStyle = "rgba(0, 128, 255, 0.65)";
|
|
191
|
+
ctx.arc(64, 48, 24, 0, Math.PI * 2);
|
|
192
|
+
ctx.fill();
|
|
193
|
+
return canvas.toDataURL();
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const first = draw();
|
|
197
|
+
const second = draw();
|
|
198
|
+
if (!first || !second) {
|
|
199
|
+
return { status: "unavailable" };
|
|
200
|
+
}
|
|
201
|
+
if (first !== second) {
|
|
202
|
+
return { status: "unstable" };
|
|
203
|
+
}
|
|
204
|
+
return { status: "stable", hash: hashString(first) };
|
|
205
|
+
} catch {
|
|
206
|
+
return { status: "error" };
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const audioSignals = () => {
|
|
211
|
+
const Ctor = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
|
212
|
+
if (!Ctor) {
|
|
213
|
+
return Promise.resolve({ status: "unavailable" });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const render = new Promise((resolve, reject) => {
|
|
217
|
+
try {
|
|
218
|
+
const context = new Ctor(1, 4096, 44100);
|
|
219
|
+
const oscillator = context.createOscillator();
|
|
220
|
+
oscillator.type = "triangle";
|
|
221
|
+
oscillator.frequency.value = 10000;
|
|
222
|
+
const compressor = context.createDynamicsCompressor();
|
|
223
|
+
oscillator.connect(compressor);
|
|
224
|
+
compressor.connect(context.destination);
|
|
225
|
+
oscillator.start(0);
|
|
226
|
+
context.oncomplete = (event) => {
|
|
227
|
+
const samples = event.renderedBuffer.getChannelData(0);
|
|
228
|
+
let sum = 0;
|
|
229
|
+
for (let index = 0; index < samples.length; index += 1) {
|
|
230
|
+
sum += Math.abs(samples[index]);
|
|
231
|
+
}
|
|
232
|
+
resolve({ status: "available", hash: hashString(sum.toFixed(6)) });
|
|
233
|
+
};
|
|
234
|
+
context.startRendering();
|
|
235
|
+
} catch (error) {
|
|
236
|
+
reject(error);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return withTimeout(render, (reason) => ({ status: reason === "error" ? "error" : "timeout" }));
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const fontSignals = () => {
|
|
244
|
+
try {
|
|
245
|
+
const canvas = document.createElement("canvas");
|
|
246
|
+
const ctx = canvas.getContext("2d");
|
|
247
|
+
if (!ctx) {
|
|
248
|
+
return { status: "unavailable", checked: 0, detected: [] };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const sample = "mmmmmmmmmmlliWWW@0123456789";
|
|
252
|
+
const fallbacks = ["monospace", "sans-serif", "serif"];
|
|
253
|
+
const widthFor = (family) => {
|
|
254
|
+
ctx.font = "72px " + family;
|
|
255
|
+
return ctx.measureText(sample).width;
|
|
256
|
+
};
|
|
257
|
+
const baseWidths = fallbacks.map(widthFor);
|
|
258
|
+
const detected = [];
|
|
259
|
+
for (const font of FONT_LIST) {
|
|
260
|
+
const differs = fallbacks.some((fallback, index) => widthFor('"' + font + '", ' + fallback) !== baseWidths[index]);
|
|
261
|
+
if (differs) {
|
|
262
|
+
detected.push(font);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { status: "available", checked: FONT_LIST.length, detected };
|
|
266
|
+
} catch {
|
|
267
|
+
return { status: "error", checked: 0, detected: [] };
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const webrtcSignals = () => {
|
|
272
|
+
const Ctor = window.RTCPeerConnection || window.webkitRTCPeerConnection;
|
|
273
|
+
if (!Ctor) {
|
|
274
|
+
return Promise.resolve({ apiAvailable: false, hostCandidateGathering: "unsupported", candidateCount: 0 });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let pc;
|
|
278
|
+
let candidateCount = 0;
|
|
279
|
+
const gather = new Promise((resolve, reject) => {
|
|
280
|
+
try {
|
|
281
|
+
pc = new Ctor({ iceServers: [] });
|
|
282
|
+
pc.createDataChannel("__botprint_probe__");
|
|
283
|
+
// Resolve on the first candidate: the question is whether host candidates are gathered
|
|
284
|
+
// at all, and Chromium can hold iceGatheringState at "gathering" for seconds afterwards.
|
|
285
|
+
pc.onicecandidate = (event) => {
|
|
286
|
+
if (event.candidate) {
|
|
287
|
+
candidateCount += 1;
|
|
288
|
+
resolve("present");
|
|
289
|
+
} else {
|
|
290
|
+
resolve(candidateCount > 0 ? "present" : "absent");
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
pc.onicegatheringstatechange = () => {
|
|
294
|
+
if (pc.iceGatheringState === "complete") {
|
|
295
|
+
resolve(candidateCount > 0 ? "present" : "absent");
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
pc.createOffer()
|
|
299
|
+
.then((offer) => pc.setLocalDescription(offer))
|
|
300
|
+
.catch(reject);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
reject(error);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return withTimeout(gather, (reason) => {
|
|
307
|
+
if (reason === "error") return "error";
|
|
308
|
+
return candidateCount > 0 ? "present" : "timeout";
|
|
309
|
+
}).then((status) => {
|
|
310
|
+
if (pc) {
|
|
311
|
+
try { pc.close(); } catch {}
|
|
312
|
+
}
|
|
313
|
+
return { apiAvailable: true, hostCandidateGathering: status, candidateCount };
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const automationNames = [
|
|
318
|
+
"$cdc_asdjflasutopfhvcZLmcfl_",
|
|
319
|
+
"_phantom",
|
|
320
|
+
"__nightmare",
|
|
321
|
+
"domAutomation",
|
|
322
|
+
"domAutomationController"
|
|
323
|
+
];
|
|
324
|
+
const windowNames = Object.getOwnPropertyNames(window);
|
|
325
|
+
const automationGlobals = {
|
|
326
|
+
"cdc_*": windowNames.some((name) => name.startsWith("cdc_")),
|
|
327
|
+
"__webdriver_*": windowNames.some((name) => name.startsWith("__webdriver_"))
|
|
328
|
+
};
|
|
329
|
+
for (const name of automationNames) {
|
|
330
|
+
automationGlobals[name] = Object.prototype.hasOwnProperty.call(window, name);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const userAgentData = () => {
|
|
334
|
+
const data = navigator.userAgentData;
|
|
335
|
+
if (!data || !Array.isArray(data.brands)) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
brands: data.brands.map((entry) => ({ brand: String(entry.brand || ""), version: String(entry.version || "") })),
|
|
340
|
+
mobile: data.mobile === true,
|
|
341
|
+
platform: String(data.platform || "")
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const permissions = {};
|
|
346
|
+
const permissionNames = ["geolocation", "notifications", "camera", "microphone"];
|
|
347
|
+
const collectPermissions = async () => {
|
|
348
|
+
for (const name of permissionNames) {
|
|
349
|
+
permissions[name] = await queryPermission(name);
|
|
350
|
+
}
|
|
351
|
+
permissions.notification_api = notificationApiPermission();
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// Chromium loads speech voices asynchronously: the first getVoices() call can return an
|
|
355
|
+
// empty list and fire voiceschanged shortly after. Wait for that event, bounded so headless
|
|
356
|
+
// environments with no voices do not stall the probe.
|
|
357
|
+
const speechVoicesCount = () => new Promise((resolve) => {
|
|
358
|
+
if (typeof speechSynthesis === "undefined" || typeof speechSynthesis.getVoices !== "function") {
|
|
359
|
+
resolve(0);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
const initial = speechSynthesis.getVoices().length;
|
|
364
|
+
if (initial > 0) {
|
|
365
|
+
resolve(initial);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
let settled = false;
|
|
369
|
+
const finish = () => {
|
|
370
|
+
if (!settled) {
|
|
371
|
+
settled = true;
|
|
372
|
+
try {
|
|
373
|
+
resolve(speechSynthesis.getVoices().length);
|
|
374
|
+
} catch {
|
|
375
|
+
resolve(0);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
speechSynthesis.addEventListener("voiceschanged", finish, { once: true });
|
|
380
|
+
setTimeout(finish, Math.min(PROBE_TIMEOUT_MS, SPEECH_VOICES_WAIT_MS));
|
|
381
|
+
} catch {
|
|
382
|
+
resolve(0);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
return Promise.all([indexedDbAvailable(), collectPermissions(), webrtcSignals(), audioSignals(), speechVoicesCount()]).then(([indexedDb, _permissions, webrtc, audio, speechVoices]) => ({
|
|
387
|
+
userAgent: navigator.userAgent,
|
|
388
|
+
userAgentData: userAgentData(),
|
|
389
|
+
platform: navigator.platform,
|
|
390
|
+
language: navigator.language || "",
|
|
391
|
+
languages: Array.from(navigator.languages || []),
|
|
392
|
+
pdfViewerEnabled: typeof navigator.pdfViewerEnabled === "boolean" ? navigator.pdfViewerEnabled : null,
|
|
393
|
+
webdriver: typeof navigator.webdriver === "boolean" ? navigator.webdriver : null,
|
|
394
|
+
chromeObject: typeof window.chrome === "object" && window.chrome !== null,
|
|
395
|
+
automationGlobals,
|
|
396
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
|
397
|
+
viewport: {
|
|
398
|
+
width: window.innerWidth,
|
|
399
|
+
height: window.innerHeight,
|
|
400
|
+
deviceScaleFactor: window.devicePixelRatio
|
|
401
|
+
},
|
|
402
|
+
outerWindow: {
|
|
403
|
+
width: window.outerWidth,
|
|
404
|
+
height: window.outerHeight
|
|
405
|
+
},
|
|
406
|
+
screen: {
|
|
407
|
+
width: window.screen.width,
|
|
408
|
+
height: window.screen.height,
|
|
409
|
+
availWidth: window.screen.availWidth,
|
|
410
|
+
availHeight: window.screen.availHeight,
|
|
411
|
+
colorDepth: window.screen.colorDepth
|
|
412
|
+
},
|
|
413
|
+
hardwareConcurrency: typeof navigator.hardwareConcurrency === "number" ? navigator.hardwareConcurrency : 0,
|
|
414
|
+
deviceMemory: typeof navigator.deviceMemory === "number" ? navigator.deviceMemory : null,
|
|
415
|
+
speechVoicesCount: speechVoices,
|
|
416
|
+
pluginsCount: navigator.plugins ? navigator.plugins.length : 0,
|
|
417
|
+
mimeTypesCount: navigator.mimeTypes ? navigator.mimeTypes.length : 0,
|
|
418
|
+
cookiesEnabled: navigator.cookieEnabled === true,
|
|
419
|
+
storage: {
|
|
420
|
+
localStorage: storageAvailable("localStorage"),
|
|
421
|
+
sessionStorage: storageAvailable("sessionStorage"),
|
|
422
|
+
indexedDb,
|
|
423
|
+
serviceWorker: "serviceWorker" in navigator ? "available" : "unavailable"
|
|
424
|
+
},
|
|
425
|
+
permissions,
|
|
426
|
+
rendering: {
|
|
427
|
+
webgl: webglSignals(),
|
|
428
|
+
canvas: canvasSignals(),
|
|
429
|
+
audio,
|
|
430
|
+
fonts: fontSignals()
|
|
431
|
+
},
|
|
432
|
+
webrtc,
|
|
433
|
+
href: location.href
|
|
434
|
+
}));
|
|
435
|
+
})()`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export type ProbeResult = {
|
|
439
|
+
userAgent: string;
|
|
440
|
+
userAgentData: { brands: Array<{ brand: string; version: string }>; mobile: boolean; platform: string } | null;
|
|
441
|
+
platform: string;
|
|
442
|
+
language: string;
|
|
443
|
+
languages: string[];
|
|
444
|
+
pdfViewerEnabled: boolean | null;
|
|
445
|
+
webdriver: boolean | null;
|
|
446
|
+
chromeObject: boolean;
|
|
447
|
+
automationGlobals: Record<string, boolean>;
|
|
448
|
+
timezone: string;
|
|
449
|
+
viewport: {
|
|
450
|
+
width: number;
|
|
451
|
+
height: number;
|
|
452
|
+
deviceScaleFactor: number;
|
|
453
|
+
};
|
|
454
|
+
outerWindow: {
|
|
455
|
+
width: number;
|
|
456
|
+
height: number;
|
|
457
|
+
};
|
|
458
|
+
screen: {
|
|
459
|
+
width: number;
|
|
460
|
+
height: number;
|
|
461
|
+
availWidth: number;
|
|
462
|
+
availHeight: number;
|
|
463
|
+
colorDepth: number;
|
|
464
|
+
};
|
|
465
|
+
hardwareConcurrency: number;
|
|
466
|
+
deviceMemory: number | null;
|
|
467
|
+
speechVoicesCount: number;
|
|
468
|
+
pluginsCount: number;
|
|
469
|
+
mimeTypesCount: number;
|
|
470
|
+
cookiesEnabled: boolean;
|
|
471
|
+
storage: {
|
|
472
|
+
localStorage: string;
|
|
473
|
+
sessionStorage: string;
|
|
474
|
+
indexedDb: string;
|
|
475
|
+
serviceWorker: string;
|
|
476
|
+
};
|
|
477
|
+
permissions: Record<string, string>;
|
|
478
|
+
rendering: {
|
|
479
|
+
webgl: Record<string, unknown>;
|
|
480
|
+
canvas: Record<string, unknown>;
|
|
481
|
+
audio: Record<string, unknown>;
|
|
482
|
+
fonts: { status: string; checked: number; detected: string[] };
|
|
483
|
+
};
|
|
484
|
+
webrtc: {
|
|
485
|
+
apiAvailable: boolean;
|
|
486
|
+
hostCandidateGathering: string;
|
|
487
|
+
candidateCount: number;
|
|
488
|
+
};
|
|
489
|
+
href: string;
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
export type ProbeRunResult = {
|
|
493
|
+
targetUrl: string;
|
|
494
|
+
result: ProbeResult;
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
export type ProbeOptions = {
|
|
498
|
+
timeoutMs: number;
|
|
499
|
+
browserContextId?: string | undefined;
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
export async function runDiagnosticProbe(client: CdpClient, browserVersion: BrowserVersion, diagnosticUrl: string, options: ProbeOptions): Promise<ProbeRunResult> {
|
|
503
|
+
const params: Record<string, unknown> = { url: diagnosticUrl };
|
|
504
|
+
if (options.browserContextId !== undefined) {
|
|
505
|
+
params.browserContextId = options.browserContextId;
|
|
506
|
+
}
|
|
507
|
+
const target = await client.send("Target.createTarget", params);
|
|
508
|
+
const targetId = readString(target.targetId, "Target.createTarget did not return targetId.");
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
return await runExistingTargetProbe(client, browserVersion, targetId, options);
|
|
512
|
+
} finally {
|
|
513
|
+
await ignoreCdpCloseError(client.send("Target.closeTarget", { targetId }));
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export async function runExistingTargetProbe(client: CdpClient, browserVersion: BrowserVersion, targetId: string, options: ProbeOptions): Promise<ProbeRunResult> {
|
|
518
|
+
let sessionId: string | undefined;
|
|
519
|
+
try {
|
|
520
|
+
const attached = await client.send("Target.attachToTarget", { targetId, flatten: true });
|
|
521
|
+
sessionId = readString(attached.sessionId, "Target.attachToTarget did not return sessionId.");
|
|
522
|
+
|
|
523
|
+
await client.send("Runtime.enable", {}, sessionId);
|
|
524
|
+
|
|
525
|
+
const evaluated = await client.send(
|
|
526
|
+
"Runtime.evaluate",
|
|
527
|
+
{
|
|
528
|
+
expression: buildProbeExpression(options.timeoutMs),
|
|
529
|
+
returnByValue: true,
|
|
530
|
+
awaitPromise: true,
|
|
531
|
+
},
|
|
532
|
+
sessionId,
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const result = readEvaluationValue(evaluated);
|
|
536
|
+
return {
|
|
537
|
+
targetUrl: result.href || "about:blank",
|
|
538
|
+
result: {
|
|
539
|
+
...result,
|
|
540
|
+
userAgent: result.userAgent || browserVersion.userAgent,
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
} finally {
|
|
544
|
+
if (sessionId !== undefined) {
|
|
545
|
+
await ignoreCdpCloseError(client.send("Target.detachFromTarget", { sessionId }));
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function readEvaluationValue(evaluated: Record<string, unknown>): ProbeResult {
|
|
551
|
+
if (evaluated.exceptionDetails !== undefined) {
|
|
552
|
+
const details = evaluated.exceptionDetails;
|
|
553
|
+
const text = isObject(details) ? stringValue(details.text) : "";
|
|
554
|
+
const exception = isObject(details) && isObject(details.exception) ? stringValue(details.exception.description) : "";
|
|
555
|
+
const detail = [text, exception].filter((part) => part.length > 0).join(" ");
|
|
556
|
+
throw new BotprintError(`Probe threw an exception in the browser${detail.length > 0 ? `: ${detail}` : "."}`, EXIT_CODES.probeFailure);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const result = evaluated.result;
|
|
560
|
+
if (!isObject(result)) {
|
|
561
|
+
throw new BotprintError("Runtime.evaluate did not return a result object.", EXIT_CODES.probeFailure);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const value = result.value;
|
|
565
|
+
if (!isObject(value)) {
|
|
566
|
+
throw new BotprintError("Probe did not return a JSON object.", EXIT_CODES.probeFailure);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const viewport = isObject(value.viewport) ? value.viewport : {};
|
|
570
|
+
const outerWindow = isObject(value.outerWindow) ? value.outerWindow : {};
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
userAgent: stringValue(value.userAgent),
|
|
574
|
+
userAgentData: readUserAgentData(value.userAgentData),
|
|
575
|
+
platform: stringValue(value.platform),
|
|
576
|
+
language: stringValue(value.language),
|
|
577
|
+
languages: stringArray(value.languages),
|
|
578
|
+
pdfViewerEnabled: booleanOrNull(value.pdfViewerEnabled),
|
|
579
|
+
webdriver: booleanOrNull(value.webdriver),
|
|
580
|
+
chromeObject: value.chromeObject === true,
|
|
581
|
+
automationGlobals: booleanRecord(value.automationGlobals),
|
|
582
|
+
timezone: stringValue(value.timezone),
|
|
583
|
+
viewport: {
|
|
584
|
+
width: numberValue(viewport.width),
|
|
585
|
+
height: numberValue(viewport.height),
|
|
586
|
+
deviceScaleFactor: numberValue(viewport.deviceScaleFactor),
|
|
587
|
+
},
|
|
588
|
+
outerWindow: {
|
|
589
|
+
width: numberValue(outerWindow.width),
|
|
590
|
+
height: numberValue(outerWindow.height),
|
|
591
|
+
},
|
|
592
|
+
screen: readScreen(value.screen),
|
|
593
|
+
hardwareConcurrency: numberValue(value.hardwareConcurrency),
|
|
594
|
+
deviceMemory: typeof value.deviceMemory === "number" ? numberValue(value.deviceMemory) : null,
|
|
595
|
+
speechVoicesCount: numberValue(value.speechVoicesCount),
|
|
596
|
+
pluginsCount: numberValue(value.pluginsCount),
|
|
597
|
+
mimeTypesCount: numberValue(value.mimeTypesCount),
|
|
598
|
+
cookiesEnabled: value.cookiesEnabled === true,
|
|
599
|
+
storage: readStorage(value.storage),
|
|
600
|
+
permissions: stringRecord(value.permissions),
|
|
601
|
+
rendering: readRendering(value.rendering),
|
|
602
|
+
webrtc: readWebrtc(value.webrtc),
|
|
603
|
+
href: stringValue(value.href),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function readString(value: unknown, message: string): string {
|
|
608
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
609
|
+
throw new BotprintError(message, EXIT_CODES.cdpConnectivity);
|
|
610
|
+
}
|
|
611
|
+
return value;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function readUserAgentData(value: unknown): ProbeResult["userAgentData"] {
|
|
615
|
+
if (!isObject(value) || !Array.isArray(value.brands)) {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
brands: value.brands.filter(isObject).map((entry) => ({ brand: stringValue(entry.brand), version: stringValue(entry.version) })),
|
|
620
|
+
mobile: value.mobile === true,
|
|
621
|
+
platform: stringValue(value.platform),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function readScreen(value: unknown): ProbeResult["screen"] {
|
|
626
|
+
const screen = isObject(value) ? value : {};
|
|
627
|
+
return {
|
|
628
|
+
width: numberValue(screen.width),
|
|
629
|
+
height: numberValue(screen.height),
|
|
630
|
+
availWidth: numberValue(screen.availWidth),
|
|
631
|
+
availHeight: numberValue(screen.availHeight),
|
|
632
|
+
colorDepth: numberValue(screen.colorDepth),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function readStorage(value: unknown): ProbeResult["storage"] {
|
|
637
|
+
const storage = isObject(value) ? value : {};
|
|
638
|
+
return {
|
|
639
|
+
localStorage: stringValue(storage.localStorage),
|
|
640
|
+
sessionStorage: stringValue(storage.sessionStorage),
|
|
641
|
+
indexedDb: stringValue(storage.indexedDb),
|
|
642
|
+
serviceWorker: stringValue(storage.serviceWorker),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function readRendering(value: unknown): ProbeResult["rendering"] {
|
|
647
|
+
const rendering = isObject(value) ? value : {};
|
|
648
|
+
const fonts = isObject(rendering.fonts) ? rendering.fonts : {};
|
|
649
|
+
return {
|
|
650
|
+
webgl: isObject(rendering.webgl) ? rendering.webgl : {},
|
|
651
|
+
canvas: isObject(rendering.canvas) ? rendering.canvas : {},
|
|
652
|
+
audio: isObject(rendering.audio) ? rendering.audio : {},
|
|
653
|
+
fonts: {
|
|
654
|
+
status: stringValue(fonts.status),
|
|
655
|
+
checked: numberValue(fonts.checked),
|
|
656
|
+
detected: stringArray(fonts.detected),
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function readWebrtc(value: unknown): ProbeResult["webrtc"] {
|
|
662
|
+
const webrtc = isObject(value) ? value : {};
|
|
663
|
+
return {
|
|
664
|
+
apiAvailable: webrtc.apiAvailable === true,
|
|
665
|
+
hostCandidateGathering: stringValue(webrtc.hostCandidateGathering),
|
|
666
|
+
candidateCount: numberValue(webrtc.candidateCount),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function booleanRecord(value: unknown): Record<string, boolean> {
|
|
671
|
+
if (!isObject(value)) {
|
|
672
|
+
return {};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const out: Record<string, boolean> = {};
|
|
676
|
+
for (const [key, recordValue] of Object.entries(value)) {
|
|
677
|
+
out[key] = recordValue === true;
|
|
678
|
+
}
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function stringRecord(value: unknown): Record<string, string> {
|
|
683
|
+
if (!isObject(value)) {
|
|
684
|
+
return {};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const out: Record<string, string> = {};
|
|
688
|
+
for (const [key, recordValue] of Object.entries(value)) {
|
|
689
|
+
out[key] = stringValue(recordValue);
|
|
690
|
+
}
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function ignoreCdpCloseError(promise: Promise<unknown>): Promise<void> {
|
|
695
|
+
try {
|
|
696
|
+
await promise;
|
|
697
|
+
} catch {
|
|
698
|
+
// Cleanup failure should not mask the probe result or original error.
|
|
699
|
+
}
|
|
700
|
+
}
|