cbrowser 8.10.0 → 9.1.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.
Files changed (35) hide show
  1. package/README.md +121 -0
  2. package/dist/analysis/accessibility-empathy.d.ts +32 -0
  3. package/dist/analysis/accessibility-empathy.d.ts.map +1 -0
  4. package/dist/analysis/accessibility-empathy.js +1005 -0
  5. package/dist/analysis/accessibility-empathy.js.map +1 -0
  6. package/dist/analysis/agent-ready-audit.d.ts +11 -0
  7. package/dist/analysis/agent-ready-audit.d.ts.map +1 -0
  8. package/dist/analysis/agent-ready-audit.js +900 -0
  9. package/dist/analysis/agent-ready-audit.js.map +1 -0
  10. package/dist/analysis/competitive-benchmark.d.ts +11 -0
  11. package/dist/analysis/competitive-benchmark.d.ts.map +1 -0
  12. package/dist/analysis/competitive-benchmark.js +1122 -0
  13. package/dist/analysis/competitive-benchmark.js.map +1 -0
  14. package/dist/analysis/index.d.ts +5 -1
  15. package/dist/analysis/index.d.ts.map +1 -1
  16. package/dist/analysis/index.js +5 -1
  17. package/dist/analysis/index.js.map +1 -1
  18. package/dist/cli.js +191 -2
  19. package/dist/cli.js.map +1 -1
  20. package/dist/cognitive/focus-hierarchies.d.ts +113 -0
  21. package/dist/cognitive/focus-hierarchies.d.ts.map +1 -0
  22. package/dist/cognitive/focus-hierarchies.js +500 -0
  23. package/dist/cognitive/focus-hierarchies.js.map +1 -0
  24. package/dist/mcp-server.d.ts.map +1 -1
  25. package/dist/mcp-server.js +100 -1
  26. package/dist/mcp-server.js.map +1 -1
  27. package/dist/personas.d.ts +14 -0
  28. package/dist/personas.d.ts.map +1 -1
  29. package/dist/personas.js +393 -0
  30. package/dist/personas.js.map +1 -1
  31. package/dist/types.d.ts +429 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js.map +1 -1
  34. package/docs/METHODOLOGY.md +245 -0
  35. package/package.json +1 -1
@@ -0,0 +1,1122 @@
1
+ /**
2
+ * Competitive UX Benchmark Module
3
+ *
4
+ * Run identical cognitive journeys across multiple sites simultaneously.
5
+ * Output: head-to-head comparison with friction analysis.
6
+ */
7
+ import { chromium } from "playwright";
8
+ import { getPersona, BUILTIN_PERSONAS } from "../personas.js";
9
+ import { getFocusHierarchy, inferTaskTypeFromGoal, calculateFocusPriority, getDistractionIgnoreRate, } from "../cognitive/focus-hierarchies.js";
10
+ // ============================================================================
11
+ // Scoring Methodology & Disclaimers
12
+ // ============================================================================
13
+ /**
14
+ * METHODOLOGY DISCLAIMER:
15
+ *
16
+ * CBrowser's competitive benchmark uses HEURISTIC estimates, not precise measurements.
17
+ * Scores are derived from behavioral simulation and pattern detection, calibrated against
18
+ * published UX research where available.
19
+ *
20
+ * Research-backed thresholds used:
21
+ * - Page load abandonment: 53% at 3s (Google/SOASTA 2017)
22
+ * - Form complexity: 7-8 fields optimal (Baymard Institute)
23
+ * - Rage click detection: 3+ clicks in 1-2s indicates frustration (FullStory)
24
+ *
25
+ * Heuristic estimates (interpret as directional, not precise):
26
+ * - Abandonment Risk: Letter grade (A-F) based on friction accumulation
27
+ * - Confusion/Frustration: Relative scale based on detected friction points
28
+ * - Site Score: Weighted composite for RANKING purposes only
29
+ *
30
+ * These scores are useful for comparing sites RELATIVE to each other,
31
+ * not as absolute measurements of user behavior.
32
+ */
33
+ // 1-10 scale with descriptive labels for abandonment risk
34
+ const ABANDONMENT_RISK_LABELS = [
35
+ { max: 10, score: 1, label: "Very Low" },
36
+ { max: 20, score: 2, label: "Very Low" },
37
+ { max: 30, score: 3, label: "Low" },
38
+ { max: 40, score: 4, label: "Low-Medium" },
39
+ { max: 50, score: 5, label: "Medium" },
40
+ { max: 60, score: 6, label: "Medium" },
41
+ { max: 70, score: 7, label: "Medium-High" },
42
+ { max: 80, score: 8, label: "High" },
43
+ { max: 90, score: 9, label: "Very High" },
44
+ { max: 100, score: 10, label: "Very High" },
45
+ ];
46
+ /**
47
+ * Simulate a user journey toward a goal
48
+ */
49
+ async function simulateJourney(page, url, goal, persona, maxSteps, maxTime) {
50
+ const startTime = Date.now();
51
+ const state = {
52
+ patience: 100,
53
+ frustration: 0,
54
+ confusion: 0,
55
+ steps: 0,
56
+ frictionPoints: [],
57
+ startTime,
58
+ };
59
+ let goalAchieved = false;
60
+ let abandonmentReason;
61
+ const screenshots = {
62
+ start: '',
63
+ end: '',
64
+ };
65
+ try {
66
+ // Navigate to starting URL
67
+ await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
68
+ await page.waitForTimeout(1000);
69
+ // Take start screenshot
70
+ const startBuffer = await page.screenshot();
71
+ screenshots.start = startBuffer.toString("base64");
72
+ // Check initial page state
73
+ const initialAnalysis = await analyzePageForGoal(page, goal);
74
+ if (initialAnalysis.goalReached) {
75
+ goalAchieved = true;
76
+ }
77
+ else {
78
+ // Simulate exploration toward goal
79
+ for (let step = 0; step < maxSteps && !goalAchieved; step++) {
80
+ // Check time limit
81
+ if (Date.now() - startTime > maxTime * 1000) {
82
+ abandonmentReason = "Time limit exceeded";
83
+ break;
84
+ }
85
+ state.steps++;
86
+ // Analyze current page for actions
87
+ const pageAnalysis = await analyzePageForGoal(page, goal);
88
+ if (pageAnalysis.goalReached) {
89
+ goalAchieved = true;
90
+ break;
91
+ }
92
+ // Check for friction points
93
+ if (pageAnalysis.frictionPoints.length > 0) {
94
+ state.frictionPoints.push(...pageAnalysis.frictionPoints);
95
+ state.patience -= pageAnalysis.frictionPoints.length * 5;
96
+ state.frustration += pageAnalysis.frictionPoints.length * 3;
97
+ }
98
+ // Try to take an action toward the goal
99
+ const action = await findBestAction(page, goal, pageAnalysis);
100
+ if (!action) {
101
+ state.confusion += 10;
102
+ state.patience -= 5;
103
+ if (state.patience <= 20 || state.confusion >= 70) {
104
+ abandonmentReason = "User became confused and lost";
105
+ break;
106
+ }
107
+ // Try scrolling to find more options
108
+ await page.evaluate(() => window.scrollBy(0, 300));
109
+ await page.waitForTimeout(500);
110
+ continue;
111
+ }
112
+ // Execute the action
113
+ try {
114
+ await executeAction(page, action);
115
+ await page.waitForTimeout(1000);
116
+ // Patience recovery on success
117
+ state.patience = Math.min(100, state.patience + 2);
118
+ }
119
+ catch (e) {
120
+ state.frictionPoints.push(`Failed to ${action.type}: ${action.target}`);
121
+ state.frustration += 5;
122
+ state.patience -= 10;
123
+ if (state.patience <= 10) {
124
+ abandonmentReason = "Too many failed interactions";
125
+ break;
126
+ }
127
+ }
128
+ // Check for patience exhaustion
129
+ if (state.patience <= 0) {
130
+ abandonmentReason = "User ran out of patience";
131
+ break;
132
+ }
133
+ }
134
+ }
135
+ // Take end screenshot
136
+ const endBuffer = await page.screenshot();
137
+ screenshots.end = endBuffer.toString("base64");
138
+ }
139
+ catch (e) {
140
+ abandonmentReason = `Error: ${e.message}`;
141
+ }
142
+ // Extract site name from URL
143
+ const siteName = new URL(url).hostname.replace('www.', '');
144
+ return {
145
+ url,
146
+ siteName,
147
+ goalAchieved,
148
+ abandonmentReason,
149
+ totalTime: Date.now() - startTime,
150
+ stepCount: state.steps,
151
+ frictionPoints: [...new Set(state.frictionPoints)], // Dedupe
152
+ confusionLevel: Math.round(state.confusion),
153
+ frustrationLevel: Math.round(state.frustration),
154
+ abandonmentRisk: calculateAbandonmentRisk(state, goalAchieved),
155
+ screenshots,
156
+ };
157
+ }
158
+ /**
159
+ * Calculate abandonment risk as a letter grade.
160
+ *
161
+ * Research basis:
162
+ * - 53% abandon at 3s load time (Google/SOASTA)
163
+ * - 67% abandon with too many steps (Baymard)
164
+ * - Rage clicks (3+ in 1-2s) correlate with 6.5% frustration rate (FullStory)
165
+ *
166
+ * This is a HEURISTIC estimate for comparison purposes, not a prediction.
167
+ */
168
+ function calculateAbandonmentRisk(state, goalAchieved) {
169
+ if (goalAchieved)
170
+ return 0;
171
+ // Weighted risk factors (calibrated to research where possible)
172
+ let risk = 0;
173
+ // Patience depletion: primary driver (research shows patience exhaustion = abandonment)
174
+ risk += (100 - state.patience) * 0.4;
175
+ // Frustration accumulation: rage clicks correlate with ~6.5% session abandonment
176
+ risk += state.frustration * 0.3;
177
+ // Confusion: correlates with backtracking and eventual abandonment
178
+ risk += state.confusion * 0.3;
179
+ // Cap at 100
180
+ return Math.min(100, Math.round(risk));
181
+ }
182
+ /**
183
+ * Convert numeric risk to 1-10 scale with descriptive label.
184
+ * Returns { score: 1-10, label: "Low" | "Medium" | "High" etc. }
185
+ */
186
+ function getAbandonmentRiskRating(risk) {
187
+ for (const tier of ABANDONMENT_RISK_LABELS) {
188
+ if (risk <= tier.max) {
189
+ return { score: tier.score, label: tier.label };
190
+ }
191
+ }
192
+ return { score: 10, label: "Very High" };
193
+ }
194
+ async function analyzePageForGoal(page, goal) {
195
+ const goalLower = goal.toLowerCase();
196
+ const frictionPoints = [];
197
+ // Extract page content for analysis with position data for focus hierarchy
198
+ const pageData = await page.evaluate(() => {
199
+ const viewportHeight = window.innerHeight;
200
+ const viewportWidth = window.innerWidth;
201
+ // Helper: detect page area from element position and context
202
+ function detectArea(el) {
203
+ const rect = el.getBoundingClientRect();
204
+ const classes = el.className?.toString?.().toLowerCase() || '';
205
+ const id = el.id?.toLowerCase() || '';
206
+ const tagName = el.tagName.toLowerCase();
207
+ // Semantic detection first
208
+ if (el.closest('nav') || classes.includes('nav') || id.includes('nav') || el.closest('[role="navigation"]')) {
209
+ return 'navigation';
210
+ }
211
+ if (el.closest('form') || classes.includes('form')) {
212
+ return 'forms';
213
+ }
214
+ if (el.closest('footer') || classes.includes('footer') || id.includes('footer')) {
215
+ return 'footer';
216
+ }
217
+ if (el.closest('aside') || classes.includes('sidebar') || classes.includes('aside')) {
218
+ return 'sidebar';
219
+ }
220
+ if (tagName.match(/^h[1-6]$/) || el.closest('h1, h2, h3, h4, h5, h6')) {
221
+ return 'headings';
222
+ }
223
+ if (classes.includes('hero') || classes.includes('banner') || classes.includes('jumbotron')) {
224
+ return 'hero';
225
+ }
226
+ if (classes.includes('cta') || classes.includes('call-to-action') ||
227
+ (tagName === 'button' && rect.width > 100) ||
228
+ el.getAttribute?.('role') === 'button') {
229
+ return 'cta';
230
+ }
231
+ if (tagName === 'img' || el.closest('figure, picture')) {
232
+ return 'images';
233
+ }
234
+ if (classes.includes('search') || id.includes('search') || el.type === 'search') {
235
+ return 'search';
236
+ }
237
+ // Position-based detection
238
+ if (rect.top < 100) {
239
+ // Top of page - likely navigation or hero
240
+ return rect.height > 200 ? 'hero' : 'navigation';
241
+ }
242
+ if (rect.top > viewportHeight * 0.8) {
243
+ return 'footer';
244
+ }
245
+ if (rect.left < viewportWidth * 0.2 || rect.right > viewportWidth * 0.8) {
246
+ return 'sidebar';
247
+ }
248
+ // Default to content
249
+ return 'content';
250
+ }
251
+ const data = {
252
+ title: document.title,
253
+ url: window.location.href,
254
+ text: document.body.innerText.slice(0, 5000),
255
+ buttons: Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"]')).map(el => ({
256
+ text: el.textContent?.trim() || el.value || '',
257
+ selector: el.id ? `#${el.id}` : el.className ? `.${el.className.toString().split(' ')[0]}` : el.tagName.toLowerCase(),
258
+ visible: el.getBoundingClientRect().width > 0,
259
+ area: detectArea(el),
260
+ })),
261
+ links: Array.from(document.querySelectorAll('a[href]')).map(el => ({
262
+ text: el.textContent?.trim() || '',
263
+ href: el.href,
264
+ selector: el.id ? `#${el.id}` : `a[href="${el.getAttribute('href')}"]`,
265
+ visible: el.getBoundingClientRect().width > 0,
266
+ area: detectArea(el),
267
+ })).slice(0, 50),
268
+ inputs: Array.from(document.querySelectorAll('input:not([type="hidden"]), textarea, select')).map(el => {
269
+ const input = el;
270
+ return {
271
+ type: input.type || 'text',
272
+ name: input.name,
273
+ placeholder: input.placeholder,
274
+ label: document.querySelector(`label[for="${input.id}"]`)?.textContent?.trim() || '',
275
+ selector: input.id ? `#${input.id}` : input.name ? `[name="${input.name}"]` : input.placeholder ? `[placeholder="${input.placeholder}"]` : 'input',
276
+ visible: el.getBoundingClientRect().width > 0,
277
+ area: detectArea(el),
278
+ };
279
+ }),
280
+ // Check for common friction indicators
281
+ hasPopup: !!document.querySelector('[class*="modal"], [class*="popup"], [role="dialog"]'),
282
+ hasError: !!document.querySelector('[class*="error"], .alert-danger, [role="alert"]'),
283
+ hasCaptcha: !!document.querySelector('[class*="captcha"], [class*="recaptcha"], iframe[src*="captcha"]'),
284
+ formCount: document.querySelectorAll('form').length,
285
+ };
286
+ return data;
287
+ });
288
+ // Detect friction points
289
+ if (pageData.hasPopup) {
290
+ frictionPoints.push("Popup/modal blocking content");
291
+ }
292
+ if (pageData.hasError) {
293
+ frictionPoints.push("Error message displayed");
294
+ }
295
+ if (pageData.hasCaptcha) {
296
+ frictionPoints.push("CAPTCHA blocking progress");
297
+ }
298
+ // Check if goal is reached based on semantic analysis
299
+ const goalKeywords = extractGoalKeywords(goal);
300
+ const pageText = pageData.text.toLowerCase();
301
+ const pageUrl = pageData.url.toLowerCase();
302
+ let goalReached = false;
303
+ // Action-oriented goal patterns (require completion confirmation)
304
+ if (goalLower.includes("sign up") || goalLower.includes("register")) {
305
+ goalReached = pageText.includes("welcome") ||
306
+ pageText.includes("account created") ||
307
+ pageText.includes("verify your email") ||
308
+ pageUrl.includes("dashboard") ||
309
+ pageUrl.includes("welcome");
310
+ }
311
+ else if (goalLower.includes("login") || goalLower.includes("sign in")) {
312
+ goalReached = pageUrl.includes("dashboard") ||
313
+ pageUrl.includes("account") ||
314
+ pageText.includes("welcome back");
315
+ }
316
+ else if (goalLower.includes("checkout") || goalLower.includes("purchase")) {
317
+ goalReached = pageText.includes("order confirmed") ||
318
+ pageText.includes("thank you for your") ||
319
+ pageUrl.includes("confirmation");
320
+ }
321
+ else {
322
+ // Information-seeking goals: "find X", "search for X", "locate X", or generic
323
+ // Extract the subject being sought (remove action verbs)
324
+ const actionVerbs = ["find", "search", "locate", "look", "get", "see", "view", "check", "learn", "discover"];
325
+ const subjectKeywords = goalKeywords.filter(kw => !actionVerbs.includes(kw));
326
+ // Expand subject keywords with synonyms (e.g., "requirements" -> also check "eligibility", "criteria")
327
+ const expandedKeywords = expandKeywordsWithSynonyms(subjectKeywords);
328
+ // If we have subject keywords, check for their presence and density
329
+ if (subjectKeywords.length > 0) {
330
+ // Count how many expanded keywords (including synonyms) appear in the page
331
+ const keywordsFound = expandedKeywords.filter(kw => pageText.includes(kw));
332
+ // For ratio, compare against original subject count (finding ANY synonym counts)
333
+ // If we find synonyms for a concept, that concept is "found"
334
+ const conceptsFound = new Set();
335
+ for (const kw of subjectKeywords) {
336
+ // Check if original keyword or any of its synonyms appear
337
+ const synonyms = expandKeywordsWithSynonyms([kw]);
338
+ if (synonyms.some(syn => pageText.includes(syn))) {
339
+ conceptsFound.add(kw);
340
+ }
341
+ }
342
+ const keywordRatio = conceptsFound.size / subjectKeywords.length;
343
+ // Also check keyword density (multiple mentions = more relevant)
344
+ let totalMentions = 0;
345
+ for (const kw of expandedKeywords) {
346
+ const regex = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
347
+ const matches = pageText.match(regex);
348
+ totalMentions += matches ? matches.length : 0;
349
+ }
350
+ // Goal reached if:
351
+ // - At least 50% of subject CONCEPTS are present (via original or synonym), AND
352
+ // - Keywords/synonyms are mentioned at least 3x total (indicates actual content)
353
+ goalReached = keywordRatio >= 0.5 && totalMentions >= 3;
354
+ // Also check if URL contains subject keywords (strong signal)
355
+ const urlHasSubject = expandedKeywords.some(kw => pageUrl.includes(kw));
356
+ if (urlHasSubject && keywordRatio >= 0.4) {
357
+ goalReached = true;
358
+ }
359
+ }
360
+ else {
361
+ // Fallback: all keywords are action verbs, just check if we're on a relevant page
362
+ goalReached = goalKeywords.some(kw => pageUrl.includes(kw));
363
+ }
364
+ }
365
+ // Build action candidates
366
+ const availableActions = [];
367
+ // Score buttons by relevance to goal (with area detection)
368
+ for (const btn of pageData.buttons.filter(b => b.visible)) {
369
+ const textLower = btn.text.toLowerCase();
370
+ let relevance = 0;
371
+ for (const keyword of goalKeywords) {
372
+ if (textLower.includes(keyword))
373
+ relevance += 0.3;
374
+ }
375
+ // Common action words
376
+ if (textLower.match(/submit|continue|next|proceed|sign|login|create|register|add|buy|checkout/)) {
377
+ relevance += 0.2;
378
+ }
379
+ if (relevance > 0 || btn.text.length > 0) {
380
+ availableActions.push({
381
+ type: "click",
382
+ target: btn.text || "button",
383
+ selector: btn.selector,
384
+ relevance: Math.min(1, relevance),
385
+ area: btn.area,
386
+ text: btn.text,
387
+ });
388
+ }
389
+ }
390
+ // Score links by relevance (with area detection)
391
+ for (const link of pageData.links.filter(l => l.visible)) {
392
+ const textLower = link.text.toLowerCase();
393
+ let relevance = 0;
394
+ for (const keyword of goalKeywords) {
395
+ if (textLower.includes(keyword) || link.href.toLowerCase().includes(keyword)) {
396
+ relevance += 0.3;
397
+ }
398
+ }
399
+ if (relevance > 0) {
400
+ availableActions.push({
401
+ type: "click",
402
+ target: link.text || link.href,
403
+ selector: link.selector,
404
+ relevance: Math.min(1, relevance),
405
+ area: link.area,
406
+ text: link.text,
407
+ });
408
+ }
409
+ }
410
+ // Score inputs (with area detection)
411
+ for (const input of pageData.inputs.filter(i => i.visible)) {
412
+ const labelLower = (input.label + input.placeholder + input.name).toLowerCase();
413
+ let relevance = 0;
414
+ for (const keyword of goalKeywords) {
415
+ if (labelLower.includes(keyword))
416
+ relevance += 0.2;
417
+ }
418
+ // Common form fields
419
+ if (labelLower.match(/email|password|name|username|search/)) {
420
+ relevance += 0.3;
421
+ }
422
+ if (relevance > 0) {
423
+ availableActions.push({
424
+ type: "fill",
425
+ target: input.label || input.placeholder || input.name || input.type,
426
+ selector: input.selector,
427
+ relevance: Math.min(1, relevance),
428
+ area: input.area,
429
+ text: input.label || input.placeholder,
430
+ });
431
+ }
432
+ }
433
+ // Sort by relevance (focus hierarchy applied later in findBestAction)
434
+ availableActions.sort((a, b) => b.relevance - a.relevance);
435
+ return {
436
+ goalReached,
437
+ frictionPoints,
438
+ availableActions,
439
+ pageContent: pageData.text.slice(0, 1000),
440
+ };
441
+ }
442
+ function extractGoalKeywords(goal) {
443
+ const stopWords = ['a', 'an', 'the', 'to', 'for', 'on', 'in', 'at', 'and', 'or', 'of', 'with'];
444
+ return goal.toLowerCase()
445
+ .split(/\s+/)
446
+ .filter(word => word.length > 2 && !stopWords.includes(word));
447
+ }
448
+ // Synonym map for common goal terms - expands keywords to include related terms
449
+ const KEYWORD_SYNONYMS = {
450
+ requirements: ['requirements', 'eligibility', 'criteria', 'prerequisites', 'qualifications', 'what you need', 'how to apply', 'checklist'],
451
+ application: ['application', 'apply', 'applying', 'submit', 'enrollment', 'enroll'],
452
+ admission: ['admission', 'admissions', 'accepted', 'acceptance', 'enrolled'],
453
+ cost: ['cost', 'tuition', 'fees', 'price', 'pricing', 'financial', 'payment'],
454
+ deadline: ['deadline', 'due date', 'dates', 'when to apply', 'timeline'],
455
+ contact: ['contact', 'email', 'phone', 'reach', 'support', 'help'],
456
+ international: ['international', 'global', 'foreign', 'overseas', 'visa', 'i-20'],
457
+ };
458
+ function expandKeywordsWithSynonyms(keywords) {
459
+ const expanded = new Set();
460
+ for (const kw of keywords) {
461
+ expanded.add(kw);
462
+ // Check if this keyword has synonyms
463
+ if (KEYWORD_SYNONYMS[kw]) {
464
+ for (const syn of KEYWORD_SYNONYMS[kw]) {
465
+ expanded.add(syn);
466
+ }
467
+ }
468
+ // Also check if keyword is IN a synonym list (reverse lookup)
469
+ for (const [key, syns] of Object.entries(KEYWORD_SYNONYMS)) {
470
+ if (syns.includes(kw)) {
471
+ expanded.add(key);
472
+ for (const syn of syns) {
473
+ expanded.add(syn);
474
+ }
475
+ }
476
+ }
477
+ }
478
+ return Array.from(expanded);
479
+ }
480
+ /**
481
+ * Find the best action using focus hierarchy weighting.
482
+ *
483
+ * This applies probabilistic focus patterns to simulate how real users
484
+ * prioritize different page areas based on their task type.
485
+ *
486
+ * Key behaviors:
487
+ * - Information-seeking tasks prioritize headings/navigation
488
+ * - Action-completion tasks prioritize forms/CTAs
489
+ * - Common distractions (newsletter popups, etc.) are filtered
490
+ */
491
+ async function findBestAction(page, goal, analysis) {
492
+ if (analysis.availableActions.length === 0)
493
+ return null;
494
+ // Infer task type from goal and get appropriate focus hierarchy
495
+ const taskType = inferTaskTypeFromGoal(goal);
496
+ const focusHierarchy = getFocusHierarchy(taskType);
497
+ // Apply focus hierarchy weighting to each action
498
+ const weightedActions = analysis.availableActions.map(action => {
499
+ // Calculate focus priority based on area and distraction filtering
500
+ const focusPriority = calculateFocusPriority({
501
+ area: action.area,
502
+ text: action.text,
503
+ selector: action.selector,
504
+ isRelevantToGoal: action.relevance > 0.3,
505
+ }, focusHierarchy);
506
+ // Check if this action should be filtered as a distraction
507
+ const distractionRate = getDistractionIgnoreRate({ text: action.text, selector: action.selector }, focusHierarchy.distractionFilters);
508
+ // Probabilistic filtering: randomly skip distractions
509
+ const skipAsDistraction = Math.random() < distractionRate;
510
+ // Combined score: relevance * focus priority, with distraction penalty
511
+ const combinedScore = skipAsDistraction
512
+ ? 0 // Filtered out as distraction
513
+ : action.relevance * focusPriority;
514
+ return {
515
+ action,
516
+ focusPriority,
517
+ distractionRate,
518
+ combinedScore,
519
+ skipped: skipAsDistraction,
520
+ };
521
+ });
522
+ // Filter out skipped distractions
523
+ const viableActions = weightedActions.filter(w => !w.skipped);
524
+ if (viableActions.length === 0) {
525
+ // All actions were filtered as distractions - fall back to nav-first
526
+ // This happens when page is full of popups/banners but has some nav
527
+ const navActions = weightedActions.filter(w => w.action.area === 'navigation' || w.action.area === 'headings');
528
+ if (navActions.length > 0) {
529
+ // Force through navigation even if it was a distraction
530
+ navActions.sort((a, b) => b.action.relevance - a.action.relevance);
531
+ return navActions[0].action;
532
+ }
533
+ // Absolute fallback: return highest relevance regardless of focus
534
+ return analysis.availableActions[0];
535
+ }
536
+ // Sort by combined score (focus-weighted relevance)
537
+ viableActions.sort((a, b) => b.combinedScore - a.combinedScore);
538
+ // Apply attention capacity limit (humans don't consider all options)
539
+ const consideredActions = viableActions.slice(0, focusHierarchy.attentionCapacity);
540
+ // Add slight randomness to simulate human variability
541
+ // (Sometimes users pick 2nd or 3rd best option)
542
+ const randomFactor = Math.random();
543
+ if (randomFactor < 0.7 && consideredActions.length > 0) {
544
+ return consideredActions[0].action;
545
+ }
546
+ else if (randomFactor < 0.9 && consideredActions.length > 1) {
547
+ return consideredActions[1].action;
548
+ }
549
+ else if (consideredActions.length > 2) {
550
+ return consideredActions[2].action;
551
+ }
552
+ return consideredActions[0]?.action ?? null;
553
+ }
554
+ async function executeAction(page, action) {
555
+ switch (action.type) {
556
+ case "click":
557
+ await page.click(action.selector, { timeout: 5000 });
558
+ break;
559
+ case "fill":
560
+ // Generate test data based on field type
561
+ const testValue = generateTestValue(action.target);
562
+ await page.fill(action.selector, testValue, { timeout: 5000 });
563
+ break;
564
+ case "scroll":
565
+ await page.evaluate(() => window.scrollBy(0, 500));
566
+ break;
567
+ }
568
+ }
569
+ function generateTestValue(fieldHint) {
570
+ const hint = fieldHint.toLowerCase();
571
+ if (hint.includes("email"))
572
+ return "test@example.com";
573
+ if (hint.includes("password"))
574
+ return "TestPassword123!";
575
+ if (hint.includes("name"))
576
+ return "Test User";
577
+ if (hint.includes("phone"))
578
+ return "555-123-4567";
579
+ if (hint.includes("search"))
580
+ return "test search";
581
+ return "test value";
582
+ }
583
+ // ============================================================================
584
+ // Comparison and Ranking
585
+ // ============================================================================
586
+ function calculateSiteScore(result) {
587
+ let score = 0;
588
+ // Goal achieved is worth a lot
589
+ if (result.goalAchieved)
590
+ score += 50;
591
+ // Faster is better (up to 30 points for speed)
592
+ const timeScore = Math.max(0, 30 - (result.totalTime / 1000 / 2));
593
+ score += timeScore;
594
+ // Fewer steps is better (up to 10 points)
595
+ const stepScore = Math.max(0, 10 - result.stepCount);
596
+ score += stepScore;
597
+ // Less friction is better (up to 10 points)
598
+ const frictionScore = Math.max(0, 10 - result.frictionPoints.length * 2);
599
+ score += frictionScore;
600
+ return Math.round(score);
601
+ }
602
+ function generateRankings(results) {
603
+ const scored = results.map(r => ({
604
+ result: r,
605
+ score: calculateSiteScore(r),
606
+ }));
607
+ // Sort by score descending
608
+ scored.sort((a, b) => b.score - a.score);
609
+ return scored.map((item, index) => {
610
+ // Determine strengths and weaknesses
611
+ const strengths = [];
612
+ const weaknesses = [];
613
+ const avgTime = results.reduce((sum, r) => sum + r.totalTime, 0) / results.length;
614
+ const avgSteps = results.reduce((sum, r) => sum + r.stepCount, 0) / results.length;
615
+ const avgFriction = results.reduce((sum, r) => sum + r.frictionPoints.length, 0) / results.length;
616
+ if (item.result.goalAchieved) {
617
+ strengths.push("Goal achieved successfully");
618
+ }
619
+ else {
620
+ weaknesses.push("Failed to achieve goal");
621
+ }
622
+ if (item.result.totalTime < avgTime * 0.8) {
623
+ strengths.push("Faster than average");
624
+ }
625
+ else if (item.result.totalTime > avgTime * 1.2) {
626
+ weaknesses.push("Slower than average");
627
+ }
628
+ if (item.result.stepCount < avgSteps * 0.8) {
629
+ strengths.push("Fewer steps needed");
630
+ }
631
+ else if (item.result.stepCount > avgSteps * 1.2) {
632
+ weaknesses.push("Too many steps required");
633
+ }
634
+ if (item.result.frictionPoints.length < avgFriction * 0.8) {
635
+ strengths.push("Low friction experience");
636
+ }
637
+ else if (item.result.frictionPoints.length > avgFriction * 1.2) {
638
+ weaknesses.push("High friction experience");
639
+ }
640
+ return {
641
+ rank: index + 1,
642
+ site: item.result.siteName,
643
+ score: item.score,
644
+ strengths,
645
+ weaknesses,
646
+ };
647
+ });
648
+ }
649
+ function generateComparison(results) {
650
+ const sorted = [...results];
651
+ // Find fastest/slowest
652
+ sorted.sort((a, b) => a.totalTime - b.totalTime);
653
+ const fastestSite = sorted[0].siteName;
654
+ const slowestSite = sorted[sorted.length - 1].siteName;
655
+ // Find most/least friction
656
+ sorted.sort((a, b) => a.frictionPoints.length - b.frictionPoints.length);
657
+ const leastFriction = sorted[0].siteName;
658
+ const mostFriction = sorted[sorted.length - 1].siteName;
659
+ // Find highest abandonment risk
660
+ sorted.sort((a, b) => b.abandonmentRisk - a.abandonmentRisk);
661
+ const highestAbandonmentRisk = sorted[0].siteName;
662
+ // Find common friction points
663
+ const frictionCounts = new Map();
664
+ for (const result of results) {
665
+ for (const friction of result.frictionPoints) {
666
+ frictionCounts.set(friction, (frictionCounts.get(friction) || 0) + 1);
667
+ }
668
+ }
669
+ const commonFrictionAcrossSites = Array.from(frictionCounts.entries())
670
+ .filter(([_, count]) => count >= results.length / 2)
671
+ .map(([friction, _]) => friction);
672
+ return {
673
+ fastestSite,
674
+ slowestSite,
675
+ mostFriction,
676
+ leastFriction,
677
+ highestAbandonmentRisk,
678
+ commonFrictionAcrossSites,
679
+ };
680
+ }
681
+ function generateCompetitiveRecommendations(results, ranking) {
682
+ const recommendations = [];
683
+ const bestSite = ranking[0];
684
+ for (const result of results) {
685
+ if (result.siteName === bestSite.site)
686
+ continue; // Skip the winner
687
+ const thisRanking = ranking.find(r => r.site === result.siteName);
688
+ if (!thisRanking)
689
+ continue;
690
+ // Generate recommendations based on weaknesses
691
+ for (const weakness of thisRanking.weaknesses) {
692
+ let improvement = "";
693
+ let reference = "";
694
+ if (weakness.includes("Failed to achieve")) {
695
+ improvement = "Improve conversion flow to ensure users can complete the goal";
696
+ if (bestSite.strengths.includes("Goal achieved successfully")) {
697
+ reference = `${bestSite.site} successfully guides users to goal completion`;
698
+ }
699
+ }
700
+ else if (weakness.includes("Slower")) {
701
+ improvement = "Reduce time-to-completion by streamlining steps";
702
+ const fastest = results.reduce((a, b) => a.totalTime < b.totalTime ? a : b);
703
+ reference = `${fastest.siteName} completes in ${(fastest.totalTime / 1000).toFixed(1)}s`;
704
+ }
705
+ else if (weakness.includes("Too many steps")) {
706
+ improvement = "Reduce the number of steps required";
707
+ const fewestSteps = results.reduce((a, b) => a.stepCount < b.stepCount ? a : b);
708
+ reference = `${fewestSteps.siteName} only requires ${fewestSteps.stepCount} steps`;
709
+ }
710
+ else if (weakness.includes("High friction")) {
711
+ improvement = "Reduce friction points in the user flow";
712
+ const leastFriction = results.reduce((a, b) => a.frictionPoints.length < b.frictionPoints.length ? a : b);
713
+ reference = `${leastFriction.siteName} has fewer friction points`;
714
+ }
715
+ if (improvement) {
716
+ recommendations.push({
717
+ site: result.siteName,
718
+ improvement,
719
+ competitorReference: reference || undefined,
720
+ });
721
+ }
722
+ }
723
+ // Add friction-specific recommendations
724
+ for (const friction of result.frictionPoints) {
725
+ recommendations.push({
726
+ site: result.siteName,
727
+ improvement: `Fix: ${friction}`,
728
+ });
729
+ }
730
+ }
731
+ return recommendations;
732
+ }
733
+ // ============================================================================
734
+ // Report Generation
735
+ // ============================================================================
736
+ export function formatCompetitiveBenchmarkReport(result) {
737
+ let report = `
738
+ ╔══════════════════════════════════════════════════════════════════════════════╗
739
+ ║ COMPETITIVE UX BENCHMARK ║
740
+ ╚══════════════════════════════════════════════════════════════════════════════╝
741
+
742
+ Goal: "${result.goal}"
743
+ Persona: ${result.persona}
744
+ Timestamp: ${result.timestamp}
745
+ Total Duration: ${(result.duration / 1000).toFixed(1)}s
746
+
747
+ ⚠️ METHODOLOGY NOTE: Scores are heuristic estimates for RELATIVE comparison.
748
+
749
+ ABANDONMENT RISK SCALE (1-10):
750
+ 1-2 Very Low │ Users likely to complete. Smooth experience.
751
+ 3-4 Low │ Minor friction. Most users persist.
752
+ 5-6 Medium │ Noticeable friction. Some users may leave.
753
+ 7-8 High │ Significant obstacles. Many users abandon.
754
+ 9-10 Very High │ Critical barriers. Most users will leave.
755
+
756
+ See docs/METHODOLOGY.md for research sources.
757
+
758
+ ┌────────────────────────────────────────────────────────────────────────────┐
759
+ │ RANKING │
760
+ ├────────────────────────────────────────────────────────────────────────────┤
761
+ `;
762
+ const medals = ['🥇', '🥈', '🥉'];
763
+ for (const site of result.ranking) {
764
+ const medal = medals[site.rank - 1] || `#${site.rank}`;
765
+ const siteResult = result.sites.find(s => s.siteName === site.site);
766
+ const riskRating = getAbandonmentRiskRating(siteResult.abandonmentRisk);
767
+ report += `│ ${medal} ${site.site.padEnd(20)} — ${(siteResult.totalTime / 1000).toFixed(1)}s, ${siteResult.stepCount} steps, Risk: ${riskRating.score}/10 (${riskRating.label})\n`;
768
+ }
769
+ report += `└────────────────────────────────────────────────────────────────────────────┘
770
+
771
+ DETAILED COMPARISON
772
+ ───────────────────
773
+ `;
774
+ // Build comparison table
775
+ const metrics = ['Time', 'Steps', 'Friction', 'Confusion', 'Goal'];
776
+ const headers = ['Metric', ...result.sites.map(s => s.siteName.slice(0, 15))];
777
+ const colWidth = 16;
778
+ report += headers.map(h => h.padEnd(colWidth)).join('') + '\n';
779
+ report += '─'.repeat(colWidth * headers.length) + '\n';
780
+ // Find best values for highlighting
781
+ const bestTime = Math.min(...result.sites.map(s => s.totalTime));
782
+ const bestSteps = Math.min(...result.sites.map(s => s.stepCount));
783
+ const bestFriction = Math.min(...result.sites.map(s => s.frictionPoints.length));
784
+ const bestConfusion = Math.min(...result.sites.map(s => s.confusionLevel));
785
+ for (const metric of metrics) {
786
+ const row = [metric];
787
+ for (const site of result.sites) {
788
+ let value = '';
789
+ let isBest = false;
790
+ switch (metric) {
791
+ case 'Time':
792
+ value = `${(site.totalTime / 1000).toFixed(1)}s`;
793
+ isBest = site.totalTime === bestTime;
794
+ break;
795
+ case 'Steps':
796
+ value = `${site.stepCount}`;
797
+ isBest = site.stepCount === bestSteps;
798
+ break;
799
+ case 'Friction':
800
+ value = `${site.frictionPoints.length}`;
801
+ isBest = site.frictionPoints.length === bestFriction;
802
+ break;
803
+ case 'Confusion':
804
+ value = `${site.confusionLevel}%`;
805
+ isBest = site.confusionLevel === bestConfusion;
806
+ break;
807
+ case 'Goal':
808
+ value = site.goalAchieved ? '✓' : '✗';
809
+ isBest = site.goalAchieved;
810
+ break;
811
+ }
812
+ row.push(isBest ? `${value} ✓` : value);
813
+ }
814
+ report += row.map(c => c.padEnd(colWidth)).join('') + '\n';
815
+ }
816
+ report += `
817
+ RECOMMENDATIONS
818
+ ───────────────
819
+ `;
820
+ const groupedRecs = new Map();
821
+ for (const rec of result.recommendations) {
822
+ if (!groupedRecs.has(rec.site))
823
+ groupedRecs.set(rec.site, []);
824
+ groupedRecs.get(rec.site).push(rec.improvement + (rec.competitorReference ? ` — ${rec.competitorReference}` : ''));
825
+ }
826
+ for (const [site, recs] of groupedRecs) {
827
+ report += `\n${site}:\n`;
828
+ for (const rec of recs.slice(0, 5)) {
829
+ report += ` • ${rec}\n`;
830
+ }
831
+ }
832
+ report += `
833
+ ─────────────────────────────────────────────────────────────────────────────
834
+ * Methodology and research sources: docs/METHODOLOGY.md
835
+ Key sources: Google/SOASTA (2017), Baymard Institute, Nielsen Norman Group
836
+
837
+ Generated by CBrowser v8.0.0 - Competitive UX Benchmark
838
+ `;
839
+ return report;
840
+ }
841
+ export function generateCompetitiveBenchmarkHtmlReport(result) {
842
+ const medals = ['🥇', '🥈', '🥉'];
843
+ const rankingRows = result.ranking.map((site, i) => {
844
+ const siteResult = result.sites.find(s => s.siteName === site.site);
845
+ const riskRating = getAbandonmentRiskRating(siteResult.abandonmentRisk);
846
+ const riskClass = riskRating.score <= 3 ? 'risk-low' :
847
+ riskRating.score <= 6 ? 'risk-medium' : 'risk-high';
848
+ return `
849
+ <tr class="${siteResult.goalAchieved ? 'success' : 'failure'}">
850
+ <td>${medals[i] || `#${site.rank}`}</td>
851
+ <td><strong>${site.site}</strong></td>
852
+ <td>${site.score}</td>
853
+ <td>${(siteResult.totalTime / 1000).toFixed(1)}s</td>
854
+ <td>${siteResult.stepCount}</td>
855
+ <td>${siteResult.frictionPoints.length}</td>
856
+ <td><span class="risk-score ${riskClass}">${riskRating.score}/10 <small>${riskRating.label}</small></span></td>
857
+ <td>${siteResult.goalAchieved ? '✓' : '✗'}</td>
858
+ </tr>
859
+ `;
860
+ }).join('');
861
+ const recommendationCards = Array.from(result.recommendations.reduce((map, rec) => {
862
+ if (!map.has(rec.site))
863
+ map.set(rec.site, []);
864
+ map.get(rec.site).push(rec);
865
+ return map;
866
+ }, new Map())).map(([site, recs]) => `
867
+ <div class="rec-site">
868
+ <h4>${site}</h4>
869
+ <ul>
870
+ ${recs.slice(0, 5).map(r => `
871
+ <li>
872
+ ${r.improvement}
873
+ ${r.competitorReference ? `<br><small class="reference">${r.competitorReference}</small>` : ''}
874
+ </li>
875
+ `).join('')}
876
+ </ul>
877
+ </div>
878
+ `).join('');
879
+ return `<!DOCTYPE html>
880
+ <html lang="en">
881
+ <head>
882
+ <meta charset="UTF-8">
883
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
884
+ <title>Competitive UX Benchmark - ${result.goal}</title>
885
+ <style>
886
+ * { box-sizing: border-box; }
887
+ body {
888
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
889
+ max-width: 1400px;
890
+ margin: 0 auto;
891
+ padding: 2rem;
892
+ background: #0f172a;
893
+ color: #e2e8f0;
894
+ }
895
+ h1 {
896
+ color: #f8fafc;
897
+ border-bottom: 3px solid #3b82f6;
898
+ padding-bottom: 0.5rem;
899
+ }
900
+ h2 { color: #94a3b8; margin-top: 2rem; }
901
+ .meta {
902
+ background: #1e293b;
903
+ padding: 1rem;
904
+ border-radius: 8px;
905
+ margin-bottom: 1rem;
906
+ }
907
+ .meta p { margin: 0.25rem 0; }
908
+ table {
909
+ width: 100%;
910
+ border-collapse: collapse;
911
+ background: #1e293b;
912
+ border-radius: 8px;
913
+ overflow: hidden;
914
+ margin: 1rem 0;
915
+ }
916
+ th, td {
917
+ padding: 0.75rem 1rem;
918
+ text-align: left;
919
+ border-bottom: 1px solid #334155;
920
+ }
921
+ th { background: #0f172a; color: #94a3b8; }
922
+ tr.success { background: #064e3b40; }
923
+ tr.failure { background: #7f1d1d40; }
924
+ .comparison-grid {
925
+ display: grid;
926
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
927
+ gap: 1rem;
928
+ margin: 1rem 0;
929
+ }
930
+ .stat-card {
931
+ background: #1e293b;
932
+ padding: 1rem;
933
+ border-radius: 8px;
934
+ text-align: center;
935
+ }
936
+ .stat-card .label {
937
+ font-size: 0.875rem;
938
+ color: #94a3b8;
939
+ }
940
+ .stat-card .value {
941
+ font-size: 1.5rem;
942
+ font-weight: bold;
943
+ color: #3b82f6;
944
+ }
945
+ .rec-site {
946
+ background: #1e293b;
947
+ border-radius: 8px;
948
+ padding: 1rem;
949
+ margin: 1rem 0;
950
+ }
951
+ .rec-site h4 {
952
+ margin-top: 0;
953
+ color: #f8fafc;
954
+ }
955
+ .rec-site ul {
956
+ margin: 0;
957
+ padding-left: 1.5rem;
958
+ }
959
+ .rec-site li {
960
+ margin: 0.5rem 0;
961
+ }
962
+ .reference {
963
+ color: #94a3b8;
964
+ }
965
+ .risk-score {
966
+ display: inline-block;
967
+ padding: 0.25rem 0.5rem;
968
+ border-radius: 4px;
969
+ font-weight: bold;
970
+ }
971
+ .risk-score small {
972
+ font-weight: normal;
973
+ opacity: 0.8;
974
+ }
975
+ .risk-low { background: #10b98133; color: #10b981; }
976
+ .risk-medium { background: #f59e0b33; color: #f59e0b; }
977
+ .risk-high { background: #ef444433; color: #ef4444; }
978
+ .disclaimer {
979
+ background: #1e3a5f;
980
+ border-left: 4px solid #3b82f6;
981
+ padding: 1rem;
982
+ margin: 1rem 0;
983
+ border-radius: 0 8px 8px 0;
984
+ }
985
+ .disclaimer h4 {
986
+ margin: 0 0 0.5rem 0;
987
+ color: #60a5fa;
988
+ }
989
+ .disclaimer p {
990
+ margin: 0.25rem 0;
991
+ font-size: 0.875rem;
992
+ color: #94a3b8;
993
+ }
994
+ </style>
995
+ </head>
996
+ <body>
997
+ <h1>📊 Competitive UX Benchmark</h1>
998
+
999
+ <div class="disclaimer">
1000
+ <h4>⚠️ Methodology Note</h4>
1001
+ <p>Scores are <strong>heuristic estimates</strong> for relative comparison between sites, not precise measurements.</p>
1002
+ <p><strong>Abandonment Risk Scale (1-10):</strong></p>
1003
+ <table style="width: 100%; margin: 0.5rem 0; font-size: 0.875rem;">
1004
+ <tr><td style="width: 80px;"><span class="risk-score risk-low">1-2</span></td><td><strong>Very Low</strong> — Users likely to complete. Smooth experience.</td></tr>
1005
+ <tr><td><span class="risk-score risk-low">3-4</span></td><td><strong>Low</strong> — Minor friction. Most users persist.</td></tr>
1006
+ <tr><td><span class="risk-score risk-medium">5-6</span></td><td><strong>Medium</strong> — Noticeable friction. Some users may leave.</td></tr>
1007
+ <tr><td><span class="risk-score risk-high">7-8</span></td><td><strong>High</strong> — Significant obstacles. Many users abandon.</td></tr>
1008
+ <tr><td><span class="risk-score risk-high">9-10</span></td><td><strong>Very High</strong> — Critical barriers. Most users will leave.</td></tr>
1009
+ </table>
1010
+ </div>
1011
+
1012
+ <div class="meta">
1013
+ <p><strong>Goal:</strong> "${result.goal}"</p>
1014
+ <p><strong>Persona:</strong> ${result.persona}</p>
1015
+ <p><strong>Timestamp:</strong> ${result.timestamp}</p>
1016
+ <p><strong>Duration:</strong> ${(result.duration / 1000).toFixed(1)}s</p>
1017
+ </div>
1018
+
1019
+ <h2>Ranking</h2>
1020
+ <table>
1021
+ <thead>
1022
+ <tr>
1023
+ <th>Rank</th>
1024
+ <th>Site</th>
1025
+ <th>Score</th>
1026
+ <th>Time</th>
1027
+ <th>Steps</th>
1028
+ <th>Friction</th>
1029
+ <th>Abandon Risk</th>
1030
+ <th>Goal</th>
1031
+ </tr>
1032
+ </thead>
1033
+ <tbody>
1034
+ ${rankingRows}
1035
+ </tbody>
1036
+ </table>
1037
+
1038
+ <h2>Quick Comparison</h2>
1039
+ <div class="comparison-grid">
1040
+ <div class="stat-card">
1041
+ <div class="label">Fastest</div>
1042
+ <div class="value">${result.comparison.fastestSite}</div>
1043
+ </div>
1044
+ <div class="stat-card">
1045
+ <div class="label">Slowest</div>
1046
+ <div class="value">${result.comparison.slowestSite}</div>
1047
+ </div>
1048
+ <div class="stat-card">
1049
+ <div class="label">Least Friction</div>
1050
+ <div class="value">${result.comparison.leastFriction}</div>
1051
+ </div>
1052
+ <div class="stat-card">
1053
+ <div class="label">Most Friction</div>
1054
+ <div class="value">${result.comparison.mostFriction}</div>
1055
+ </div>
1056
+ </div>
1057
+
1058
+ <h2>Recommendations</h2>
1059
+ ${recommendationCards}
1060
+
1061
+ <div class="footnote">
1062
+ <p>* Methodology and research sources: <a href="docs/METHODOLOGY.md" style="color: #60a5fa;">docs/METHODOLOGY.md</a></p>
1063
+ <p>Key sources: Google/SOASTA (2017), Baymard Institute, Nielsen Norman Group, FullStory</p>
1064
+ </div>
1065
+
1066
+ <p style="color: #64748b; text-align: center; margin-top: 2rem;">
1067
+ Generated by CBrowser v8.0.0 - Competitive UX Benchmark
1068
+ </p>
1069
+ </body>
1070
+ </html>`;
1071
+ }
1072
+ // ============================================================================
1073
+ // Main Benchmark Function
1074
+ // ============================================================================
1075
+ export async function runCompetitiveBenchmark(options) {
1076
+ const { sites, goal, persona = "first-timer", maxSteps = 30, maxTime = 180, headless = true, maxConcurrency = 3, } = options;
1077
+ const startTime = Date.now();
1078
+ const personaConfig = getPersona(persona) || BUILTIN_PERSONAS["first-timer"];
1079
+ // Run journeys in parallel (limited concurrency)
1080
+ const results = [];
1081
+ // Process sites in batches
1082
+ for (let i = 0; i < sites.length; i += maxConcurrency) {
1083
+ const batch = sites.slice(i, i + maxConcurrency);
1084
+ const batchResults = await Promise.all(batch.map(async (site) => {
1085
+ let browser = null;
1086
+ try {
1087
+ browser = await chromium.launch({ headless });
1088
+ const context = await browser.newContext({
1089
+ viewport: { width: 1920, height: 1080 },
1090
+ });
1091
+ const page = await context.newPage();
1092
+ const result = await simulateJourney(page, site.url, goal, personaConfig, maxSteps, maxTime);
1093
+ // Override site name if provided
1094
+ if (site.name) {
1095
+ result.siteName = site.name;
1096
+ }
1097
+ return result;
1098
+ }
1099
+ finally {
1100
+ if (browser) {
1101
+ await browser.close();
1102
+ }
1103
+ }
1104
+ }));
1105
+ results.push(...batchResults);
1106
+ }
1107
+ // Generate rankings and comparisons
1108
+ const ranking = generateRankings(results);
1109
+ const comparison = generateComparison(results);
1110
+ const recommendations = generateCompetitiveRecommendations(results, ranking);
1111
+ return {
1112
+ goal,
1113
+ persona,
1114
+ timestamp: new Date().toISOString(),
1115
+ duration: Date.now() - startTime,
1116
+ sites: results,
1117
+ ranking,
1118
+ comparison,
1119
+ recommendations,
1120
+ };
1121
+ }
1122
+ //# sourceMappingURL=competitive-benchmark.js.map