cbrowser 8.7.3 → 8.9.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/README.md +9 -2
- package/dist/analysis/bug-hunter.js +1 -4
- package/dist/analysis/bug-hunter.js.map +1 -1
- package/dist/analysis/chaos-testing.js +4 -8
- package/dist/analysis/chaos-testing.js.map +1 -1
- package/dist/analysis/index.js +4 -20
- package/dist/analysis/index.js.map +1 -1
- package/dist/analysis/natural-language.js +4 -10
- package/dist/analysis/natural-language.js.map +1 -1
- package/dist/analysis/persona-comparison.js +6 -10
- package/dist/analysis/persona-comparison.js.map +1 -1
- package/dist/browser.d.ts +50 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +665 -131
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +138 -131
- package/dist/cli.js.map +1 -1
- package/dist/cognitive/index.js +29 -40
- package/dist/cognitive/index.js.map +1 -1
- package/dist/config.js +50 -61
- package/dist/config.js.map +1 -1
- package/dist/daemon.js +28 -37
- package/dist/daemon.js.map +1 -1
- package/dist/index.js +10 -40
- package/dist/index.js.map +1 -1
- package/dist/mcp-server-remote.js +153 -156
- package/dist/mcp-server-remote.js.map +1 -1
- package/dist/mcp-server.js +146 -149
- package/dist/mcp-server.js.map +1 -1
- package/dist/performance/index.js +1 -17
- package/dist/performance/index.js.map +1 -1
- package/dist/performance/metrics.js +30 -39
- package/dist/performance/metrics.js.map +1 -1
- package/dist/personas.js +32 -46
- package/dist/personas.js.map +1 -1
- package/dist/testing/coverage.js +13 -23
- package/dist/testing/coverage.js.map +1 -1
- package/dist/testing/flaky-detection.js +4 -8
- package/dist/testing/flaky-detection.js.map +1 -1
- package/dist/testing/index.js +4 -20
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/nl-test-suite.js +11 -19
- package/dist/testing/nl-test-suite.js.map +1 -1
- package/dist/testing/test-repair.js +9 -15
- package/dist/testing/test-repair.js.map +1 -1
- package/dist/types.js +3 -6
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +9 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +60 -0
- package/dist/version.js.map +1 -0
- package/dist/visual/ab-comparison.js +16 -22
- package/dist/visual/ab-comparison.js.map +1 -1
- package/dist/visual/cross-browser.js +17 -24
- package/dist/visual/cross-browser.js.map +1 -1
- package/dist/visual/index.js +4 -20
- package/dist/visual/index.js.map +1 -1
- package/dist/visual/regression.js +46 -58
- package/dist/visual/regression.js.map +1 -1
- package/dist/visual/responsive.js +22 -29
- package/dist/visual/responsive.js.map +1 -1
- package/package.json +2 -1
package/dist/browser.js
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
/**
|
|
3
2
|
* CBrowser - Main Browser Class
|
|
4
3
|
*
|
|
5
4
|
* AI-powered browser automation with constitutional safety.
|
|
6
5
|
*/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const personas_js_1 = require("./personas.js");
|
|
14
|
-
const types_js_1 = require("./types.js");
|
|
6
|
+
import { chromium, firefox, webkit } from "playwright";
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync, mkdirSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { mergeConfig, getPaths, ensureDirectories } from "./config.js";
|
|
10
|
+
import { BUILTIN_PERSONAS, getPersona } from "./personas.js";
|
|
11
|
+
import { DEVICE_PRESETS, LOCATION_PRESETS } from "./types.js";
|
|
15
12
|
// Browser-specific fast launch args for performance optimization
|
|
16
13
|
const BROWSER_LAUNCH_ARGS = {
|
|
17
14
|
// Chromium args - reduces cold start time significantly
|
|
@@ -44,7 +41,7 @@ const BROWSER_LAUNCH_ARGS = {
|
|
|
44
41
|
};
|
|
45
42
|
// Legacy alias for backward compatibility
|
|
46
43
|
const FAST_LAUNCH_ARGS = BROWSER_LAUNCH_ARGS.chromium;
|
|
47
|
-
class CBrowser {
|
|
44
|
+
export class CBrowser {
|
|
48
45
|
config;
|
|
49
46
|
paths;
|
|
50
47
|
browser = null;
|
|
@@ -57,14 +54,14 @@ class CBrowser {
|
|
|
57
54
|
isRecordingHar = false;
|
|
58
55
|
skipSessionRestore = false;
|
|
59
56
|
constructor(userConfig = {}) {
|
|
60
|
-
this.config =
|
|
61
|
-
this.paths =
|
|
57
|
+
this.config = mergeConfig(userConfig);
|
|
58
|
+
this.paths = ensureDirectories(getPaths(this.config.dataDir));
|
|
62
59
|
}
|
|
63
60
|
// =========================================================================
|
|
64
61
|
// Session State Persistence
|
|
65
62
|
// =========================================================================
|
|
66
63
|
get sessionStateFile() {
|
|
67
|
-
return
|
|
64
|
+
return join(this.paths.dataDir, "browser-state", "last-session.json");
|
|
68
65
|
}
|
|
69
66
|
saveSessionState(url, viewport) {
|
|
70
67
|
try {
|
|
@@ -76,11 +73,11 @@ class CBrowser {
|
|
|
76
73
|
timestamp: Date.now(),
|
|
77
74
|
viewport,
|
|
78
75
|
};
|
|
79
|
-
const stateDir =
|
|
80
|
-
if (!
|
|
81
|
-
|
|
76
|
+
const stateDir = join(this.paths.dataDir, "browser-state");
|
|
77
|
+
if (!existsSync(stateDir)) {
|
|
78
|
+
mkdirSync(stateDir, { recursive: true });
|
|
82
79
|
}
|
|
83
|
-
|
|
80
|
+
writeFileSync(this.sessionStateFile, JSON.stringify(state, null, 2));
|
|
84
81
|
}
|
|
85
82
|
catch (e) {
|
|
86
83
|
// Silently fail - this is a best-effort feature
|
|
@@ -88,14 +85,14 @@ class CBrowser {
|
|
|
88
85
|
}
|
|
89
86
|
loadSessionState() {
|
|
90
87
|
try {
|
|
91
|
-
if (!
|
|
88
|
+
if (!existsSync(this.sessionStateFile))
|
|
92
89
|
return null;
|
|
93
|
-
const content =
|
|
90
|
+
const content = readFileSync(this.sessionStateFile, "utf-8");
|
|
94
91
|
const state = JSON.parse(content);
|
|
95
92
|
// Expire sessions older than 1 hour
|
|
96
93
|
const oneHour = 60 * 60 * 1000;
|
|
97
94
|
if (Date.now() - state.timestamp > oneHour) {
|
|
98
|
-
|
|
95
|
+
unlinkSync(this.sessionStateFile);
|
|
99
96
|
return null;
|
|
100
97
|
}
|
|
101
98
|
return state;
|
|
@@ -106,8 +103,8 @@ class CBrowser {
|
|
|
106
103
|
}
|
|
107
104
|
clearSessionState() {
|
|
108
105
|
try {
|
|
109
|
-
if (
|
|
110
|
-
|
|
106
|
+
if (existsSync(this.sessionStateFile)) {
|
|
107
|
+
unlinkSync(this.sessionStateFile);
|
|
111
108
|
}
|
|
112
109
|
}
|
|
113
110
|
catch (e) {
|
|
@@ -125,10 +122,10 @@ class CBrowser {
|
|
|
125
122
|
return;
|
|
126
123
|
// Select browser engine based on config
|
|
127
124
|
const browserType = this.config.browser === "firefox"
|
|
128
|
-
?
|
|
125
|
+
? firefox
|
|
129
126
|
: this.config.browser === "webkit"
|
|
130
|
-
?
|
|
131
|
-
:
|
|
127
|
+
? webkit
|
|
128
|
+
: chromium;
|
|
132
129
|
// Build context options
|
|
133
130
|
const contextOptions = {
|
|
134
131
|
viewport: {
|
|
@@ -137,8 +134,8 @@ class CBrowser {
|
|
|
137
134
|
},
|
|
138
135
|
};
|
|
139
136
|
// Apply device emulation if configured
|
|
140
|
-
if (this.config.device &&
|
|
141
|
-
const device =
|
|
137
|
+
if (this.config.device && DEVICE_PRESETS[this.config.device]) {
|
|
138
|
+
const device = DEVICE_PRESETS[this.config.device];
|
|
142
139
|
contextOptions.viewport = device.viewport;
|
|
143
140
|
contextOptions.userAgent = device.userAgent;
|
|
144
141
|
contextOptions.deviceScaleFactor = device.deviceScaleFactor;
|
|
@@ -180,9 +177,9 @@ class CBrowser {
|
|
|
180
177
|
}
|
|
181
178
|
// Enable video recording if configured
|
|
182
179
|
if (this.config.recordVideo) {
|
|
183
|
-
const videoDir = this.config.videoDir ||
|
|
184
|
-
if (!
|
|
185
|
-
|
|
180
|
+
const videoDir = this.config.videoDir || join(this.paths.dataDir, "videos");
|
|
181
|
+
if (!existsSync(videoDir)) {
|
|
182
|
+
mkdirSync(videoDir, { recursive: true });
|
|
186
183
|
}
|
|
187
184
|
contextOptions.recordVideo = {
|
|
188
185
|
dir: videoDir,
|
|
@@ -196,9 +193,9 @@ class CBrowser {
|
|
|
196
193
|
// Use browser-specific launch args for optimal performance
|
|
197
194
|
const launchArgs = BROWSER_LAUNCH_ARGS[this.config.browser];
|
|
198
195
|
if (this.config.persistent) {
|
|
199
|
-
const browserStateDir =
|
|
200
|
-
if (!
|
|
201
|
-
|
|
196
|
+
const browserStateDir = join(this.paths.dataDir, "browser-state");
|
|
197
|
+
if (!existsSync(browserStateDir)) {
|
|
198
|
+
mkdirSync(browserStateDir, { recursive: true });
|
|
202
199
|
}
|
|
203
200
|
this.context = await browserType.launchPersistentContext(browserStateDir, {
|
|
204
201
|
headless: this.config.headless,
|
|
@@ -285,11 +282,11 @@ class CBrowser {
|
|
|
285
282
|
// Clear session state first (to prevent close() from saving)
|
|
286
283
|
this.clearSessionState();
|
|
287
284
|
await this.close();
|
|
288
|
-
const browserStateDir =
|
|
289
|
-
if (
|
|
285
|
+
const browserStateDir = join(this.paths.dataDir, "browser-state");
|
|
286
|
+
if (existsSync(browserStateDir)) {
|
|
290
287
|
const { rmSync } = await import("fs");
|
|
291
288
|
rmSync(browserStateDir, { recursive: true, force: true });
|
|
292
|
-
|
|
289
|
+
mkdirSync(browserStateDir, { recursive: true });
|
|
293
290
|
}
|
|
294
291
|
if (this.config.verbose) {
|
|
295
292
|
console.log("🔄 Browser state reset");
|
|
@@ -486,12 +483,12 @@ class CBrowser {
|
|
|
486
483
|
entries: this.harEntries,
|
|
487
484
|
};
|
|
488
485
|
const har = { log: harLog };
|
|
489
|
-
const harDir =
|
|
490
|
-
if (!
|
|
491
|
-
|
|
486
|
+
const harDir = join(this.paths.dataDir, "har");
|
|
487
|
+
if (!existsSync(harDir)) {
|
|
488
|
+
mkdirSync(harDir, { recursive: true });
|
|
492
489
|
}
|
|
493
|
-
const filename = outputPath ||
|
|
494
|
-
|
|
490
|
+
const filename = outputPath || join(harDir, `har-${Date.now()}.har`);
|
|
491
|
+
writeFileSync(filename, JSON.stringify(har, null, 2));
|
|
495
492
|
return filename;
|
|
496
493
|
}
|
|
497
494
|
/**
|
|
@@ -697,7 +694,7 @@ class CBrowser {
|
|
|
697
694
|
* Set device emulation (requires browser restart).
|
|
698
695
|
*/
|
|
699
696
|
setDevice(deviceName) {
|
|
700
|
-
if (
|
|
697
|
+
if (DEVICE_PRESETS[deviceName]) {
|
|
701
698
|
this.config.device = deviceName;
|
|
702
699
|
return true;
|
|
703
700
|
}
|
|
@@ -707,7 +704,7 @@ class CBrowser {
|
|
|
707
704
|
* List available device presets.
|
|
708
705
|
*/
|
|
709
706
|
static listDevices() {
|
|
710
|
-
return Object.keys(
|
|
707
|
+
return Object.keys(DEVICE_PRESETS);
|
|
711
708
|
}
|
|
712
709
|
// =========================================================================
|
|
713
710
|
// Geolocation
|
|
@@ -717,8 +714,8 @@ class CBrowser {
|
|
|
717
714
|
*/
|
|
718
715
|
setGeolocation(location) {
|
|
719
716
|
if (typeof location === "string") {
|
|
720
|
-
if (
|
|
721
|
-
this.config.geolocation =
|
|
717
|
+
if (LOCATION_PRESETS[location]) {
|
|
718
|
+
this.config.geolocation = LOCATION_PRESETS[location];
|
|
722
719
|
return true;
|
|
723
720
|
}
|
|
724
721
|
return false;
|
|
@@ -734,9 +731,9 @@ class CBrowser {
|
|
|
734
731
|
return false;
|
|
735
732
|
let geo;
|
|
736
733
|
if (typeof location === "string") {
|
|
737
|
-
if (!
|
|
734
|
+
if (!LOCATION_PRESETS[location])
|
|
738
735
|
return false;
|
|
739
|
-
geo =
|
|
736
|
+
geo = LOCATION_PRESETS[location];
|
|
740
737
|
}
|
|
741
738
|
else {
|
|
742
739
|
geo = location;
|
|
@@ -749,7 +746,7 @@ class CBrowser {
|
|
|
749
746
|
* List available location presets.
|
|
750
747
|
*/
|
|
751
748
|
static listLocations() {
|
|
752
|
-
return Object.keys(
|
|
749
|
+
return Object.keys(LOCATION_PRESETS);
|
|
753
750
|
}
|
|
754
751
|
// =========================================================================
|
|
755
752
|
// Navigation
|
|
@@ -1072,6 +1069,382 @@ class CBrowser {
|
|
|
1072
1069
|
};
|
|
1073
1070
|
}
|
|
1074
1071
|
}
|
|
1072
|
+
// =========================================================================
|
|
1073
|
+
// Custom Dropdown Handling (v8.8.0)
|
|
1074
|
+
// Handles hidden <select> elements with custom dropdown UIs (Alpine.js, React Select, etc.)
|
|
1075
|
+
// =========================================================================
|
|
1076
|
+
/**
|
|
1077
|
+
* Check if an element is visually hidden (but exists in DOM).
|
|
1078
|
+
* Common patterns: display:none, visibility:hidden, opacity:0, zero dimensions,
|
|
1079
|
+
* or positioned off-screen.
|
|
1080
|
+
*/
|
|
1081
|
+
async isElementHidden(element) {
|
|
1082
|
+
try {
|
|
1083
|
+
const isHidden = await element.evaluate((el) => {
|
|
1084
|
+
const style = getComputedStyle(el);
|
|
1085
|
+
// Check common hiding methods
|
|
1086
|
+
if (style.display === 'none')
|
|
1087
|
+
return true;
|
|
1088
|
+
if (style.visibility === 'hidden')
|
|
1089
|
+
return true;
|
|
1090
|
+
if (style.opacity === '0')
|
|
1091
|
+
return true;
|
|
1092
|
+
// Check for zero dimensions
|
|
1093
|
+
const rect = el.getBoundingClientRect();
|
|
1094
|
+
if (rect.width === 0 || rect.height === 0)
|
|
1095
|
+
return true;
|
|
1096
|
+
// Check for off-screen positioning
|
|
1097
|
+
if (rect.right < 0 || rect.bottom < 0)
|
|
1098
|
+
return true;
|
|
1099
|
+
if (rect.left > window.innerWidth || rect.top > window.innerHeight)
|
|
1100
|
+
return true;
|
|
1101
|
+
// Check for clip-path hiding
|
|
1102
|
+
if (style.clipPath === 'inset(100%)' || style.clip === 'rect(0, 0, 0, 0)')
|
|
1103
|
+
return true;
|
|
1104
|
+
// Check for sr-only / screen-reader-only patterns
|
|
1105
|
+
if (style.position === 'absolute' &&
|
|
1106
|
+
(parseInt(style.width) === 1 || parseInt(style.height) === 1) &&
|
|
1107
|
+
style.overflow === 'hidden')
|
|
1108
|
+
return true;
|
|
1109
|
+
return false;
|
|
1110
|
+
});
|
|
1111
|
+
return isHidden;
|
|
1112
|
+
}
|
|
1113
|
+
catch {
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Find the visible custom dropdown trigger associated with a hidden select.
|
|
1119
|
+
* Uses multiple heuristics to locate the clickable UI element.
|
|
1120
|
+
*/
|
|
1121
|
+
async findCustomDropdownTrigger(hiddenSelect) {
|
|
1122
|
+
const page = await this.getPage();
|
|
1123
|
+
try {
|
|
1124
|
+
// Get info about the hidden select to help find its trigger
|
|
1125
|
+
const selectInfo = await hiddenSelect.evaluate((el) => {
|
|
1126
|
+
const select = el;
|
|
1127
|
+
return {
|
|
1128
|
+
id: select.id,
|
|
1129
|
+
name: select.name,
|
|
1130
|
+
parentId: select.parentElement?.id,
|
|
1131
|
+
parentClasses: select.parentElement?.className,
|
|
1132
|
+
// Check for Alpine.js x-model binding
|
|
1133
|
+
xModel: select.getAttribute('x-model'),
|
|
1134
|
+
// Check for aria associations
|
|
1135
|
+
ariaLabelledBy: select.getAttribute('aria-labelledby'),
|
|
1136
|
+
};
|
|
1137
|
+
});
|
|
1138
|
+
// Strategy 1: Look for adjacent sibling that's visible (common Alpine pattern)
|
|
1139
|
+
const adjacentTrigger = await page.evaluate((selectInfo) => {
|
|
1140
|
+
let select = null;
|
|
1141
|
+
// Find the select by various methods
|
|
1142
|
+
if (selectInfo.id) {
|
|
1143
|
+
select = document.getElementById(selectInfo.id);
|
|
1144
|
+
}
|
|
1145
|
+
if (!select && selectInfo.name) {
|
|
1146
|
+
select = document.querySelector(`select[name="${selectInfo.name}"]`);
|
|
1147
|
+
}
|
|
1148
|
+
if (!select)
|
|
1149
|
+
return null;
|
|
1150
|
+
// Check next sibling
|
|
1151
|
+
let sibling = select.nextElementSibling;
|
|
1152
|
+
while (sibling) {
|
|
1153
|
+
const style = getComputedStyle(sibling);
|
|
1154
|
+
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
1155
|
+
// Check if it looks like a dropdown trigger
|
|
1156
|
+
const hasClickIndicator = sibling.querySelector('[class*="chevron"], [class*="arrow"], [class*="caret"], svg') !== null;
|
|
1157
|
+
const hasRole = sibling.getAttribute('role') === 'button' || sibling.getAttribute('role') === 'combobox';
|
|
1158
|
+
const isButton = sibling.tagName === 'BUTTON';
|
|
1159
|
+
const hasDropdownClass = /dropdown|select|trigger|toggle/i.test(sibling.className);
|
|
1160
|
+
if (hasClickIndicator || hasRole || isButton || hasDropdownClass) {
|
|
1161
|
+
// Return a selector for this element
|
|
1162
|
+
if (sibling.id)
|
|
1163
|
+
return `#${sibling.id}`;
|
|
1164
|
+
if (sibling.className)
|
|
1165
|
+
return `.${sibling.className.split(' ')[0]}`;
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
sibling = sibling.nextElementSibling;
|
|
1170
|
+
}
|
|
1171
|
+
return null;
|
|
1172
|
+
}, selectInfo);
|
|
1173
|
+
if (adjacentTrigger) {
|
|
1174
|
+
const trigger = page.locator(adjacentTrigger).first();
|
|
1175
|
+
if (await trigger.isVisible()) {
|
|
1176
|
+
return trigger;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
// Strategy 2: Look for parent container with click handler/dropdown classes
|
|
1180
|
+
const parentTrigger = await page.evaluate((selectInfo) => {
|
|
1181
|
+
let select = null;
|
|
1182
|
+
if (selectInfo.id) {
|
|
1183
|
+
select = document.getElementById(selectInfo.id);
|
|
1184
|
+
}
|
|
1185
|
+
if (!select && selectInfo.name) {
|
|
1186
|
+
select = document.querySelector(`select[name="${selectInfo.name}"]`);
|
|
1187
|
+
}
|
|
1188
|
+
if (!select)
|
|
1189
|
+
return null;
|
|
1190
|
+
// Walk up to find a clickable parent container
|
|
1191
|
+
let parent = select.parentElement;
|
|
1192
|
+
let depth = 0;
|
|
1193
|
+
while (parent && depth < 5) {
|
|
1194
|
+
const style = getComputedStyle(parent);
|
|
1195
|
+
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
1196
|
+
// Check for Alpine x-data (indicates interactive component)
|
|
1197
|
+
const hasAlpineData = parent.hasAttribute('x-data');
|
|
1198
|
+
const hasDropdownClass = /dropdown|select|combobox|listbox/i.test(parent.className);
|
|
1199
|
+
const hasRole = parent.getAttribute('role') === 'combobox' || parent.getAttribute('role') === 'listbox';
|
|
1200
|
+
if (hasAlpineData || hasDropdownClass || hasRole) {
|
|
1201
|
+
// Look for a visible clickable child
|
|
1202
|
+
const clickable = parent.querySelector('button, [role="button"], [tabindex="0"], div[class*="trigger"], div[class*="selected"]');
|
|
1203
|
+
if (clickable) {
|
|
1204
|
+
const clickableStyle = getComputedStyle(clickable);
|
|
1205
|
+
if (clickableStyle.display !== 'none') {
|
|
1206
|
+
if (clickable.id)
|
|
1207
|
+
return `#${clickable.id}`;
|
|
1208
|
+
if (clickable.className)
|
|
1209
|
+
return `.${clickable.className.split(' ')[0]}`;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
// If parent itself looks clickable
|
|
1213
|
+
if (parent.id)
|
|
1214
|
+
return `#${parent.id}`;
|
|
1215
|
+
if (parent.className)
|
|
1216
|
+
return `.${parent.className.split(' ')[0]}`;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
parent = parent.parentElement;
|
|
1220
|
+
depth++;
|
|
1221
|
+
}
|
|
1222
|
+
return null;
|
|
1223
|
+
}, selectInfo);
|
|
1224
|
+
if (parentTrigger) {
|
|
1225
|
+
const trigger = page.locator(parentTrigger).first();
|
|
1226
|
+
if (await trigger.isVisible()) {
|
|
1227
|
+
return trigger;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
// Strategy 3: Look for aria-controls or aria-labelledby relationships
|
|
1231
|
+
if (selectInfo.ariaLabelledBy) {
|
|
1232
|
+
const trigger = page.locator(`#${selectInfo.ariaLabelledBy}`).first();
|
|
1233
|
+
if (await trigger.isVisible()) {
|
|
1234
|
+
return trigger;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
// Strategy 4: Find any visible element with matching x-model (Alpine.js)
|
|
1238
|
+
if (selectInfo.xModel) {
|
|
1239
|
+
const alpineTrigger = await page.evaluate((xModel) => {
|
|
1240
|
+
// Look for visible elements with same x-model that aren't the select
|
|
1241
|
+
const elements = Array.from(document.querySelectorAll(`[x-model="${xModel}"], [\\@click*="${xModel}"]`));
|
|
1242
|
+
for (let i = 0; i < elements.length; i++) {
|
|
1243
|
+
const el = elements[i];
|
|
1244
|
+
if (el.tagName !== 'SELECT') {
|
|
1245
|
+
const style = getComputedStyle(el);
|
|
1246
|
+
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
1247
|
+
if (el.id)
|
|
1248
|
+
return `#${el.id}`;
|
|
1249
|
+
if (el.className)
|
|
1250
|
+
return `.${el.className.split(' ')[0]}`;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
return null;
|
|
1255
|
+
}, selectInfo.xModel);
|
|
1256
|
+
if (alpineTrigger) {
|
|
1257
|
+
const trigger = page.locator(alpineTrigger).first();
|
|
1258
|
+
if (await trigger.isVisible()) {
|
|
1259
|
+
return trigger;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
return null;
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
return null;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Find and select an option in a custom dropdown.
|
|
1271
|
+
* Opens the dropdown, waits for options, and clicks the target option.
|
|
1272
|
+
*/
|
|
1273
|
+
async selectCustomDropdownOption(trigger, optionValue, options = {}) {
|
|
1274
|
+
const page = await this.getPage();
|
|
1275
|
+
const timeout = options.timeout ?? 5000;
|
|
1276
|
+
try {
|
|
1277
|
+
// Click the trigger to open the dropdown
|
|
1278
|
+
if (options.verbose)
|
|
1279
|
+
console.log(` Opening dropdown by clicking trigger`);
|
|
1280
|
+
await trigger.click();
|
|
1281
|
+
// Wait for dropdown options to appear
|
|
1282
|
+
// Try multiple common patterns for option containers
|
|
1283
|
+
const optionSelectors = [
|
|
1284
|
+
// Alpine.js / Headless UI patterns
|
|
1285
|
+
'[x-show]:not([x-show="false"])',
|
|
1286
|
+
'[role="listbox"]',
|
|
1287
|
+
'[role="menu"]',
|
|
1288
|
+
'[role="option"]',
|
|
1289
|
+
// Common class patterns
|
|
1290
|
+
'[class*="dropdown-menu"]:not([class*="hidden"])',
|
|
1291
|
+
'[class*="select-options"]',
|
|
1292
|
+
'[class*="listbox-options"]',
|
|
1293
|
+
'[class*="menu-items"]',
|
|
1294
|
+
// Generic visible dropdown
|
|
1295
|
+
'.dropdown-content:visible',
|
|
1296
|
+
'.dropdown-options',
|
|
1297
|
+
// React Select patterns
|
|
1298
|
+
'[class*="menu"]',
|
|
1299
|
+
'[class*="MenuList"]',
|
|
1300
|
+
];
|
|
1301
|
+
await page.waitForTimeout(200); // Brief wait for animation
|
|
1302
|
+
// Find the option with matching text
|
|
1303
|
+
if (options.verbose)
|
|
1304
|
+
console.log(` Looking for option: "${optionValue}"`);
|
|
1305
|
+
// Strategy 1: Find by exact text match
|
|
1306
|
+
const optionByText = page.getByRole('option', { name: optionValue });
|
|
1307
|
+
if (await optionByText.isVisible({ timeout: 500 }).catch(() => false)) {
|
|
1308
|
+
await optionByText.click();
|
|
1309
|
+
return { success: true, strategy: 'role-option-exact' };
|
|
1310
|
+
}
|
|
1311
|
+
// Strategy 2: Find by partial text match in role=option
|
|
1312
|
+
const optionByPartial = page.locator('[role="option"]').filter({ hasText: optionValue }).first();
|
|
1313
|
+
if (await optionByPartial.isVisible({ timeout: 500 }).catch(() => false)) {
|
|
1314
|
+
await optionByPartial.click();
|
|
1315
|
+
return { success: true, strategy: 'role-option-partial' };
|
|
1316
|
+
}
|
|
1317
|
+
// Strategy 3: Find by li elements (common dropdown pattern)
|
|
1318
|
+
const liOption = page.locator('li').filter({ hasText: optionValue }).first();
|
|
1319
|
+
if (await liOption.isVisible({ timeout: 500 }).catch(() => false)) {
|
|
1320
|
+
await liOption.click();
|
|
1321
|
+
return { success: true, strategy: 'li-element' };
|
|
1322
|
+
}
|
|
1323
|
+
// Strategy 4: Find any clickable element with the text
|
|
1324
|
+
const anyOption = page.locator(`text="${optionValue}"`).first();
|
|
1325
|
+
if (await anyOption.isVisible({ timeout: 500 }).catch(() => false)) {
|
|
1326
|
+
await anyOption.click();
|
|
1327
|
+
return { success: true, strategy: 'text-match' };
|
|
1328
|
+
}
|
|
1329
|
+
// Strategy 5: Find by data-value attribute
|
|
1330
|
+
const dataValueOption = page.locator(`[data-value="${optionValue}"], [value="${optionValue}"]`).first();
|
|
1331
|
+
if (await dataValueOption.isVisible({ timeout: 500 }).catch(() => false)) {
|
|
1332
|
+
await dataValueOption.click();
|
|
1333
|
+
return { success: true, strategy: 'data-value' };
|
|
1334
|
+
}
|
|
1335
|
+
// If we couldn't find the option, close dropdown and report
|
|
1336
|
+
await page.keyboard.press('Escape');
|
|
1337
|
+
return {
|
|
1338
|
+
success: false,
|
|
1339
|
+
error: `Could not find option "${optionValue}" in dropdown`
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
catch (e) {
|
|
1343
|
+
// Try to close any open dropdown
|
|
1344
|
+
await page.keyboard.press('Escape').catch(() => { });
|
|
1345
|
+
return {
|
|
1346
|
+
success: false,
|
|
1347
|
+
error: `Custom dropdown selection failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Handle filling a custom dropdown (hidden select with custom UI).
|
|
1353
|
+
* Automatically detects and interacts with the visible dropdown component.
|
|
1354
|
+
*/
|
|
1355
|
+
async handleCustomDropdown(hiddenSelect, value, options = {}) {
|
|
1356
|
+
if (options.verbose)
|
|
1357
|
+
console.log(` Detected hidden select, looking for custom dropdown trigger`);
|
|
1358
|
+
// Find the visible trigger element
|
|
1359
|
+
const trigger = await this.findCustomDropdownTrigger(hiddenSelect);
|
|
1360
|
+
if (!trigger) {
|
|
1361
|
+
return {
|
|
1362
|
+
success: false,
|
|
1363
|
+
error: 'Could not find custom dropdown trigger for hidden select'
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
if (options.verbose)
|
|
1367
|
+
console.log(` Found dropdown trigger`);
|
|
1368
|
+
// Try to select the option
|
|
1369
|
+
const result = await this.selectCustomDropdownOption(trigger, value, options);
|
|
1370
|
+
return result;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Find the best click candidate from multiple matches.
|
|
1374
|
+
* Prioritizes: exact text match > non-sticky elements > shorter text (closer match)
|
|
1375
|
+
*/
|
|
1376
|
+
async findBestClickCandidate(locator, searchText) {
|
|
1377
|
+
const page = await this.getPage();
|
|
1378
|
+
const count = await locator.count();
|
|
1379
|
+
if (count === 0)
|
|
1380
|
+
return null;
|
|
1381
|
+
if (count === 1)
|
|
1382
|
+
return locator.first();
|
|
1383
|
+
// Score each candidate
|
|
1384
|
+
const candidates = [];
|
|
1385
|
+
const searchLower = searchText.toLowerCase();
|
|
1386
|
+
for (let i = 0; i < count; i++) {
|
|
1387
|
+
const el = locator.nth(i);
|
|
1388
|
+
let score = 0;
|
|
1389
|
+
try {
|
|
1390
|
+
const info = await el.evaluate((element, search) => {
|
|
1391
|
+
const text = element.textContent?.trim() || '';
|
|
1392
|
+
const style = getComputedStyle(element);
|
|
1393
|
+
// Check if element is in a sticky/fixed container
|
|
1394
|
+
let inStickyContainer = false;
|
|
1395
|
+
let parent = element;
|
|
1396
|
+
while (parent) {
|
|
1397
|
+
const parentStyle = getComputedStyle(parent);
|
|
1398
|
+
if (parentStyle.position === 'fixed' || parentStyle.position === 'sticky') {
|
|
1399
|
+
inStickyContainer = true;
|
|
1400
|
+
break;
|
|
1401
|
+
}
|
|
1402
|
+
parent = parent.parentElement;
|
|
1403
|
+
}
|
|
1404
|
+
// Check for exact text match vs partial
|
|
1405
|
+
const textLower = text.toLowerCase();
|
|
1406
|
+
const isExactMatch = textLower === search.toLowerCase();
|
|
1407
|
+
const isPartialMatch = textLower.includes(search.toLowerCase());
|
|
1408
|
+
// Get element role/type info
|
|
1409
|
+
const tagName = element.tagName.toLowerCase();
|
|
1410
|
+
const role = element.getAttribute('role');
|
|
1411
|
+
const isInteractive = ['button', 'a', 'input', 'select'].includes(tagName) ||
|
|
1412
|
+
['button', 'link', 'option'].includes(role || '');
|
|
1413
|
+
return {
|
|
1414
|
+
text,
|
|
1415
|
+
textLength: text.length,
|
|
1416
|
+
inStickyContainer,
|
|
1417
|
+
isExactMatch,
|
|
1418
|
+
isPartialMatch,
|
|
1419
|
+
isInteractive,
|
|
1420
|
+
};
|
|
1421
|
+
}, searchLower);
|
|
1422
|
+
// Scoring:
|
|
1423
|
+
// +100: exact text match
|
|
1424
|
+
// +50: not in sticky container
|
|
1425
|
+
// +20: is interactive element
|
|
1426
|
+
// -1 per extra character (prefer shorter matches)
|
|
1427
|
+
if (info.isExactMatch)
|
|
1428
|
+
score += 100;
|
|
1429
|
+
if (!info.inStickyContainer)
|
|
1430
|
+
score += 50;
|
|
1431
|
+
if (info.isInteractive)
|
|
1432
|
+
score += 20;
|
|
1433
|
+
score -= Math.abs(info.textLength - searchText.length);
|
|
1434
|
+
candidates.push({ index: i, score });
|
|
1435
|
+
}
|
|
1436
|
+
catch {
|
|
1437
|
+
// If evaluation fails, give low score
|
|
1438
|
+
candidates.push({ index: i, score: -1000 });
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
// Sort by score descending and return the best match
|
|
1442
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
1443
|
+
if (candidates.length > 0 && candidates[0].score > -1000) {
|
|
1444
|
+
return locator.nth(candidates[0].index);
|
|
1445
|
+
}
|
|
1446
|
+
return locator.first();
|
|
1447
|
+
}
|
|
1075
1448
|
/**
|
|
1076
1449
|
* Hover then click - useful for dropdown menus that need hover to reveal items.
|
|
1077
1450
|
* ALWAYS hovers the parent menu and keeps it hovered while clicking the submenu item.
|
|
@@ -1203,6 +1576,44 @@ class CBrowser {
|
|
|
1203
1576
|
}
|
|
1204
1577
|
return result;
|
|
1205
1578
|
}
|
|
1579
|
+
// Check if the element is hidden (custom dropdown/input pattern)
|
|
1580
|
+
const isHidden = await this.isElementHidden(element);
|
|
1581
|
+
if (isHidden) {
|
|
1582
|
+
if (options.verbose)
|
|
1583
|
+
console.log(` Element "${selector}" is hidden, checking for custom UI component`);
|
|
1584
|
+
// Get the tag name to determine handling strategy
|
|
1585
|
+
const tagName = await element.evaluate((el) => el.tagName.toLowerCase());
|
|
1586
|
+
if (tagName === 'select') {
|
|
1587
|
+
// Hidden select with custom dropdown UI
|
|
1588
|
+
const customResult = await this.handleCustomDropdown(element, value, options);
|
|
1589
|
+
if (customResult.success) {
|
|
1590
|
+
this.audit("fill", `${selector} (custom-dropdown:${customResult.strategy})`, "yellow", "success");
|
|
1591
|
+
return {
|
|
1592
|
+
success: true,
|
|
1593
|
+
screenshot: await this.screenshot(),
|
|
1594
|
+
message: `Filled custom dropdown: ${selector} (strategy: ${customResult.strategy})`,
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
else {
|
|
1598
|
+
// Fall through to try standard fill anyway
|
|
1599
|
+
if (options.verbose)
|
|
1600
|
+
console.log(` Custom dropdown handling failed: ${customResult.error}`);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
else if (tagName === 'input') {
|
|
1604
|
+
// Hidden input - might be part of autocomplete, datepicker, etc.
|
|
1605
|
+
const customResult = await this.handleCustomInput(element, value, options);
|
|
1606
|
+
if (customResult.success) {
|
|
1607
|
+
this.audit("fill", `${selector} (custom-input:${customResult.strategy})`, "yellow", "success");
|
|
1608
|
+
return {
|
|
1609
|
+
success: true,
|
|
1610
|
+
screenshot: await this.screenshot(),
|
|
1611
|
+
message: `Filled custom input: ${selector} (strategy: ${customResult.strategy})`,
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
// Standard fill - works for visible inputs and textareas
|
|
1206
1617
|
await element.fill(value);
|
|
1207
1618
|
this.audit("fill", selector, "yellow", "success");
|
|
1208
1619
|
return {
|
|
@@ -1231,6 +1642,111 @@ class CBrowser {
|
|
|
1231
1642
|
return result;
|
|
1232
1643
|
}
|
|
1233
1644
|
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Handle filling a custom input (hidden input with custom UI).
|
|
1647
|
+
* Handles autocomplete, datepickers, custom text inputs, etc.
|
|
1648
|
+
*/
|
|
1649
|
+
async handleCustomInput(hiddenInput, value, options = {}) {
|
|
1650
|
+
const page = await this.getPage();
|
|
1651
|
+
try {
|
|
1652
|
+
// Get input info to help find its visible counterpart
|
|
1653
|
+
const inputInfo = await hiddenInput.evaluate((el) => {
|
|
1654
|
+
const input = el;
|
|
1655
|
+
return {
|
|
1656
|
+
id: input.id,
|
|
1657
|
+
name: input.name,
|
|
1658
|
+
type: input.type,
|
|
1659
|
+
ariaLabelledBy: input.getAttribute('aria-labelledby'),
|
|
1660
|
+
xModel: input.getAttribute('x-model'),
|
|
1661
|
+
};
|
|
1662
|
+
});
|
|
1663
|
+
// Strategy 1: Look for visible sibling input or wrapper
|
|
1664
|
+
const adjacentVisible = await page.evaluate((inputInfo) => {
|
|
1665
|
+
let input = null;
|
|
1666
|
+
if (inputInfo.id)
|
|
1667
|
+
input = document.getElementById(inputInfo.id);
|
|
1668
|
+
if (!input && inputInfo.name)
|
|
1669
|
+
input = document.querySelector(`input[name="${inputInfo.name}"]`);
|
|
1670
|
+
if (!input)
|
|
1671
|
+
return null;
|
|
1672
|
+
// Check parent container for visible input
|
|
1673
|
+
const parent = input.closest('[x-data], .form-group, .input-wrapper, .field-container');
|
|
1674
|
+
if (parent) {
|
|
1675
|
+
const visibleInputs = Array.from(parent.querySelectorAll('input:not([type="hidden"])'));
|
|
1676
|
+
for (let i = 0; i < visibleInputs.length; i++) {
|
|
1677
|
+
const vi = visibleInputs[i];
|
|
1678
|
+
const style = getComputedStyle(vi);
|
|
1679
|
+
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
1680
|
+
if (vi.id)
|
|
1681
|
+
return { selector: `#${vi.id}`, type: 'visible-input' };
|
|
1682
|
+
if (vi.name)
|
|
1683
|
+
return { selector: `input[name="${vi.name}"]`, type: 'visible-input' };
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
// Check for text display element that might be the "face" of a custom input
|
|
1687
|
+
const textDisplay = parent.querySelector('[contenteditable="true"], .editable, .input-display');
|
|
1688
|
+
if (textDisplay) {
|
|
1689
|
+
const style = getComputedStyle(textDisplay);
|
|
1690
|
+
if (style.display !== 'none') {
|
|
1691
|
+
if (textDisplay.id)
|
|
1692
|
+
return { selector: `#${textDisplay.id}`, type: 'contenteditable' };
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
return null;
|
|
1697
|
+
}, inputInfo);
|
|
1698
|
+
if (adjacentVisible) {
|
|
1699
|
+
if (adjacentVisible.type === 'visible-input') {
|
|
1700
|
+
const visibleInput = page.locator(adjacentVisible.selector).first();
|
|
1701
|
+
await visibleInput.fill(value);
|
|
1702
|
+
return { success: true, strategy: 'visible-sibling-input' };
|
|
1703
|
+
}
|
|
1704
|
+
else if (adjacentVisible.type === 'contenteditable') {
|
|
1705
|
+
const editableEl = page.locator(adjacentVisible.selector).first();
|
|
1706
|
+
await editableEl.click();
|
|
1707
|
+
await editableEl.fill(value);
|
|
1708
|
+
return { success: true, strategy: 'contenteditable' };
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
// Strategy 2: For datepickers, try clicking the wrapper and typing
|
|
1712
|
+
if (inputInfo.type === 'date' || inputInfo.type === 'datetime-local' || inputInfo.type === 'time') {
|
|
1713
|
+
const wrapper = await page.evaluate((inputInfo) => {
|
|
1714
|
+
let input = null;
|
|
1715
|
+
if (inputInfo.id)
|
|
1716
|
+
input = document.getElementById(inputInfo.id);
|
|
1717
|
+
if (!input && inputInfo.name)
|
|
1718
|
+
input = document.querySelector(`input[name="${inputInfo.name}"]`);
|
|
1719
|
+
if (!input)
|
|
1720
|
+
return null;
|
|
1721
|
+
const parent = input.closest('.datepicker, .date-input, [class*="picker"]');
|
|
1722
|
+
if (parent) {
|
|
1723
|
+
if (parent.id)
|
|
1724
|
+
return `#${parent.id}`;
|
|
1725
|
+
}
|
|
1726
|
+
return null;
|
|
1727
|
+
}, inputInfo);
|
|
1728
|
+
if (wrapper) {
|
|
1729
|
+
const wrapperEl = page.locator(wrapper).first();
|
|
1730
|
+
await wrapperEl.click();
|
|
1731
|
+
await page.keyboard.type(value);
|
|
1732
|
+
return { success: true, strategy: 'datepicker-type' };
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
// Strategy 3: Set value directly via JavaScript (last resort)
|
|
1736
|
+
await hiddenInput.evaluate((el, val) => {
|
|
1737
|
+
el.value = val;
|
|
1738
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1739
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1740
|
+
}, value);
|
|
1741
|
+
return { success: true, strategy: 'js-value-set' };
|
|
1742
|
+
}
|
|
1743
|
+
catch (e) {
|
|
1744
|
+
return {
|
|
1745
|
+
success: false,
|
|
1746
|
+
error: `Custom input handling failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1234
1750
|
// =========================================================================
|
|
1235
1751
|
// Tier 5: Smart Retry (v5.0.0)
|
|
1236
1752
|
// =========================================================================
|
|
@@ -1672,7 +2188,7 @@ class CBrowser {
|
|
|
1672
2188
|
* Get the selector cache file path.
|
|
1673
2189
|
*/
|
|
1674
2190
|
getSelectorCachePath() {
|
|
1675
|
-
return
|
|
2191
|
+
return join(this.paths.dataDir, "selector-cache.json");
|
|
1676
2192
|
}
|
|
1677
2193
|
/**
|
|
1678
2194
|
* Load the selector cache from disk.
|
|
@@ -1681,9 +2197,9 @@ class CBrowser {
|
|
|
1681
2197
|
if (this.selectorCache)
|
|
1682
2198
|
return this.selectorCache;
|
|
1683
2199
|
const cachePath = this.getSelectorCachePath();
|
|
1684
|
-
if (
|
|
2200
|
+
if (existsSync(cachePath)) {
|
|
1685
2201
|
try {
|
|
1686
|
-
const data =
|
|
2202
|
+
const data = readFileSync(cachePath, "utf-8");
|
|
1687
2203
|
this.selectorCache = JSON.parse(data);
|
|
1688
2204
|
return this.selectorCache;
|
|
1689
2205
|
}
|
|
@@ -1701,7 +2217,7 @@ class CBrowser {
|
|
|
1701
2217
|
if (!this.selectorCache)
|
|
1702
2218
|
return;
|
|
1703
2219
|
const cachePath = this.getSelectorCachePath();
|
|
1704
|
-
|
|
2220
|
+
writeFileSync(cachePath, JSON.stringify(this.selectorCache, null, 2));
|
|
1705
2221
|
}
|
|
1706
2222
|
/**
|
|
1707
2223
|
* Get cache key for a selector (includes domain for context).
|
|
@@ -2263,7 +2779,7 @@ class CBrowser {
|
|
|
2263
2779
|
// Take screenshot
|
|
2264
2780
|
const dir = options.debugDir || this.paths.screenshotsDir;
|
|
2265
2781
|
const filename = `debug-${Date.now()}.png`;
|
|
2266
|
-
const filepath =
|
|
2782
|
+
const filepath = join(dir, filename);
|
|
2267
2783
|
await page.screenshot({ path: filepath, fullPage: false });
|
|
2268
2784
|
// Clean up highlights
|
|
2269
2785
|
await page.evaluate(() => {
|
|
@@ -2448,10 +2964,30 @@ class CBrowser {
|
|
|
2448
2964
|
const [role, name] = selector.slice(5).split("/");
|
|
2449
2965
|
return page.getByRole(role, { name }).first();
|
|
2450
2966
|
}
|
|
2451
|
-
// Strategy 3: Text content
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2967
|
+
// Strategy 3: Text content - prefer exact matches over fuzzy, non-sticky over sticky
|
|
2968
|
+
// First, try exact text match
|
|
2969
|
+
const byTextExact = page.getByText(selector, { exact: true });
|
|
2970
|
+
const exactCount = await byTextExact.count();
|
|
2971
|
+
if (exactCount > 0) {
|
|
2972
|
+
// If multiple exact matches, prefer non-sticky elements
|
|
2973
|
+
if (exactCount > 1) {
|
|
2974
|
+
const bestMatch = await this.findBestClickCandidate(byTextExact, selector);
|
|
2975
|
+
if (bestMatch)
|
|
2976
|
+
return bestMatch;
|
|
2977
|
+
}
|
|
2978
|
+
return byTextExact.first();
|
|
2979
|
+
}
|
|
2980
|
+
// Then try partial/fuzzy text match (but with sticky deprioritization)
|
|
2981
|
+
const byTextFuzzy = page.getByText(selector, { exact: false });
|
|
2982
|
+
const fuzzyCount = await byTextFuzzy.count();
|
|
2983
|
+
if (fuzzyCount > 0) {
|
|
2984
|
+
// If multiple fuzzy matches, prefer non-sticky elements and shorter/exact matches
|
|
2985
|
+
if (fuzzyCount > 1) {
|
|
2986
|
+
const bestMatch = await this.findBestClickCandidate(byTextFuzzy, selector);
|
|
2987
|
+
if (bestMatch)
|
|
2988
|
+
return bestMatch;
|
|
2989
|
+
}
|
|
2990
|
+
return byTextFuzzy.first();
|
|
2455
2991
|
}
|
|
2456
2992
|
// Strategy 4: Placeholder
|
|
2457
2993
|
const byPlaceholder = page.getByPlaceholder(selector).first();
|
|
@@ -2631,7 +3167,7 @@ class CBrowser {
|
|
|
2631
3167
|
*/
|
|
2632
3168
|
async screenshot(path) {
|
|
2633
3169
|
const page = await this.getPage();
|
|
2634
|
-
const filename = path ||
|
|
3170
|
+
const filename = path || join(this.paths.screenshotsDir, `screenshot-${Date.now()}.png`);
|
|
2635
3171
|
await page.screenshot({ path: filename, fullPage: false });
|
|
2636
3172
|
return filename;
|
|
2637
3173
|
}
|
|
@@ -2691,18 +3227,18 @@ class CBrowser {
|
|
|
2691
3227
|
localStorage,
|
|
2692
3228
|
sessionStorage,
|
|
2693
3229
|
};
|
|
2694
|
-
const sessionPath =
|
|
2695
|
-
|
|
3230
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3231
|
+
writeFileSync(sessionPath, JSON.stringify(session, null, 2));
|
|
2696
3232
|
}
|
|
2697
3233
|
/**
|
|
2698
3234
|
* Load a saved session.
|
|
2699
3235
|
*/
|
|
2700
3236
|
async loadSession(name) {
|
|
2701
|
-
const sessionPath =
|
|
2702
|
-
if (!
|
|
3237
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3238
|
+
if (!existsSync(sessionPath)) {
|
|
2703
3239
|
return { success: false, name, cookiesRestored: 0, localStorageKeysRestored: 0, sessionStorageKeysRestored: 0 };
|
|
2704
3240
|
}
|
|
2705
|
-
const session = JSON.parse(
|
|
3241
|
+
const session = JSON.parse(readFileSync(sessionPath, "utf-8"));
|
|
2706
3242
|
const page = await this.getPage();
|
|
2707
3243
|
const context = this.context;
|
|
2708
3244
|
const result = {
|
|
@@ -2747,29 +3283,29 @@ class CBrowser {
|
|
|
2747
3283
|
await page.reload({ waitUntil: "networkidle" });
|
|
2748
3284
|
// Update lastUsed
|
|
2749
3285
|
session.lastUsed = new Date().toISOString();
|
|
2750
|
-
|
|
3286
|
+
writeFileSync(sessionPath, JSON.stringify(session, null, 2));
|
|
2751
3287
|
return result;
|
|
2752
3288
|
}
|
|
2753
3289
|
/**
|
|
2754
3290
|
* List all saved session names.
|
|
2755
3291
|
*/
|
|
2756
3292
|
listSessions() {
|
|
2757
|
-
const files =
|
|
3293
|
+
const files = readdirSync(this.paths.sessionsDir);
|
|
2758
3294
|
return files.filter((f) => f.endsWith(".json") && f !== "last-session.json").map((f) => f.replace(".json", ""));
|
|
2759
3295
|
}
|
|
2760
3296
|
/**
|
|
2761
3297
|
* List all saved sessions with rich metadata.
|
|
2762
3298
|
*/
|
|
2763
3299
|
listSessionsDetailed() {
|
|
2764
|
-
const files =
|
|
3300
|
+
const files = readdirSync(this.paths.sessionsDir);
|
|
2765
3301
|
const sessions = [];
|
|
2766
3302
|
for (const file of files) {
|
|
2767
3303
|
if (!file.endsWith(".json") || file === "last-session.json")
|
|
2768
3304
|
continue;
|
|
2769
|
-
const filePath =
|
|
3305
|
+
const filePath = join(this.paths.sessionsDir, file);
|
|
2770
3306
|
try {
|
|
2771
|
-
const data = JSON.parse(
|
|
2772
|
-
const stats =
|
|
3307
|
+
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
3308
|
+
const stats = statSync(filePath);
|
|
2773
3309
|
sessions.push({
|
|
2774
3310
|
name: data.name || file.replace(".json", ""),
|
|
2775
3311
|
created: data.created,
|
|
@@ -2792,11 +3328,11 @@ class CBrowser {
|
|
|
2792
3328
|
* Get detailed info for a single session.
|
|
2793
3329
|
*/
|
|
2794
3330
|
getSessionDetails(name) {
|
|
2795
|
-
const sessionPath =
|
|
2796
|
-
if (!
|
|
3331
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3332
|
+
if (!existsSync(sessionPath))
|
|
2797
3333
|
return null;
|
|
2798
3334
|
try {
|
|
2799
|
-
return JSON.parse(
|
|
3335
|
+
return JSON.parse(readFileSync(sessionPath, "utf-8"));
|
|
2800
3336
|
}
|
|
2801
3337
|
catch {
|
|
2802
3338
|
return null;
|
|
@@ -2806,9 +3342,9 @@ class CBrowser {
|
|
|
2806
3342
|
* Delete a saved session.
|
|
2807
3343
|
*/
|
|
2808
3344
|
deleteSession(name) {
|
|
2809
|
-
const sessionPath =
|
|
2810
|
-
if (
|
|
2811
|
-
|
|
3345
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3346
|
+
if (existsSync(sessionPath)) {
|
|
3347
|
+
unlinkSync(sessionPath);
|
|
2812
3348
|
return true;
|
|
2813
3349
|
}
|
|
2814
3350
|
return false;
|
|
@@ -2820,17 +3356,17 @@ class CBrowser {
|
|
|
2820
3356
|
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
|
|
2821
3357
|
const deleted = [];
|
|
2822
3358
|
const kept = [];
|
|
2823
|
-
const files =
|
|
3359
|
+
const files = readdirSync(this.paths.sessionsDir);
|
|
2824
3360
|
for (const file of files) {
|
|
2825
3361
|
if (!file.endsWith(".json") || file === "last-session.json")
|
|
2826
3362
|
continue;
|
|
2827
|
-
const filePath =
|
|
3363
|
+
const filePath = join(this.paths.sessionsDir, file);
|
|
2828
3364
|
const name = file.replace(".json", "");
|
|
2829
3365
|
try {
|
|
2830
|
-
const data = JSON.parse(
|
|
3366
|
+
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
2831
3367
|
const lastUsed = new Date(data.lastUsed).getTime();
|
|
2832
3368
|
if (lastUsed < cutoff) {
|
|
2833
|
-
|
|
3369
|
+
unlinkSync(filePath);
|
|
2834
3370
|
deleted.push(name);
|
|
2835
3371
|
}
|
|
2836
3372
|
else {
|
|
@@ -2847,24 +3383,24 @@ class CBrowser {
|
|
|
2847
3383
|
* Export a session to a portable JSON file.
|
|
2848
3384
|
*/
|
|
2849
3385
|
exportSession(name, outputPath) {
|
|
2850
|
-
const sessionPath =
|
|
2851
|
-
if (!
|
|
3386
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3387
|
+
if (!existsSync(sessionPath))
|
|
2852
3388
|
return false;
|
|
2853
|
-
const data =
|
|
2854
|
-
|
|
3389
|
+
const data = readFileSync(sessionPath, "utf-8");
|
|
3390
|
+
writeFileSync(outputPath, data);
|
|
2855
3391
|
return true;
|
|
2856
3392
|
}
|
|
2857
3393
|
/**
|
|
2858
3394
|
* Import a session from a JSON file.
|
|
2859
3395
|
*/
|
|
2860
3396
|
importSession(inputPath, name) {
|
|
2861
|
-
if (!
|
|
3397
|
+
if (!existsSync(inputPath))
|
|
2862
3398
|
return false;
|
|
2863
3399
|
try {
|
|
2864
|
-
const data = JSON.parse(
|
|
3400
|
+
const data = JSON.parse(readFileSync(inputPath, "utf-8"));
|
|
2865
3401
|
data.name = name;
|
|
2866
|
-
const sessionPath =
|
|
2867
|
-
|
|
3402
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3403
|
+
writeFileSync(sessionPath, JSON.stringify(data, null, 2));
|
|
2868
3404
|
return true;
|
|
2869
3405
|
}
|
|
2870
3406
|
catch {
|
|
@@ -2879,7 +3415,7 @@ class CBrowser {
|
|
|
2879
3415
|
*/
|
|
2880
3416
|
async journey(options) {
|
|
2881
3417
|
const { persona: personaName, startUrl, goal, maxSteps = 20 } = options;
|
|
2882
|
-
const persona =
|
|
3418
|
+
const persona = getPersona(personaName) || BUILTIN_PERSONAS["first-timer"];
|
|
2883
3419
|
this.currentPersona = persona;
|
|
2884
3420
|
// Set viewport based on persona
|
|
2885
3421
|
if (persona.context?.viewport) {
|
|
@@ -2969,8 +3505,8 @@ class CBrowser {
|
|
|
2969
3505
|
consoleLogs,
|
|
2970
3506
|
};
|
|
2971
3507
|
// Save journey results
|
|
2972
|
-
const journeyFile =
|
|
2973
|
-
|
|
3508
|
+
const journeyFile = join(this.paths.dataDir, `journey-${Date.now()}.json`);
|
|
3509
|
+
writeFileSync(journeyFile, JSON.stringify(result, null, 2));
|
|
2974
3510
|
return result;
|
|
2975
3511
|
}
|
|
2976
3512
|
/**
|
|
@@ -3027,13 +3563,13 @@ class CBrowser {
|
|
|
3027
3563
|
result,
|
|
3028
3564
|
persona: this.currentPersona?.name,
|
|
3029
3565
|
};
|
|
3030
|
-
const auditFile =
|
|
3566
|
+
const auditFile = join(this.paths.auditDir, `audit-${new Date().toISOString().split("T")[0]}.json`);
|
|
3031
3567
|
let entries = [];
|
|
3032
|
-
if (
|
|
3033
|
-
entries = JSON.parse(
|
|
3568
|
+
if (existsSync(auditFile)) {
|
|
3569
|
+
entries = JSON.parse(readFileSync(auditFile, "utf-8"));
|
|
3034
3570
|
}
|
|
3035
3571
|
entries.push(entry);
|
|
3036
|
-
|
|
3572
|
+
writeFileSync(auditFile, JSON.stringify(entries, null, 2));
|
|
3037
3573
|
}
|
|
3038
3574
|
// =========================================================================
|
|
3039
3575
|
// Cleanup
|
|
@@ -3054,22 +3590,22 @@ class CBrowser {
|
|
|
3054
3590
|
},
|
|
3055
3591
|
};
|
|
3056
3592
|
const cleanDir = (dir, pattern, keep, category) => {
|
|
3057
|
-
if (!
|
|
3593
|
+
if (!existsSync(dir))
|
|
3058
3594
|
return;
|
|
3059
|
-
const files =
|
|
3595
|
+
const files = readdirSync(dir)
|
|
3060
3596
|
.filter((f) => pattern.test(f))
|
|
3061
3597
|
.map((f) => ({
|
|
3062
3598
|
name: f,
|
|
3063
|
-
path:
|
|
3064
|
-
mtime:
|
|
3065
|
-
size:
|
|
3599
|
+
path: join(dir, f),
|
|
3600
|
+
mtime: statSync(join(dir, f)).mtime,
|
|
3601
|
+
size: statSync(join(dir, f)).size,
|
|
3066
3602
|
}))
|
|
3067
3603
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
3068
3604
|
const cutoff = Date.now() - olderThan * 24 * 60 * 60 * 1000;
|
|
3069
3605
|
const toDelete = files.slice(keep).filter((f) => f.mtime.getTime() < cutoff);
|
|
3070
3606
|
for (const file of toDelete) {
|
|
3071
3607
|
if (!dryRun) {
|
|
3072
|
-
|
|
3608
|
+
unlinkSync(file.path);
|
|
3073
3609
|
}
|
|
3074
3610
|
result.deleted++;
|
|
3075
3611
|
result.freedBytes += file.size;
|
|
@@ -3089,12 +3625,12 @@ class CBrowser {
|
|
|
3089
3625
|
getStorageStats() {
|
|
3090
3626
|
const stats = {};
|
|
3091
3627
|
const countDir = (dir, pattern) => {
|
|
3092
|
-
if (!
|
|
3628
|
+
if (!existsSync(dir))
|
|
3093
3629
|
return { count: 0, size: 0 };
|
|
3094
|
-
const files =
|
|
3630
|
+
const files = readdirSync(dir).filter((f) => pattern.test(f));
|
|
3095
3631
|
let size = 0;
|
|
3096
3632
|
for (const file of files) {
|
|
3097
|
-
size +=
|
|
3633
|
+
size += statSync(join(dir, file)).size;
|
|
3098
3634
|
}
|
|
3099
3635
|
return { count: files.length, size };
|
|
3100
3636
|
};
|
|
@@ -3117,12 +3653,12 @@ class CBrowser {
|
|
|
3117
3653
|
* Save a visual baseline screenshot.
|
|
3118
3654
|
*/
|
|
3119
3655
|
async saveBaseline(name, url) {
|
|
3120
|
-
const baselinesDir =
|
|
3121
|
-
if (!
|
|
3122
|
-
|
|
3656
|
+
const baselinesDir = join(this.paths.dataDir, "baselines");
|
|
3657
|
+
if (!existsSync(baselinesDir)) {
|
|
3658
|
+
mkdirSync(baselinesDir, { recursive: true });
|
|
3123
3659
|
}
|
|
3124
3660
|
const page = await this.getPage();
|
|
3125
|
-
const screenshotPath =
|
|
3661
|
+
const screenshotPath = join(baselinesDir, `${name}.png`);
|
|
3126
3662
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
3127
3663
|
const baseline = {
|
|
3128
3664
|
name,
|
|
@@ -3132,25 +3668,25 @@ class CBrowser {
|
|
|
3132
3668
|
created: new Date().toISOString(),
|
|
3133
3669
|
lastUsed: new Date().toISOString(),
|
|
3134
3670
|
};
|
|
3135
|
-
const metaPath =
|
|
3136
|
-
|
|
3671
|
+
const metaPath = join(baselinesDir, `${name}.json`);
|
|
3672
|
+
writeFileSync(metaPath, JSON.stringify(baseline, null, 2));
|
|
3137
3673
|
return screenshotPath;
|
|
3138
3674
|
}
|
|
3139
3675
|
/**
|
|
3140
3676
|
* Compare current page to a baseline.
|
|
3141
3677
|
*/
|
|
3142
3678
|
async compareBaseline(name, threshold = 0.1) {
|
|
3143
|
-
const baselinesDir =
|
|
3144
|
-
const metaPath =
|
|
3145
|
-
if (!
|
|
3679
|
+
const baselinesDir = join(this.paths.dataDir, "baselines");
|
|
3680
|
+
const metaPath = join(baselinesDir, `${name}.json`);
|
|
3681
|
+
if (!existsSync(metaPath)) {
|
|
3146
3682
|
throw new Error(`Baseline not found: ${name}`);
|
|
3147
3683
|
}
|
|
3148
|
-
const baseline = JSON.parse(
|
|
3684
|
+
const baseline = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
3149
3685
|
const page = await this.getPage();
|
|
3150
|
-
const currentPath =
|
|
3686
|
+
const currentPath = join(baselinesDir, `${name}-current-${Date.now()}.png`);
|
|
3151
3687
|
await page.screenshot({ path: currentPath, fullPage: true });
|
|
3152
|
-
const baselineBuffer =
|
|
3153
|
-
const currentBuffer =
|
|
3688
|
+
const baselineBuffer = readFileSync(baseline.screenshotPath);
|
|
3689
|
+
const currentBuffer = readFileSync(currentPath);
|
|
3154
3690
|
const sizeDiff = Math.abs(baselineBuffer.length - currentBuffer.length);
|
|
3155
3691
|
const maxSize = Math.max(baselineBuffer.length, currentBuffer.length);
|
|
3156
3692
|
const diffPercentage = sizeDiff / maxSize;
|
|
@@ -3165,10 +3701,10 @@ class CBrowser {
|
|
|
3165
3701
|
* List all visual baselines.
|
|
3166
3702
|
*/
|
|
3167
3703
|
listBaselines() {
|
|
3168
|
-
const baselinesDir =
|
|
3169
|
-
if (!
|
|
3704
|
+
const baselinesDir = join(this.paths.dataDir, "baselines");
|
|
3705
|
+
if (!existsSync(baselinesDir))
|
|
3170
3706
|
return [];
|
|
3171
|
-
return
|
|
3707
|
+
return readdirSync(baselinesDir)
|
|
3172
3708
|
.filter(f => f.endsWith(".json"))
|
|
3173
3709
|
.map(f => f.replace(".json", ""));
|
|
3174
3710
|
}
|
|
@@ -3266,17 +3802,17 @@ class CBrowser {
|
|
|
3266
3802
|
* Save recording to file.
|
|
3267
3803
|
*/
|
|
3268
3804
|
saveRecording(name, actions) {
|
|
3269
|
-
const recordingsDir =
|
|
3270
|
-
if (!
|
|
3271
|
-
|
|
3805
|
+
const recordingsDir = join(this.paths.dataDir, "recordings");
|
|
3806
|
+
if (!existsSync(recordingsDir)) {
|
|
3807
|
+
mkdirSync(recordingsDir, { recursive: true });
|
|
3272
3808
|
}
|
|
3273
3809
|
const recording = {
|
|
3274
3810
|
name,
|
|
3275
3811
|
actions: actions || this.recordingActions,
|
|
3276
3812
|
created: new Date().toISOString(),
|
|
3277
3813
|
};
|
|
3278
|
-
const filePath =
|
|
3279
|
-
|
|
3814
|
+
const filePath = join(recordingsDir, `${name}.json`);
|
|
3815
|
+
writeFileSync(filePath, JSON.stringify(recording, null, 2));
|
|
3280
3816
|
return filePath;
|
|
3281
3817
|
}
|
|
3282
3818
|
/**
|
|
@@ -3312,7 +3848,7 @@ class CBrowser {
|
|
|
3312
3848
|
* Export test results as JUnit XML.
|
|
3313
3849
|
*/
|
|
3314
3850
|
exportJUnit(suite, outputPath) {
|
|
3315
|
-
const filename = outputPath ||
|
|
3851
|
+
const filename = outputPath || join(this.paths.dataDir, `junit-${Date.now()}.xml`);
|
|
3316
3852
|
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
|
3317
3853
|
xml += `<testsuite name="${suite.name}" tests="${suite.tests.length}">\n`;
|
|
3318
3854
|
for (const test of suite.tests) {
|
|
@@ -3323,21 +3859,21 @@ class CBrowser {
|
|
|
3323
3859
|
xml += ` </testcase>\n`;
|
|
3324
3860
|
}
|
|
3325
3861
|
xml += `</testsuite>\n`;
|
|
3326
|
-
|
|
3862
|
+
writeFileSync(filename, xml);
|
|
3327
3863
|
return filename;
|
|
3328
3864
|
}
|
|
3329
3865
|
/**
|
|
3330
3866
|
* Export test results as TAP format.
|
|
3331
3867
|
*/
|
|
3332
3868
|
exportTAP(suite, outputPath) {
|
|
3333
|
-
const filename = outputPath ||
|
|
3869
|
+
const filename = outputPath || join(this.paths.dataDir, `tap-${Date.now()}.tap`);
|
|
3334
3870
|
let tap = `TAP version 13\n`;
|
|
3335
3871
|
tap += `1..${suite.tests.length}\n`;
|
|
3336
3872
|
suite.tests.forEach((test, i) => {
|
|
3337
3873
|
const status = test.status === "passed" ? "ok" : "not ok";
|
|
3338
3874
|
tap += `${status} ${i + 1} ${test.name}\n`;
|
|
3339
3875
|
});
|
|
3340
|
-
|
|
3876
|
+
writeFileSync(filename, tap);
|
|
3341
3877
|
return filename;
|
|
3342
3878
|
}
|
|
3343
3879
|
// =========================================================================
|
|
@@ -3413,11 +3949,10 @@ class CBrowser {
|
|
|
3413
3949
|
return new FluentCBrowser(this);
|
|
3414
3950
|
}
|
|
3415
3951
|
}
|
|
3416
|
-
exports.CBrowser = CBrowser;
|
|
3417
3952
|
/**
|
|
3418
3953
|
* Fluent wrapper for chainable API.
|
|
3419
3954
|
*/
|
|
3420
|
-
class FluentCBrowser {
|
|
3955
|
+
export class FluentCBrowser {
|
|
3421
3956
|
browser;
|
|
3422
3957
|
constructor(browser) {
|
|
3423
3958
|
this.browser = browser;
|
|
@@ -3449,5 +3984,4 @@ class FluentCBrowser {
|
|
|
3449
3984
|
return this.browser;
|
|
3450
3985
|
}
|
|
3451
3986
|
}
|
|
3452
|
-
exports.FluentCBrowser = FluentCBrowser;
|
|
3453
3987
|
//# sourceMappingURL=browser.js.map
|