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/profile.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { BotprintError } from "./errors";
|
|
4
|
+
import { EXIT_CODES } from "./exit-codes";
|
|
5
|
+
|
|
6
|
+
export const SCHEMA_VERSION = "0.3";
|
|
7
|
+
|
|
8
|
+
const AvailabilitySchema = z.enum(["available", "unavailable", "error"]);
|
|
9
|
+
const TimedAvailabilitySchema = z.enum(["available", "unavailable", "error", "timeout"]);
|
|
10
|
+
const PermissionStateSchema = z.enum(["granted", "denied", "prompt", "unsupported", "error", "unknown"]);
|
|
11
|
+
|
|
12
|
+
export const PROBE_WARNINGS = ["page-contamination-possible", "default-browser-context", "multiple-browser-contexts", "http-not-captured"] as const;
|
|
13
|
+
|
|
14
|
+
const FindingSchema = z.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
severity: z.enum(["normal", "notable", "warning", "compatibility_risk", "high_confidence_automation_signal"]),
|
|
17
|
+
category: z.string(),
|
|
18
|
+
title: z.string(),
|
|
19
|
+
evidence: z.array(z.string()),
|
|
20
|
+
explanation: z.string(),
|
|
21
|
+
likely_implications: z.string(),
|
|
22
|
+
suggested_investigation: z.string(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const ProfileSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
schema_version: z.literal(SCHEMA_VERSION),
|
|
28
|
+
botprint_version: z.string(),
|
|
29
|
+
created_at: z.iso.datetime(),
|
|
30
|
+
source: z.object({
|
|
31
|
+
label: z.string().optional(),
|
|
32
|
+
cdp_endpoint_kind: z.enum(["http", "https", "ws", "wss"]),
|
|
33
|
+
browser_version: z.string(),
|
|
34
|
+
target_url: z.string(),
|
|
35
|
+
probe_context: z.enum(["local-diagnostic-page", "existing-target"]),
|
|
36
|
+
browser_context_id: z.string().optional(),
|
|
37
|
+
browser_context_source: z.enum(["page-target", "browser-default", "explicit"]).optional(),
|
|
38
|
+
target_id: z.string().optional(),
|
|
39
|
+
target_type: z.string().optional(),
|
|
40
|
+
probe_timeout_ms: z.number().int().positive().optional(),
|
|
41
|
+
probe_warnings: z.array(z.enum(PROBE_WARNINGS)).optional(),
|
|
42
|
+
}),
|
|
43
|
+
signals: z.object({
|
|
44
|
+
identity: z.object({
|
|
45
|
+
user_agent: z.string(),
|
|
46
|
+
user_agent_data: z
|
|
47
|
+
.object({
|
|
48
|
+
brands: z.array(z.object({ brand: z.string(), version: z.string() })),
|
|
49
|
+
mobile: z.boolean(),
|
|
50
|
+
platform: z.string(),
|
|
51
|
+
})
|
|
52
|
+
.optional(),
|
|
53
|
+
platform: z.string(),
|
|
54
|
+
language: z.string(),
|
|
55
|
+
languages: z.array(z.string()),
|
|
56
|
+
plugins_count: z.number().int().nonnegative(),
|
|
57
|
+
mime_types_count: z.number().int().nonnegative(),
|
|
58
|
+
pdf_viewer_enabled: z.boolean().nullable(),
|
|
59
|
+
}),
|
|
60
|
+
automation: z.object({
|
|
61
|
+
webdriver: z.boolean().nullable(),
|
|
62
|
+
chrome_object: z.boolean(),
|
|
63
|
+
globals: z.record(z.string(), z.boolean()),
|
|
64
|
+
}),
|
|
65
|
+
environment: z.object({
|
|
66
|
+
timezone: z.string(),
|
|
67
|
+
viewport: z.object({
|
|
68
|
+
width: z.number().nonnegative(),
|
|
69
|
+
height: z.number().nonnegative(),
|
|
70
|
+
device_scale_factor: z.number().nonnegative(),
|
|
71
|
+
}),
|
|
72
|
+
outer_window: z.object({
|
|
73
|
+
width: z.number().nonnegative(),
|
|
74
|
+
height: z.number().nonnegative(),
|
|
75
|
+
}),
|
|
76
|
+
screen: z.object({
|
|
77
|
+
width: z.number().nonnegative(),
|
|
78
|
+
height: z.number().nonnegative(),
|
|
79
|
+
avail_width: z.number().nonnegative(),
|
|
80
|
+
avail_height: z.number().nonnegative(),
|
|
81
|
+
color_depth: z.number().nonnegative(),
|
|
82
|
+
}),
|
|
83
|
+
hardware_concurrency: z.number().nonnegative(),
|
|
84
|
+
device_memory: z.number().nonnegative().nullable(),
|
|
85
|
+
speech_voices_count: z.number().int().nonnegative(),
|
|
86
|
+
}),
|
|
87
|
+
rendering: z.object({
|
|
88
|
+
webgl: z.object({
|
|
89
|
+
status: AvailabilitySchema,
|
|
90
|
+
vendor: z.string().optional(),
|
|
91
|
+
renderer: z.string().optional(),
|
|
92
|
+
unmasked_vendor: z.string().optional(),
|
|
93
|
+
unmasked_renderer: z.string().optional(),
|
|
94
|
+
}),
|
|
95
|
+
canvas: z.object({
|
|
96
|
+
status: z.enum(["stable", "unstable", "unavailable", "error"]),
|
|
97
|
+
hash: z.string().optional(),
|
|
98
|
+
}),
|
|
99
|
+
audio: z.object({
|
|
100
|
+
status: TimedAvailabilitySchema,
|
|
101
|
+
hash: z.string().optional(),
|
|
102
|
+
}),
|
|
103
|
+
fonts: z.object({
|
|
104
|
+
status: AvailabilitySchema,
|
|
105
|
+
checked: z.number().int().nonnegative(),
|
|
106
|
+
detected: z.array(z.string()),
|
|
107
|
+
}),
|
|
108
|
+
}),
|
|
109
|
+
storage: z.object({
|
|
110
|
+
cookies_enabled: z.boolean(),
|
|
111
|
+
local_storage: AvailabilitySchema,
|
|
112
|
+
session_storage: AvailabilitySchema,
|
|
113
|
+
indexed_db: TimedAvailabilitySchema,
|
|
114
|
+
service_worker: AvailabilitySchema,
|
|
115
|
+
}),
|
|
116
|
+
permissions: z.record(z.string(), PermissionStateSchema),
|
|
117
|
+
webrtc: z.object({
|
|
118
|
+
api_available: z.boolean(),
|
|
119
|
+
host_candidate_gathering: z.enum(["present", "absent", "unsupported", "error", "timeout"]),
|
|
120
|
+
candidate_count: z.number().int().nonnegative(),
|
|
121
|
+
}),
|
|
122
|
+
http: z
|
|
123
|
+
.object({
|
|
124
|
+
status: z.enum(["captured", "not-captured"]),
|
|
125
|
+
request_headers: z.record(z.string(), z.string()),
|
|
126
|
+
})
|
|
127
|
+
.optional(),
|
|
128
|
+
cdp: z.object({
|
|
129
|
+
product: z.string(),
|
|
130
|
+
protocol_version: z.string(),
|
|
131
|
+
user_agent: z.string(),
|
|
132
|
+
}),
|
|
133
|
+
}),
|
|
134
|
+
findings: z.array(FindingSchema),
|
|
135
|
+
})
|
|
136
|
+
.passthrough();
|
|
137
|
+
|
|
138
|
+
export type BotprintProfile = z.infer<typeof ProfileSchema>;
|
|
139
|
+
export type ProbeWarning = (typeof PROBE_WARNINGS)[number];
|
|
140
|
+
|
|
141
|
+
export async function readProfile(path: string): Promise<BotprintProfile> {
|
|
142
|
+
let raw: string;
|
|
143
|
+
try {
|
|
144
|
+
raw = await readFile(path, "utf8");
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
+
throw new BotprintError(`Could not read profile ${path}: ${message}`, EXIT_CODES.usage);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
return validateProfile(JSON.parse(raw));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof BotprintError) {
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
158
|
+
throw new BotprintError(`Profile is not valid JSON: ${message}`, EXIT_CODES.schemaValidation);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function writeProfile(path: string, profile: BotprintProfile): Promise<void> {
|
|
163
|
+
const validated = validateProfile(profile);
|
|
164
|
+
await writeFile(path, `${JSON.stringify(validated, null, 2)}\n`, "utf8");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function validateProfile(profile: unknown): BotprintProfile {
|
|
168
|
+
const result = ProfileSchema.safeParse(profile);
|
|
169
|
+
if (!result.success) {
|
|
170
|
+
const version = typeof profile === "object" && profile !== null ? (profile as { schema_version?: unknown }).schema_version : undefined;
|
|
171
|
+
const hint =
|
|
172
|
+
typeof version === "string" && version !== SCHEMA_VERSION
|
|
173
|
+
? ` Profile schema_version is ${version}; this Botprint reads ${SCHEMA_VERSION}. Regenerate the profile with \`botprint run\`.`
|
|
174
|
+
: "";
|
|
175
|
+
throw new BotprintError(`Profile validation failed: ${z.prettifyError(result.error)}${hint}`, EXIT_CODES.schemaValidation);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return result.data;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function endpointKind(endpoint: string): BotprintProfile["source"]["cdp_endpoint_kind"] {
|
|
182
|
+
const url = new URL(endpoint);
|
|
183
|
+
if (url.protocol === "http:") return "http";
|
|
184
|
+
if (url.protocol === "https:") return "https";
|
|
185
|
+
if (url.protocol === "ws:") return "ws";
|
|
186
|
+
if (url.protocol === "wss:") return "wss";
|
|
187
|
+
throw new BotprintError(`Unsupported CDP endpoint protocol: ${url.protocol}`, EXIT_CODES.usage);
|
|
188
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { isObject } from "./coerce";
|
|
2
|
+
import type { AbsentValue, CompareResult, ProfileDiff } from "./compare";
|
|
3
|
+
import { analyzeProfile, type Finding } from "./findings";
|
|
4
|
+
import { SCHEMA_VERSION, validateProfile, type BotprintProfile } from "./profile";
|
|
5
|
+
|
|
6
|
+
const MAX_REPORT_ITEMS = 5;
|
|
7
|
+
const MAX_AGENT_ITEMS = 4;
|
|
8
|
+
const AGENT_WORD_LIMIT = 200;
|
|
9
|
+
|
|
10
|
+
export function renderProfileMarkdown(profile: BotprintProfile, findings: Finding[]): string {
|
|
11
|
+
const lines = [
|
|
12
|
+
`# Botprint Profile: ${profileLabel(profile)}`,
|
|
13
|
+
"",
|
|
14
|
+
"## Summary",
|
|
15
|
+
`- Browser: ${profile.source.browser_version}`,
|
|
16
|
+
`- Target: ${profile.source.target_url}`,
|
|
17
|
+
`- Findings: ${findings.length}`,
|
|
18
|
+
"",
|
|
19
|
+
"## Browser Context",
|
|
20
|
+
`- Probe context: ${probeContextText(profile)}`,
|
|
21
|
+
`- User agent: ${profile.signals.identity.user_agent}`,
|
|
22
|
+
`- Platform: ${profile.signals.identity.platform}`,
|
|
23
|
+
`- Language: ${profile.signals.identity.language || "(empty)"}; languages=${JSON.stringify(profile.signals.identity.languages)}`,
|
|
24
|
+
`- Timezone: ${profile.signals.environment.timezone}`,
|
|
25
|
+
`- Viewport: ${profile.signals.environment.viewport.width}x${profile.signals.environment.viewport.height} @ ${profile.signals.environment.viewport.device_scale_factor}; outer ${profile.signals.environment.outer_window.width}x${profile.signals.environment.outer_window.height}`,
|
|
26
|
+
"",
|
|
27
|
+
"## Key Signals",
|
|
28
|
+
`- WebDriver: ${formatValue(profile.signals.automation.webdriver)}; window.chrome: ${profile.signals.automation.chrome_object}`,
|
|
29
|
+
`- WebGL: ${profile.signals.rendering.webgl.unmasked_renderer ?? profile.signals.rendering.webgl.renderer ?? profile.signals.rendering.webgl.status}`,
|
|
30
|
+
`- Canvas: ${profile.signals.rendering.canvas.status}; audio: ${profile.signals.rendering.audio.status}`,
|
|
31
|
+
`- Fonts: ${profile.signals.rendering.fonts.detected.length} of ${profile.signals.rendering.fonts.checked} common fonts detected`,
|
|
32
|
+
`- Storage: cookies=${profile.signals.storage.cookies_enabled}, indexedDB=${profile.signals.storage.indexed_db}`,
|
|
33
|
+
`- Accept-Language: ${profile.signals.http?.status === "captured" ? (profile.signals.http.request_headers["accept-language"] ?? "(not sent)") : "(not captured)"}`,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
if (profile.source.probe_warnings !== undefined && profile.source.probe_warnings.length > 0) {
|
|
37
|
+
lines.push("", "## Measurement Notes");
|
|
38
|
+
for (const warning of profile.source.probe_warnings) {
|
|
39
|
+
lines.push(`- ${profileWarningText(warning)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const top = topFindings(findings, MAX_REPORT_ITEMS);
|
|
44
|
+
|
|
45
|
+
if (top.length > 0) {
|
|
46
|
+
lines.push("", "## Top Findings");
|
|
47
|
+
if (findings.length > top.length) {
|
|
48
|
+
lines.push(`_Showing top ${top.length} of ${findings.length} findings. Use \`--json\` for the full finding list._`);
|
|
49
|
+
}
|
|
50
|
+
for (const finding of top) {
|
|
51
|
+
lines.push(`- **${finding.title}** (${finding.severity}): ${finding.evidence.join(" ")}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
lines.push("", "## Suggested Investigation");
|
|
56
|
+
if (top.length === 0) {
|
|
57
|
+
lines.push("- No notable findings. Compare this profile against a known-good baseline for drift.");
|
|
58
|
+
} else {
|
|
59
|
+
for (const finding of top) {
|
|
60
|
+
lines.push(`- ${finding.suggested_investigation}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function renderCompareMarkdown(result: CompareResult): string {
|
|
68
|
+
const warnings = result.verdict.warnings;
|
|
69
|
+
const lines = [
|
|
70
|
+
`# Botprint Compare: ${result.baseline.label} -> ${result.candidate.label}`,
|
|
71
|
+
"",
|
|
72
|
+
"## Summary",
|
|
73
|
+
`- Verdict: ${result.verdict.level}`,
|
|
74
|
+
`- Material: ${result.verdict.material ? "yes" : "no"}`,
|
|
75
|
+
`- Differences: ${result.diffs.length}`,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
if (result.verdict.categories.length > 0) {
|
|
79
|
+
lines.push(`- Categories: ${result.verdict.categories.join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (warnings.length > 0) {
|
|
83
|
+
lines.push(`- Warnings: ${warnings.map((warning) => warning.id).join(", ")}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (warnings.length > 0) {
|
|
87
|
+
lines.push("", "## Warnings");
|
|
88
|
+
for (const warning of warnings) {
|
|
89
|
+
lines.push(`- **${warning.id}**: ${warning.summary} ${warning.implications} ${formatEvidence(warning.evidence)}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (result.diffs.length === 0) {
|
|
94
|
+
lines.push("", "No differences found by the current comparison rules.");
|
|
95
|
+
return `${lines.join("\n")}\n`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
lines.push("", "## Top Differences");
|
|
99
|
+
const top = topDiffs(result.diffs, MAX_REPORT_ITEMS);
|
|
100
|
+
if (result.diffs.length > top.length) {
|
|
101
|
+
lines.push(`_Showing top ${top.length} of ${result.diffs.length} differences. Use \`--json\` for the full diff list._`);
|
|
102
|
+
}
|
|
103
|
+
for (const diff of top) {
|
|
104
|
+
lines.push(`- **${diff.summary}** (${diff.level}): ${diff.path} ${formatValue(diff.baseline)} -> ${formatValue(diff.candidate)}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
lines.push("", "## Interpretation");
|
|
108
|
+
lines.push(compareInterpretation(result));
|
|
109
|
+
|
|
110
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function renderAgentProfileExplanation(profile: BotprintProfile, findings: Finding[]): string {
|
|
114
|
+
const top = topFindings(findings, MAX_AGENT_ITEMS);
|
|
115
|
+
const parts = [
|
|
116
|
+
`Botprint profile for ${profileLabel(profile)} on ${profile.source.browser_version}.`,
|
|
117
|
+
`Context: ${profile.signals.identity.platform}, timezone ${profile.signals.environment.timezone}, viewport ${profile.signals.environment.viewport.width}x${profile.signals.environment.viewport.height}.`,
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
if (top.length === 0) {
|
|
121
|
+
parts.push("No notable findings were generated. Use compare against a known-good baseline to identify environment drift.");
|
|
122
|
+
} else {
|
|
123
|
+
parts.push(`Top findings: ${top.map((finding) => `${finding.id} (${finding.severity})`).join("; ")}.`);
|
|
124
|
+
parts.push(`Investigate: ${top.map((finding) => finding.suggested_investigation).join(" ")}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (profile.source.probe_warnings !== undefined && profile.source.probe_warnings.length > 0) {
|
|
128
|
+
parts.push(`Measurement notes: ${profile.source.probe_warnings.map(profileWarningText).join(" ")}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return `${limitWords(parts.join(" "), AGENT_WORD_LIMIT)}\n`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function renderAgentCompareExplanation(result: CompareResult): string {
|
|
135
|
+
const top = topDiffs(result.diffs, MAX_AGENT_ITEMS);
|
|
136
|
+
const warnings = result.verdict.warnings;
|
|
137
|
+
const parts = [
|
|
138
|
+
`Botprint compare ${result.baseline.label} -> ${result.candidate.label}: verdict ${result.verdict.level}, material=${result.verdict.material}.`,
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
if (top.length === 0) {
|
|
142
|
+
parts.push("No differences were found by the current comparison rules.");
|
|
143
|
+
} else {
|
|
144
|
+
parts.push(`Categories: ${result.verdict.categories.join(", ")}.`);
|
|
145
|
+
if (warnings.length > 0) {
|
|
146
|
+
parts.push(`Warnings: ${warnings.map((warning) => `${warning.id} (${warning.summary.replace(/\.$/, "")})`).join("; ")}.`);
|
|
147
|
+
}
|
|
148
|
+
parts.push(`Top diffs: ${top.map((diff) => `${diff.path} ${formatValue(diff.baseline)} -> ${formatValue(diff.candidate)} (${diff.level})`).join("; ")}.`);
|
|
149
|
+
parts.push(compareInterpretation(result));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return `${limitWords(parts.join(" "), AGENT_WORD_LIMIT)}\n`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function renderAgentExplanation(input: unknown): string {
|
|
156
|
+
if (isCompareResult(input)) {
|
|
157
|
+
return renderAgentCompareExplanation(input);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const profile = validateProfile(input);
|
|
161
|
+
return renderAgentProfileExplanation(profile, analyzeProfile(profile));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isCompareResult(value: unknown): value is CompareResult {
|
|
165
|
+
if (!isObject(value)) return false;
|
|
166
|
+
const verdict = value.verdict;
|
|
167
|
+
return (
|
|
168
|
+
value.schema_version === SCHEMA_VERSION &&
|
|
169
|
+
Array.isArray(value.diffs) &&
|
|
170
|
+
isObject(value.baseline) &&
|
|
171
|
+
isObject(value.candidate) &&
|
|
172
|
+
isObject(verdict) &&
|
|
173
|
+
typeof verdict.level === "string" &&
|
|
174
|
+
typeof verdict.material === "boolean"
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function topFindings(findings: Finding[], limit: number): Finding[] {
|
|
179
|
+
return [...findings].sort((left, right) => severityRank(right.severity) - severityRank(left.severity)).slice(0, limit);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function severityRank(severity: Finding["severity"]): number {
|
|
183
|
+
switch (severity) {
|
|
184
|
+
case "high_confidence_automation_signal":
|
|
185
|
+
return 4;
|
|
186
|
+
case "compatibility_risk":
|
|
187
|
+
return 3;
|
|
188
|
+
case "warning":
|
|
189
|
+
return 2;
|
|
190
|
+
case "notable":
|
|
191
|
+
return 1;
|
|
192
|
+
default:
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function topDiffs(diffs: ProfileDiff[], limit: number): ProfileDiff[] {
|
|
198
|
+
return [...diffs].sort((left, right) => levelRank(right.level) - levelRank(left.level)).slice(0, limit);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function levelRank(level: ProfileDiff["level"]): number {
|
|
202
|
+
if (level === "high_confidence_automation") return 3;
|
|
203
|
+
if (level === "material") return 2;
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function compareInterpretation(result: CompareResult): string {
|
|
208
|
+
if (result.verdict.level === "none") {
|
|
209
|
+
return "The candidate matches the baseline across the current comparison rules.";
|
|
210
|
+
}
|
|
211
|
+
if (result.verdict.level === "advisory") {
|
|
212
|
+
return "The candidate differs from the baseline, but the current rules classify the differences as advisory by default.";
|
|
213
|
+
}
|
|
214
|
+
if (result.verdict.level === "high_confidence_automation") {
|
|
215
|
+
return "The candidate exposes high-confidence automation evidence compared with the baseline.";
|
|
216
|
+
}
|
|
217
|
+
return `The candidate differs materially from the baseline across ${result.verdict.categories.join(", ")} signals.`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function profileLabel(profile: BotprintProfile): string {
|
|
221
|
+
return profile.source.label ?? profile.source.browser_version;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function probeContextText(profile: BotprintProfile): string {
|
|
225
|
+
if (profile.source.probe_context === "existing-target") {
|
|
226
|
+
return `existing target ${profile.source.target_id ?? ""}`.trim();
|
|
227
|
+
}
|
|
228
|
+
const source = profile.source.browser_context_source;
|
|
229
|
+
if (source === "page-target") return "fresh page in the browser context of an existing page target";
|
|
230
|
+
if (source === "explicit") return "fresh page in an explicitly selected browser context";
|
|
231
|
+
return "fresh page in the browser default context";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function formatValue(value: unknown): string {
|
|
235
|
+
if (isAbsentValue(value)) return "<absent>";
|
|
236
|
+
return JSON.stringify(value);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isAbsentValue(value: unknown): value is AbsentValue {
|
|
240
|
+
return isObject(value) && value.kind === "absent";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function formatEvidence(value: Record<string, unknown>): string {
|
|
244
|
+
const entries = Object.entries(value);
|
|
245
|
+
if (entries.length === 0) return "";
|
|
246
|
+
return `(${entries.map(([key, item]) => `${key}=${formatValue(item)}`).join(", ")})`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function profileWarningText(warning: NonNullable<BotprintProfile["source"]["probe_warnings"]>[number]): string {
|
|
250
|
+
switch (warning) {
|
|
251
|
+
case "page-contamination-possible":
|
|
252
|
+
return "This existing-target profile may reflect page scripts, framework state, service workers, CSP, cookies, or application state.";
|
|
253
|
+
case "default-browser-context":
|
|
254
|
+
return "No page targets were open, so the fresh page ran in the browser default context. Framework context options (locale, timezone, viewport, permissions) are not reflected.";
|
|
255
|
+
case "multiple-browser-contexts":
|
|
256
|
+
return "Page targets span several browser contexts; the fresh page used the first one. Pass --context <id> to choose another.";
|
|
257
|
+
case "http-not-captured":
|
|
258
|
+
return "The browser's request for the diagnostic page was not observed, so HTTP request headers are absent.";
|
|
259
|
+
default:
|
|
260
|
+
return warning;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function limitWords(value: string, maxWords: number): string {
|
|
265
|
+
const words = value.trim().split(/\s+/);
|
|
266
|
+
if (words.length <= maxWords) return value.trim();
|
|
267
|
+
return `${words.slice(0, maxWords).join(" ")}...`;
|
|
268
|
+
}
|