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/coerce.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function isObject(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function stringValue(value: unknown): string {
|
|
6
|
+
return typeof value === "string" ? value : "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function numberValue(value: unknown): number {
|
|
10
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function booleanOrNull(value: unknown): boolean | null {
|
|
14
|
+
return typeof value === "boolean" ? value : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function stringArray(value: unknown): string[] {
|
|
18
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
19
|
+
}
|
package/src/compare.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import type { FindingCategory } from "./findings";
|
|
2
|
+
import { SCHEMA_VERSION, type BotprintProfile } from "./profile";
|
|
3
|
+
import { parseBrowserProduct, parseUserAgent } from "./user-agent";
|
|
4
|
+
import { BOTPRINT_VERSION } from "./version";
|
|
5
|
+
|
|
6
|
+
export type VerdictLevel = "none" | "advisory" | "material" | "high_confidence_automation";
|
|
7
|
+
export type DiffLevel = Exclude<VerdictLevel, "none">;
|
|
8
|
+
export type AbsentValue = { kind: "absent" };
|
|
9
|
+
|
|
10
|
+
export type DiffCategory = FindingCategory | "cdp";
|
|
11
|
+
|
|
12
|
+
export type ProfileDiff = {
|
|
13
|
+
id: string;
|
|
14
|
+
category: DiffCategory;
|
|
15
|
+
level: DiffLevel;
|
|
16
|
+
path: string;
|
|
17
|
+
baseline: unknown;
|
|
18
|
+
candidate: unknown;
|
|
19
|
+
summary: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const ABSENT_VALUE: AbsentValue = { kind: "absent" };
|
|
23
|
+
|
|
24
|
+
export type CompareWarningId =
|
|
25
|
+
| "comparison.browser_binary_mismatch"
|
|
26
|
+
| "comparison.headless_mode_mismatch"
|
|
27
|
+
| "comparison.probe_context_mismatch"
|
|
28
|
+
| "comparison.browser_context_mismatch"
|
|
29
|
+
| "comparison.http_not_captured";
|
|
30
|
+
|
|
31
|
+
export type CompareWarning = {
|
|
32
|
+
id: CompareWarningId;
|
|
33
|
+
summary: string;
|
|
34
|
+
implications: string;
|
|
35
|
+
evidence: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type CompareResult = {
|
|
39
|
+
schema_version: typeof SCHEMA_VERSION;
|
|
40
|
+
botprint_version: string;
|
|
41
|
+
created_at: string;
|
|
42
|
+
baseline: {
|
|
43
|
+
label: string;
|
|
44
|
+
profile_path: string;
|
|
45
|
+
};
|
|
46
|
+
candidate: {
|
|
47
|
+
label: string;
|
|
48
|
+
profile_path: string;
|
|
49
|
+
};
|
|
50
|
+
diffs: ProfileDiff[];
|
|
51
|
+
verdict: {
|
|
52
|
+
level: VerdictLevel;
|
|
53
|
+
material: boolean;
|
|
54
|
+
categories: DiffCategory[];
|
|
55
|
+
warnings: CompareWarning[];
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type CompareInput = {
|
|
60
|
+
baseline: BotprintProfile;
|
|
61
|
+
baselinePath: string;
|
|
62
|
+
candidate: BotprintProfile;
|
|
63
|
+
candidatePath: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function compareProfiles(input: CompareInput): CompareResult {
|
|
67
|
+
const diffs = collectDiffs(input.baseline, input.candidate);
|
|
68
|
+
const level = verdictLevel(diffs);
|
|
69
|
+
const warnings = collectWarnings(input.baseline, input.candidate);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
schema_version: SCHEMA_VERSION,
|
|
73
|
+
botprint_version: BOTPRINT_VERSION,
|
|
74
|
+
created_at: new Date().toISOString(),
|
|
75
|
+
baseline: {
|
|
76
|
+
label: profileLabel(input.baseline, input.baselinePath),
|
|
77
|
+
profile_path: input.baselinePath,
|
|
78
|
+
},
|
|
79
|
+
candidate: {
|
|
80
|
+
label: profileLabel(input.candidate, input.candidatePath),
|
|
81
|
+
profile_path: input.candidatePath,
|
|
82
|
+
},
|
|
83
|
+
diffs,
|
|
84
|
+
verdict: {
|
|
85
|
+
level,
|
|
86
|
+
material: level === "material" || level === "high_confidence_automation",
|
|
87
|
+
categories: uniqueCategories(diffs),
|
|
88
|
+
warnings,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function collectWarnings(baseline: BotprintProfile, candidate: BotprintProfile): CompareWarning[] {
|
|
94
|
+
const warnings: CompareWarning[] = [];
|
|
95
|
+
if (baseline.source.probe_context !== candidate.source.probe_context) {
|
|
96
|
+
warnings.push({
|
|
97
|
+
id: "comparison.probe_context_mismatch",
|
|
98
|
+
summary: "Profiles were collected with different probe contexts.",
|
|
99
|
+
implications: "Diffs may include artifacts of page state, framework behavior, or measurement mode rather than browser-environment drift alone.",
|
|
100
|
+
evidence: {
|
|
101
|
+
baseline_probe_context: baseline.source.probe_context,
|
|
102
|
+
candidate_probe_context: candidate.source.probe_context,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const baselineContext = baseline.source.browser_context_source;
|
|
108
|
+
const candidateContext = candidate.source.browser_context_source;
|
|
109
|
+
if (baselineContext !== undefined && candidateContext !== undefined && baselineContext !== candidateContext) {
|
|
110
|
+
warnings.push({
|
|
111
|
+
id: "comparison.browser_context_mismatch",
|
|
112
|
+
summary: "Profiles were collected from different kinds of browser context.",
|
|
113
|
+
implications: "One profile measured a page-target context (framework options applied) and the other the browser default context. Locale, timezone, viewport, and permission diffs may be context configuration rather than environment drift.",
|
|
114
|
+
evidence: {
|
|
115
|
+
baseline_browser_context_source: baselineContext,
|
|
116
|
+
candidate_browser_context_source: candidateContext,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const baselineProduct = parseBrowserProduct(baseline.signals.cdp.product);
|
|
122
|
+
const candidateProduct = parseBrowserProduct(candidate.signals.cdp.product);
|
|
123
|
+
const familyChanged = baselineProduct.binaryFamily !== candidateProduct.binaryFamily;
|
|
124
|
+
const majorDelta =
|
|
125
|
+
baselineProduct.major === undefined || candidateProduct.major === undefined
|
|
126
|
+
? 0
|
|
127
|
+
: Math.abs(baselineProduct.major - candidateProduct.major);
|
|
128
|
+
|
|
129
|
+
if (familyChanged || majorDelta >= 2) {
|
|
130
|
+
warnings.push({
|
|
131
|
+
id: "comparison.browser_binary_mismatch",
|
|
132
|
+
summary: "Baseline and candidate were collected from different browser binaries.",
|
|
133
|
+
implications: "Diffs below may include artifacts of the binary difference, not environment drift alone.",
|
|
134
|
+
evidence: {
|
|
135
|
+
baseline_product: baseline.signals.cdp.product,
|
|
136
|
+
candidate_product: candidate.signals.cdp.product,
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The CDP product says "HeadlessChrome" only for the headless shell; new headless mode reports
|
|
142
|
+
// "Chrome" there while still reporting HeadlessChrome in the user agent. Check both.
|
|
143
|
+
const baselineUserAgent = baseline.signals.identity.user_agent;
|
|
144
|
+
const candidateUserAgent = candidate.signals.identity.user_agent;
|
|
145
|
+
const shellMismatch = baselineProduct.headless !== candidateProduct.headless;
|
|
146
|
+
const userAgentMismatch = baselineUserAgent !== candidateUserAgent && parseUserAgent(baselineUserAgent).headless !== parseUserAgent(candidateUserAgent).headless;
|
|
147
|
+
if (shellMismatch || userAgentMismatch) {
|
|
148
|
+
const text = shellMismatch
|
|
149
|
+
? {
|
|
150
|
+
summary: "One profile came from the headless shell binary and the other from a headed or new-headless build.",
|
|
151
|
+
implications:
|
|
152
|
+
"Plugin counts, window.chrome, outer window size, and notification permission change with the headless shell; the user agent changes only against a headed build. Treat those diffs as mode differences unless the environments are meant to match.",
|
|
153
|
+
}
|
|
154
|
+
: {
|
|
155
|
+
summary: "One profile came from a headless browser and the other from a headed one.",
|
|
156
|
+
implications:
|
|
157
|
+
"Both Chromium headless modes report HeadlessChrome in the user agent while the CDP product still says Chrome. Treat the user agent diff as a mode difference unless the environments are meant to match.",
|
|
158
|
+
};
|
|
159
|
+
warnings.push({
|
|
160
|
+
id: "comparison.headless_mode_mismatch",
|
|
161
|
+
...text,
|
|
162
|
+
evidence: {
|
|
163
|
+
baseline_product: baseline.signals.cdp.product,
|
|
164
|
+
candidate_product: candidate.signals.cdp.product,
|
|
165
|
+
baseline_user_agent: baselineUserAgent,
|
|
166
|
+
candidate_user_agent: candidateUserAgent,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const baselineHttp = baseline.signals.http?.status === "captured";
|
|
172
|
+
const candidateHttp = candidate.signals.http?.status === "captured";
|
|
173
|
+
if (baselineHttp !== candidateHttp) {
|
|
174
|
+
warnings.push({
|
|
175
|
+
id: "comparison.http_not_captured",
|
|
176
|
+
summary: "HTTP request headers were captured for only one profile.",
|
|
177
|
+
implications: "Header diffs are skipped. Headers are only captured in fresh diagnostic-page mode.",
|
|
178
|
+
evidence: {
|
|
179
|
+
baseline_http_status: baseline.signals.http?.status ?? "absent",
|
|
180
|
+
candidate_http_status: candidate.signals.http?.status ?? "absent",
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return warnings;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function collectDiffs(baseline: BotprintProfile, candidate: BotprintProfile): ProfileDiff[] {
|
|
189
|
+
const diffs: ProfileDiff[] = [];
|
|
190
|
+
const b = baseline.signals;
|
|
191
|
+
const c = candidate.signals;
|
|
192
|
+
|
|
193
|
+
addDiff(diffs, {
|
|
194
|
+
id: "automation.webdriver_changed",
|
|
195
|
+
category: "automation",
|
|
196
|
+
path: "signals.automation.webdriver",
|
|
197
|
+
baseline: b.automation.webdriver,
|
|
198
|
+
candidate: c.automation.webdriver,
|
|
199
|
+
level: c.automation.webdriver === true ? "high_confidence_automation" : "material",
|
|
200
|
+
summary: "navigator.webdriver changed",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
for (const name of sortedKeys(b.automation.globals, c.automation.globals)) {
|
|
204
|
+
addDiff(diffs, {
|
|
205
|
+
id: `automation.global.${name}`,
|
|
206
|
+
category: "automation",
|
|
207
|
+
path: `signals.automation.globals.${name}`,
|
|
208
|
+
baseline: b.automation.globals[name] === true,
|
|
209
|
+
candidate: c.automation.globals[name] === true,
|
|
210
|
+
level: c.automation.globals[name] === true ? "high_confidence_automation" : "material",
|
|
211
|
+
summary: `Automation global ${name} changed`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
addDiff(diffs, material("automation.chrome_object_changed", "automation", "signals.automation.chrome_object", b.automation.chrome_object, c.automation.chrome_object, "window.chrome presence changed"));
|
|
216
|
+
|
|
217
|
+
addDiff(
|
|
218
|
+
diffs,
|
|
219
|
+
advisory("identity.user_agent_changed", "identity", "signals.identity.user_agent", b.identity.user_agent, c.identity.user_agent, "User agent changed"),
|
|
220
|
+
sameUserAgentValue,
|
|
221
|
+
);
|
|
222
|
+
addDiff(diffs, advisory("identity.user_agent_data_changed", "identity", "signals.identity.user_agent_data", b.identity.user_agent_data, c.identity.user_agent_data, "Client hint brands or platform changed"));
|
|
223
|
+
addDiff(diffs, advisory("identity.platform_changed", "identity", "signals.identity.platform", b.identity.platform, c.identity.platform, "Platform changed"));
|
|
224
|
+
addDiff(diffs, advisory("identity.language_changed", "identity", "signals.identity.language", b.identity.language, c.identity.language, "navigator.language changed"));
|
|
225
|
+
addDiff(diffs, advisory("identity.languages_changed", "identity", "signals.identity.languages", b.identity.languages, c.identity.languages, "Languages changed"));
|
|
226
|
+
addDiff(diffs, advisory("identity.plugins_count_changed", "identity", "signals.identity.plugins_count", b.identity.plugins_count, c.identity.plugins_count, "Plugin count changed"));
|
|
227
|
+
addDiff(diffs, advisory("identity.mime_types_count_changed", "identity", "signals.identity.mime_types_count", b.identity.mime_types_count, c.identity.mime_types_count, "MIME type count changed"));
|
|
228
|
+
addDiff(diffs, advisory("identity.pdf_viewer_changed", "identity", "signals.identity.pdf_viewer_enabled", b.identity.pdf_viewer_enabled, c.identity.pdf_viewer_enabled, "PDF viewer availability changed"));
|
|
229
|
+
|
|
230
|
+
addDiff(diffs, material("environment.timezone_changed", "environment", "signals.environment.timezone", b.environment.timezone, c.environment.timezone, "Timezone changed"));
|
|
231
|
+
addDiff(diffs, advisory("environment.viewport_changed", "environment", "signals.environment.viewport", b.environment.viewport, c.environment.viewport, "Viewport changed"));
|
|
232
|
+
addDiff(diffs, advisory("environment.outer_window_changed", "environment", "signals.environment.outer_window", b.environment.outer_window, c.environment.outer_window, "Outer window size changed"));
|
|
233
|
+
addDiff(diffs, advisory("environment.screen_changed", "environment", "signals.environment.screen", b.environment.screen, c.environment.screen, "Screen metrics changed"));
|
|
234
|
+
addDiff(diffs, advisory("environment.hardware_concurrency_changed", "environment", "signals.environment.hardware_concurrency", b.environment.hardware_concurrency, c.environment.hardware_concurrency, "Hardware concurrency changed"));
|
|
235
|
+
addDiff(diffs, advisory("environment.device_memory_changed", "environment", "signals.environment.device_memory", b.environment.device_memory, c.environment.device_memory, "Device memory changed"));
|
|
236
|
+
addDiff(diffs, advisory("environment.speech_voices_changed", "environment", "signals.environment.speech_voices_count", b.environment.speech_voices_count, c.environment.speech_voices_count, "Speech synthesis voice count changed"));
|
|
237
|
+
|
|
238
|
+
addDiff(diffs, material("rendering.webgl_status_changed", "rendering", "signals.rendering.webgl.status", b.rendering.webgl.status, c.rendering.webgl.status, "WebGL availability changed"));
|
|
239
|
+
addDiff(diffs, material("rendering.webgl_renderer_changed", "rendering", "signals.rendering.webgl.unmasked_renderer", b.rendering.webgl.unmasked_renderer ?? b.rendering.webgl.renderer, c.rendering.webgl.unmasked_renderer ?? c.rendering.webgl.renderer, "WebGL renderer changed"));
|
|
240
|
+
addDiff(diffs, advisory("rendering.canvas_status_changed", "rendering", "signals.rendering.canvas.status", b.rendering.canvas.status, c.rendering.canvas.status, "Canvas status changed"));
|
|
241
|
+
addDiff(diffs, advisory("rendering.canvas_hash_changed", "rendering", "signals.rendering.canvas.hash", b.rendering.canvas.hash, c.rendering.canvas.hash, "Canvas hash changed"));
|
|
242
|
+
addDiff(diffs, advisory("rendering.audio_status_changed", "rendering", "signals.rendering.audio.status", b.rendering.audio.status, c.rendering.audio.status, "Audio rendering status changed"));
|
|
243
|
+
addDiff(diffs, advisory("rendering.audio_hash_changed", "rendering", "signals.rendering.audio.hash", b.rendering.audio.hash, c.rendering.audio.hash, "Audio hash changed"));
|
|
244
|
+
addDiff(diffs, fontsDiff(b.rendering.fonts, c.rendering.fonts));
|
|
245
|
+
|
|
246
|
+
addDiff(diffs, storageDiff("storage.cookies_enabled_changed", "signals.storage.cookies_enabled", b.storage.cookies_enabled, c.storage.cookies_enabled, "Cookie availability changed"));
|
|
247
|
+
addDiff(diffs, storageDiff("storage.local_storage_changed", "signals.storage.local_storage", b.storage.local_storage, c.storage.local_storage, "localStorage availability changed"));
|
|
248
|
+
addDiff(diffs, storageDiff("storage.session_storage_changed", "signals.storage.session_storage", b.storage.session_storage, c.storage.session_storage, "sessionStorage availability changed"));
|
|
249
|
+
addDiff(diffs, storageDiff("storage.indexed_db_changed", "signals.storage.indexed_db", b.storage.indexed_db, c.storage.indexed_db, "IndexedDB availability changed"));
|
|
250
|
+
addDiff(diffs, storageDiff("storage.service_worker_changed", "signals.storage.service_worker", b.storage.service_worker, c.storage.service_worker, "Service worker availability changed"));
|
|
251
|
+
|
|
252
|
+
for (const name of sortedKeys(b.permissions, c.permissions)) {
|
|
253
|
+
addDiff(diffs, advisory(`permissions.${name}_changed`, "permissions", `signals.permissions.${name}`, b.permissions[name], c.permissions[name], `Permission state changed for ${name}`));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
addDiff(diffs, advisory("webrtc.api_available_changed", "webrtc", "signals.webrtc.api_available", b.webrtc.api_available, c.webrtc.api_available, "WebRTC API availability changed"));
|
|
257
|
+
addDiff(diffs, advisory("webrtc.host_candidate_gathering_changed", "webrtc", "signals.webrtc.host_candidate_gathering", b.webrtc.host_candidate_gathering, c.webrtc.host_candidate_gathering, "WebRTC host-candidate gathering changed"));
|
|
258
|
+
addDiff(diffs, advisory("webrtc.candidate_count_changed", "webrtc", "signals.webrtc.candidate_count", b.webrtc.candidate_count, c.webrtc.candidate_count, "WebRTC candidate count changed"));
|
|
259
|
+
|
|
260
|
+
if (b.http?.status === "captured" && c.http?.status === "captured") {
|
|
261
|
+
for (const name of sortedKeys(b.http.request_headers, c.http.request_headers)) {
|
|
262
|
+
const level: DiffLevel = name === "accept-language" ? "material" : "advisory";
|
|
263
|
+
const equal = name === "user-agent" ? sameUserAgentValue : sameValue;
|
|
264
|
+
addDiff(diffs, {
|
|
265
|
+
id: `http.header.${name}_changed`,
|
|
266
|
+
category: "http",
|
|
267
|
+
level,
|
|
268
|
+
path: `signals.http.request_headers.${name}`,
|
|
269
|
+
baseline: b.http.request_headers[name],
|
|
270
|
+
candidate: c.http.request_headers[name],
|
|
271
|
+
summary: `Request header ${name} changed`,
|
|
272
|
+
}, equal);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
addDiff(diffs, advisory("cdp.product_changed", "cdp", "signals.cdp.product", b.cdp.product, c.cdp.product, "CDP product changed"));
|
|
277
|
+
addDiff(diffs, advisory("cdp.protocol_version_changed", "cdp", "signals.cdp.protocol_version", b.cdp.protocol_version, c.cdp.protocol_version, "CDP protocol version changed"));
|
|
278
|
+
|
|
279
|
+
return diffs;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function advisory(id: string, category: DiffCategory, path: string, baseline: unknown, candidate: unknown, summary: string): ProfileDiff {
|
|
283
|
+
return { id, category, level: "advisory", path, baseline, candidate, summary };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function material(id: string, category: DiffCategory, path: string, baseline: unknown, candidate: unknown, summary: string): ProfileDiff {
|
|
287
|
+
return { id, category, level: "material", path, baseline, candidate, summary };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function storageDiff(id: string, path: string, baseline: unknown, candidate: unknown, summary: string): ProfileDiff {
|
|
291
|
+
const level = baseline === true || baseline === "available" ? "material" : "advisory";
|
|
292
|
+
return { id, category: "storage", level, path, baseline, candidate, summary };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
type Fonts = BotprintProfile["signals"]["rendering"]["fonts"];
|
|
296
|
+
|
|
297
|
+
// Losing fonts the baseline had is material (text renders differently); gaining fonts is advisory.
|
|
298
|
+
function fontsDiff(baseline: Fonts, candidate: Fonts): ProfileDiff {
|
|
299
|
+
const candidateSet = new Set(candidate.detected);
|
|
300
|
+
const lost = baseline.detected.filter((font) => !candidateSet.has(font));
|
|
301
|
+
return {
|
|
302
|
+
id: "rendering.fonts_changed",
|
|
303
|
+
category: "rendering",
|
|
304
|
+
level: lost.length > 0 ? "material" : "advisory",
|
|
305
|
+
path: "signals.rendering.fonts.detected",
|
|
306
|
+
baseline: baseline.detected,
|
|
307
|
+
candidate: candidate.detected,
|
|
308
|
+
summary: "Detected font set changed",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function addDiff(diffs: ProfileDiff[], diff: ProfileDiff, equal: (left: unknown, right: unknown) => boolean = sameValue): void {
|
|
313
|
+
if (!equal(diff.baseline, diff.candidate)) {
|
|
314
|
+
diffs.push({
|
|
315
|
+
...diff,
|
|
316
|
+
baseline: serializableValue(diff.baseline),
|
|
317
|
+
candidate: serializableValue(diff.candidate),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function serializableValue(value: unknown): unknown {
|
|
323
|
+
return value === undefined ? ABSENT_VALUE : value;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function sameValue(left: unknown, right: unknown): boolean {
|
|
327
|
+
if (left === right) return true;
|
|
328
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const sameUserAgentValue = (left: unknown, right: unknown): boolean => sameUserAgent(String(left), String(right));
|
|
332
|
+
|
|
333
|
+
function sameUserAgent(left: string, right: string): boolean {
|
|
334
|
+
if (left === right) return true;
|
|
335
|
+
const parsedLeft = parseUserAgent(left);
|
|
336
|
+
const parsedRight = parseUserAgent(right);
|
|
337
|
+
if (parsedLeft.family === "other" || parsedRight.family === "other") return false;
|
|
338
|
+
return parsedLeft.family === parsedRight.family && parsedLeft.major === parsedRight.major && platformToken(left) === platformToken(right);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function platformToken(userAgent: string): string {
|
|
342
|
+
const match = /\(([^)]*)\)/.exec(userAgent);
|
|
343
|
+
return match?.[1] ?? "";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function verdictLevel(diffs: ProfileDiff[]): VerdictLevel {
|
|
347
|
+
if (diffs.some((diff) => diff.level === "high_confidence_automation")) return "high_confidence_automation";
|
|
348
|
+
if (diffs.some((diff) => diff.level === "material")) return "material";
|
|
349
|
+
if (diffs.length > 0) return "advisory";
|
|
350
|
+
return "none";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function uniqueCategories(diffs: ProfileDiff[]): DiffCategory[] {
|
|
354
|
+
return Array.from(new Set(diffs.map((diff) => diff.category))).sort();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function sortedKeys(left: Record<string, unknown>, right: Record<string, unknown>): string[] {
|
|
358
|
+
return Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).sort();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function profileLabel(profile: BotprintProfile, path: string): string {
|
|
362
|
+
return profile.source.label ?? path;
|
|
363
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ExitCode } from "./exit-codes";
|
|
2
|
+
|
|
3
|
+
export class BotprintError extends Error {
|
|
4
|
+
readonly exitCode: ExitCode;
|
|
5
|
+
|
|
6
|
+
constructor(message: string, exitCode: ExitCode) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "BotprintError";
|
|
9
|
+
this.exitCode = exitCode;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function errorDetail(error: unknown): string {
|
|
14
|
+
return error instanceof Error ? error.message : String(error);
|
|
15
|
+
}
|