cbrowser 8.8.0 → 8.9.1
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 +19 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +309 -132
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +138 -131
- package/dist/cli.js.map +1 -1
- package/dist/cognitive/index.d.ts.map +1 -1
- package/dist/cognitive/index.js +61 -47
- 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
|
|
@@ -1372,6 +1369,82 @@ class CBrowser {
|
|
|
1372
1369
|
const result = await this.selectCustomDropdownOption(trigger, value, options);
|
|
1373
1370
|
return result;
|
|
1374
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
|
+
}
|
|
1375
1448
|
/**
|
|
1376
1449
|
* Hover then click - useful for dropdown menus that need hover to reveal items.
|
|
1377
1450
|
* ALWAYS hovers the parent menu and keeps it hovered while clicking the submenu item.
|
|
@@ -1540,6 +1613,45 @@ class CBrowser {
|
|
|
1540
1613
|
}
|
|
1541
1614
|
}
|
|
1542
1615
|
}
|
|
1616
|
+
// Check if this is a select element - requires selectOption instead of fill
|
|
1617
|
+
const tagName = await element.evaluate((el) => el.tagName.toLowerCase());
|
|
1618
|
+
if (tagName === 'select') {
|
|
1619
|
+
// For select elements, use selectOption
|
|
1620
|
+
// Try to match by label text first, then by value
|
|
1621
|
+
try {
|
|
1622
|
+
await element.selectOption({ label: value });
|
|
1623
|
+
}
|
|
1624
|
+
catch {
|
|
1625
|
+
// If label match fails, try value match
|
|
1626
|
+
try {
|
|
1627
|
+
await element.selectOption({ value: value });
|
|
1628
|
+
}
|
|
1629
|
+
catch {
|
|
1630
|
+
// Try partial/case-insensitive label match
|
|
1631
|
+
const options = await element.evaluate((el) => {
|
|
1632
|
+
const select = el;
|
|
1633
|
+
return Array.from(select.options).map(o => ({
|
|
1634
|
+
value: o.value,
|
|
1635
|
+
text: o.text,
|
|
1636
|
+
}));
|
|
1637
|
+
});
|
|
1638
|
+
const match = options.find(o => o.text.toLowerCase().includes(value.toLowerCase()) ||
|
|
1639
|
+
o.value.toLowerCase().includes(value.toLowerCase()));
|
|
1640
|
+
if (match) {
|
|
1641
|
+
await element.selectOption({ value: match.value });
|
|
1642
|
+
}
|
|
1643
|
+
else {
|
|
1644
|
+
throw new Error(`No option matching "${value}" in select. Available: ${options.map(o => o.text).join(', ')}`);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
this.audit("fill", `${selector} (select)`, "yellow", "success");
|
|
1649
|
+
return {
|
|
1650
|
+
success: true,
|
|
1651
|
+
screenshot: await this.screenshot(),
|
|
1652
|
+
message: `Selected: ${value} in ${selector}`,
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1543
1655
|
// Standard fill - works for visible inputs and textareas
|
|
1544
1656
|
await element.fill(value);
|
|
1545
1657
|
this.audit("fill", selector, "yellow", "success");
|
|
@@ -2115,7 +2227,7 @@ class CBrowser {
|
|
|
2115
2227
|
* Get the selector cache file path.
|
|
2116
2228
|
*/
|
|
2117
2229
|
getSelectorCachePath() {
|
|
2118
|
-
return
|
|
2230
|
+
return join(this.paths.dataDir, "selector-cache.json");
|
|
2119
2231
|
}
|
|
2120
2232
|
/**
|
|
2121
2233
|
* Load the selector cache from disk.
|
|
@@ -2124,9 +2236,9 @@ class CBrowser {
|
|
|
2124
2236
|
if (this.selectorCache)
|
|
2125
2237
|
return this.selectorCache;
|
|
2126
2238
|
const cachePath = this.getSelectorCachePath();
|
|
2127
|
-
if (
|
|
2239
|
+
if (existsSync(cachePath)) {
|
|
2128
2240
|
try {
|
|
2129
|
-
const data =
|
|
2241
|
+
const data = readFileSync(cachePath, "utf-8");
|
|
2130
2242
|
this.selectorCache = JSON.parse(data);
|
|
2131
2243
|
return this.selectorCache;
|
|
2132
2244
|
}
|
|
@@ -2144,7 +2256,7 @@ class CBrowser {
|
|
|
2144
2256
|
if (!this.selectorCache)
|
|
2145
2257
|
return;
|
|
2146
2258
|
const cachePath = this.getSelectorCachePath();
|
|
2147
|
-
|
|
2259
|
+
writeFileSync(cachePath, JSON.stringify(this.selectorCache, null, 2));
|
|
2148
2260
|
}
|
|
2149
2261
|
/**
|
|
2150
2262
|
* Get cache key for a selector (includes domain for context).
|
|
@@ -2622,10 +2734,23 @@ class CBrowser {
|
|
|
2622
2734
|
/**
|
|
2623
2735
|
* Get all input fields on the page for verbose output.
|
|
2624
2736
|
*/
|
|
2737
|
+
/**
|
|
2738
|
+
* Get available form inputs on the page, including hidden ones with custom UI triggers.
|
|
2739
|
+
* Public so cognitive journey can use it.
|
|
2740
|
+
*/
|
|
2625
2741
|
async getAvailableInputs(page) {
|
|
2742
|
+
const activePage = page || await this.getPage();
|
|
2626
2743
|
try {
|
|
2627
|
-
return await
|
|
2744
|
+
return await activePage.$$eval('input, textarea, select', els => els.slice(0, 20).map(el => {
|
|
2628
2745
|
const input = el;
|
|
2746
|
+
const htmlEl = el;
|
|
2747
|
+
// Check if element is hidden (common with custom dropdowns)
|
|
2748
|
+
const style = window.getComputedStyle(el);
|
|
2749
|
+
const isHidden = style.display === 'none' ||
|
|
2750
|
+
style.visibility === 'hidden' ||
|
|
2751
|
+
parseFloat(style.opacity) === 0 ||
|
|
2752
|
+
htmlEl.offsetWidth === 0 ||
|
|
2753
|
+
htmlEl.offsetHeight === 0;
|
|
2629
2754
|
// Find associated label
|
|
2630
2755
|
let label = "";
|
|
2631
2756
|
if (input.id) {
|
|
@@ -2636,12 +2761,46 @@ class CBrowser {
|
|
|
2636
2761
|
if (!label && input.closest("label")) {
|
|
2637
2762
|
label = input.closest("label").innerText?.trim().substring(0, 50) || "";
|
|
2638
2763
|
}
|
|
2764
|
+
// For hidden elements, try to find the visible trigger
|
|
2765
|
+
let triggerText = "";
|
|
2766
|
+
if (isHidden) {
|
|
2767
|
+
// Look for sibling or parent with visible text (custom dropdown trigger)
|
|
2768
|
+
const parent = el.parentElement;
|
|
2769
|
+
if (parent) {
|
|
2770
|
+
const siblings = Array.from(parent.children).filter(c => c !== el);
|
|
2771
|
+
for (const sib of siblings) {
|
|
2772
|
+
const sibStyle = window.getComputedStyle(sib);
|
|
2773
|
+
if (sibStyle.display !== 'none' && sib.innerText?.trim()) {
|
|
2774
|
+
triggerText = sib.innerText.trim().substring(0, 50);
|
|
2775
|
+
break;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
// Also check for aria-labelledby or data attributes
|
|
2780
|
+
if (!triggerText) {
|
|
2781
|
+
const ariaLabel = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby');
|
|
2782
|
+
if (ariaLabel)
|
|
2783
|
+
triggerText = ariaLabel.substring(0, 50);
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
// For select elements, get available options
|
|
2787
|
+
let options;
|
|
2788
|
+
if (el.tagName.toLowerCase() === 'select') {
|
|
2789
|
+
const selectEl = el;
|
|
2790
|
+
options = Array.from(selectEl.options)
|
|
2791
|
+
.filter(opt => opt.value && opt.text.trim()) // Skip empty/placeholder options
|
|
2792
|
+
.slice(0, 10) // Limit to first 10 options
|
|
2793
|
+
.map(opt => opt.text.trim());
|
|
2794
|
+
}
|
|
2639
2795
|
return {
|
|
2640
2796
|
selector: input.id ? `#${input.id}` : input.name ? `[name="${input.name}"]` : input.placeholder ? `[placeholder="${input.placeholder}"]` : `${el.tagName.toLowerCase()}`,
|
|
2641
2797
|
type: input.type || el.tagName.toLowerCase(),
|
|
2642
2798
|
name: input.name || "",
|
|
2643
2799
|
placeholder: input.placeholder || "",
|
|
2644
2800
|
label,
|
|
2801
|
+
isHidden,
|
|
2802
|
+
triggerText,
|
|
2803
|
+
options,
|
|
2645
2804
|
};
|
|
2646
2805
|
}));
|
|
2647
2806
|
}
|
|
@@ -2706,7 +2865,7 @@ class CBrowser {
|
|
|
2706
2865
|
// Take screenshot
|
|
2707
2866
|
const dir = options.debugDir || this.paths.screenshotsDir;
|
|
2708
2867
|
const filename = `debug-${Date.now()}.png`;
|
|
2709
|
-
const filepath =
|
|
2868
|
+
const filepath = join(dir, filename);
|
|
2710
2869
|
await page.screenshot({ path: filepath, fullPage: false });
|
|
2711
2870
|
// Clean up highlights
|
|
2712
2871
|
await page.evaluate(() => {
|
|
@@ -2891,10 +3050,30 @@ class CBrowser {
|
|
|
2891
3050
|
const [role, name] = selector.slice(5).split("/");
|
|
2892
3051
|
return page.getByRole(role, { name }).first();
|
|
2893
3052
|
}
|
|
2894
|
-
// Strategy 3: Text content
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
3053
|
+
// Strategy 3: Text content - prefer exact matches over fuzzy, non-sticky over sticky
|
|
3054
|
+
// First, try exact text match
|
|
3055
|
+
const byTextExact = page.getByText(selector, { exact: true });
|
|
3056
|
+
const exactCount = await byTextExact.count();
|
|
3057
|
+
if (exactCount > 0) {
|
|
3058
|
+
// If multiple exact matches, prefer non-sticky elements
|
|
3059
|
+
if (exactCount > 1) {
|
|
3060
|
+
const bestMatch = await this.findBestClickCandidate(byTextExact, selector);
|
|
3061
|
+
if (bestMatch)
|
|
3062
|
+
return bestMatch;
|
|
3063
|
+
}
|
|
3064
|
+
return byTextExact.first();
|
|
3065
|
+
}
|
|
3066
|
+
// Then try partial/fuzzy text match (but with sticky deprioritization)
|
|
3067
|
+
const byTextFuzzy = page.getByText(selector, { exact: false });
|
|
3068
|
+
const fuzzyCount = await byTextFuzzy.count();
|
|
3069
|
+
if (fuzzyCount > 0) {
|
|
3070
|
+
// If multiple fuzzy matches, prefer non-sticky elements and shorter/exact matches
|
|
3071
|
+
if (fuzzyCount > 1) {
|
|
3072
|
+
const bestMatch = await this.findBestClickCandidate(byTextFuzzy, selector);
|
|
3073
|
+
if (bestMatch)
|
|
3074
|
+
return bestMatch;
|
|
3075
|
+
}
|
|
3076
|
+
return byTextFuzzy.first();
|
|
2898
3077
|
}
|
|
2899
3078
|
// Strategy 4: Placeholder
|
|
2900
3079
|
const byPlaceholder = page.getByPlaceholder(selector).first();
|
|
@@ -3074,7 +3253,7 @@ class CBrowser {
|
|
|
3074
3253
|
*/
|
|
3075
3254
|
async screenshot(path) {
|
|
3076
3255
|
const page = await this.getPage();
|
|
3077
|
-
const filename = path ||
|
|
3256
|
+
const filename = path || join(this.paths.screenshotsDir, `screenshot-${Date.now()}.png`);
|
|
3078
3257
|
await page.screenshot({ path: filename, fullPage: false });
|
|
3079
3258
|
return filename;
|
|
3080
3259
|
}
|
|
@@ -3134,18 +3313,18 @@ class CBrowser {
|
|
|
3134
3313
|
localStorage,
|
|
3135
3314
|
sessionStorage,
|
|
3136
3315
|
};
|
|
3137
|
-
const sessionPath =
|
|
3138
|
-
|
|
3316
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3317
|
+
writeFileSync(sessionPath, JSON.stringify(session, null, 2));
|
|
3139
3318
|
}
|
|
3140
3319
|
/**
|
|
3141
3320
|
* Load a saved session.
|
|
3142
3321
|
*/
|
|
3143
3322
|
async loadSession(name) {
|
|
3144
|
-
const sessionPath =
|
|
3145
|
-
if (!
|
|
3323
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3324
|
+
if (!existsSync(sessionPath)) {
|
|
3146
3325
|
return { success: false, name, cookiesRestored: 0, localStorageKeysRestored: 0, sessionStorageKeysRestored: 0 };
|
|
3147
3326
|
}
|
|
3148
|
-
const session = JSON.parse(
|
|
3327
|
+
const session = JSON.parse(readFileSync(sessionPath, "utf-8"));
|
|
3149
3328
|
const page = await this.getPage();
|
|
3150
3329
|
const context = this.context;
|
|
3151
3330
|
const result = {
|
|
@@ -3190,29 +3369,29 @@ class CBrowser {
|
|
|
3190
3369
|
await page.reload({ waitUntil: "networkidle" });
|
|
3191
3370
|
// Update lastUsed
|
|
3192
3371
|
session.lastUsed = new Date().toISOString();
|
|
3193
|
-
|
|
3372
|
+
writeFileSync(sessionPath, JSON.stringify(session, null, 2));
|
|
3194
3373
|
return result;
|
|
3195
3374
|
}
|
|
3196
3375
|
/**
|
|
3197
3376
|
* List all saved session names.
|
|
3198
3377
|
*/
|
|
3199
3378
|
listSessions() {
|
|
3200
|
-
const files =
|
|
3379
|
+
const files = readdirSync(this.paths.sessionsDir);
|
|
3201
3380
|
return files.filter((f) => f.endsWith(".json") && f !== "last-session.json").map((f) => f.replace(".json", ""));
|
|
3202
3381
|
}
|
|
3203
3382
|
/**
|
|
3204
3383
|
* List all saved sessions with rich metadata.
|
|
3205
3384
|
*/
|
|
3206
3385
|
listSessionsDetailed() {
|
|
3207
|
-
const files =
|
|
3386
|
+
const files = readdirSync(this.paths.sessionsDir);
|
|
3208
3387
|
const sessions = [];
|
|
3209
3388
|
for (const file of files) {
|
|
3210
3389
|
if (!file.endsWith(".json") || file === "last-session.json")
|
|
3211
3390
|
continue;
|
|
3212
|
-
const filePath =
|
|
3391
|
+
const filePath = join(this.paths.sessionsDir, file);
|
|
3213
3392
|
try {
|
|
3214
|
-
const data = JSON.parse(
|
|
3215
|
-
const stats =
|
|
3393
|
+
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
3394
|
+
const stats = statSync(filePath);
|
|
3216
3395
|
sessions.push({
|
|
3217
3396
|
name: data.name || file.replace(".json", ""),
|
|
3218
3397
|
created: data.created,
|
|
@@ -3235,11 +3414,11 @@ class CBrowser {
|
|
|
3235
3414
|
* Get detailed info for a single session.
|
|
3236
3415
|
*/
|
|
3237
3416
|
getSessionDetails(name) {
|
|
3238
|
-
const sessionPath =
|
|
3239
|
-
if (!
|
|
3417
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3418
|
+
if (!existsSync(sessionPath))
|
|
3240
3419
|
return null;
|
|
3241
3420
|
try {
|
|
3242
|
-
return JSON.parse(
|
|
3421
|
+
return JSON.parse(readFileSync(sessionPath, "utf-8"));
|
|
3243
3422
|
}
|
|
3244
3423
|
catch {
|
|
3245
3424
|
return null;
|
|
@@ -3249,9 +3428,9 @@ class CBrowser {
|
|
|
3249
3428
|
* Delete a saved session.
|
|
3250
3429
|
*/
|
|
3251
3430
|
deleteSession(name) {
|
|
3252
|
-
const sessionPath =
|
|
3253
|
-
if (
|
|
3254
|
-
|
|
3431
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3432
|
+
if (existsSync(sessionPath)) {
|
|
3433
|
+
unlinkSync(sessionPath);
|
|
3255
3434
|
return true;
|
|
3256
3435
|
}
|
|
3257
3436
|
return false;
|
|
@@ -3263,17 +3442,17 @@ class CBrowser {
|
|
|
3263
3442
|
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
|
|
3264
3443
|
const deleted = [];
|
|
3265
3444
|
const kept = [];
|
|
3266
|
-
const files =
|
|
3445
|
+
const files = readdirSync(this.paths.sessionsDir);
|
|
3267
3446
|
for (const file of files) {
|
|
3268
3447
|
if (!file.endsWith(".json") || file === "last-session.json")
|
|
3269
3448
|
continue;
|
|
3270
|
-
const filePath =
|
|
3449
|
+
const filePath = join(this.paths.sessionsDir, file);
|
|
3271
3450
|
const name = file.replace(".json", "");
|
|
3272
3451
|
try {
|
|
3273
|
-
const data = JSON.parse(
|
|
3452
|
+
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
3274
3453
|
const lastUsed = new Date(data.lastUsed).getTime();
|
|
3275
3454
|
if (lastUsed < cutoff) {
|
|
3276
|
-
|
|
3455
|
+
unlinkSync(filePath);
|
|
3277
3456
|
deleted.push(name);
|
|
3278
3457
|
}
|
|
3279
3458
|
else {
|
|
@@ -3290,24 +3469,24 @@ class CBrowser {
|
|
|
3290
3469
|
* Export a session to a portable JSON file.
|
|
3291
3470
|
*/
|
|
3292
3471
|
exportSession(name, outputPath) {
|
|
3293
|
-
const sessionPath =
|
|
3294
|
-
if (!
|
|
3472
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3473
|
+
if (!existsSync(sessionPath))
|
|
3295
3474
|
return false;
|
|
3296
|
-
const data =
|
|
3297
|
-
|
|
3475
|
+
const data = readFileSync(sessionPath, "utf-8");
|
|
3476
|
+
writeFileSync(outputPath, data);
|
|
3298
3477
|
return true;
|
|
3299
3478
|
}
|
|
3300
3479
|
/**
|
|
3301
3480
|
* Import a session from a JSON file.
|
|
3302
3481
|
*/
|
|
3303
3482
|
importSession(inputPath, name) {
|
|
3304
|
-
if (!
|
|
3483
|
+
if (!existsSync(inputPath))
|
|
3305
3484
|
return false;
|
|
3306
3485
|
try {
|
|
3307
|
-
const data = JSON.parse(
|
|
3486
|
+
const data = JSON.parse(readFileSync(inputPath, "utf-8"));
|
|
3308
3487
|
data.name = name;
|
|
3309
|
-
const sessionPath =
|
|
3310
|
-
|
|
3488
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3489
|
+
writeFileSync(sessionPath, JSON.stringify(data, null, 2));
|
|
3311
3490
|
return true;
|
|
3312
3491
|
}
|
|
3313
3492
|
catch {
|
|
@@ -3322,7 +3501,7 @@ class CBrowser {
|
|
|
3322
3501
|
*/
|
|
3323
3502
|
async journey(options) {
|
|
3324
3503
|
const { persona: personaName, startUrl, goal, maxSteps = 20 } = options;
|
|
3325
|
-
const persona =
|
|
3504
|
+
const persona = getPersona(personaName) || BUILTIN_PERSONAS["first-timer"];
|
|
3326
3505
|
this.currentPersona = persona;
|
|
3327
3506
|
// Set viewport based on persona
|
|
3328
3507
|
if (persona.context?.viewport) {
|
|
@@ -3412,8 +3591,8 @@ class CBrowser {
|
|
|
3412
3591
|
consoleLogs,
|
|
3413
3592
|
};
|
|
3414
3593
|
// Save journey results
|
|
3415
|
-
const journeyFile =
|
|
3416
|
-
|
|
3594
|
+
const journeyFile = join(this.paths.dataDir, `journey-${Date.now()}.json`);
|
|
3595
|
+
writeFileSync(journeyFile, JSON.stringify(result, null, 2));
|
|
3417
3596
|
return result;
|
|
3418
3597
|
}
|
|
3419
3598
|
/**
|
|
@@ -3470,13 +3649,13 @@ class CBrowser {
|
|
|
3470
3649
|
result,
|
|
3471
3650
|
persona: this.currentPersona?.name,
|
|
3472
3651
|
};
|
|
3473
|
-
const auditFile =
|
|
3652
|
+
const auditFile = join(this.paths.auditDir, `audit-${new Date().toISOString().split("T")[0]}.json`);
|
|
3474
3653
|
let entries = [];
|
|
3475
|
-
if (
|
|
3476
|
-
entries = JSON.parse(
|
|
3654
|
+
if (existsSync(auditFile)) {
|
|
3655
|
+
entries = JSON.parse(readFileSync(auditFile, "utf-8"));
|
|
3477
3656
|
}
|
|
3478
3657
|
entries.push(entry);
|
|
3479
|
-
|
|
3658
|
+
writeFileSync(auditFile, JSON.stringify(entries, null, 2));
|
|
3480
3659
|
}
|
|
3481
3660
|
// =========================================================================
|
|
3482
3661
|
// Cleanup
|
|
@@ -3497,22 +3676,22 @@ class CBrowser {
|
|
|
3497
3676
|
},
|
|
3498
3677
|
};
|
|
3499
3678
|
const cleanDir = (dir, pattern, keep, category) => {
|
|
3500
|
-
if (!
|
|
3679
|
+
if (!existsSync(dir))
|
|
3501
3680
|
return;
|
|
3502
|
-
const files =
|
|
3681
|
+
const files = readdirSync(dir)
|
|
3503
3682
|
.filter((f) => pattern.test(f))
|
|
3504
3683
|
.map((f) => ({
|
|
3505
3684
|
name: f,
|
|
3506
|
-
path:
|
|
3507
|
-
mtime:
|
|
3508
|
-
size:
|
|
3685
|
+
path: join(dir, f),
|
|
3686
|
+
mtime: statSync(join(dir, f)).mtime,
|
|
3687
|
+
size: statSync(join(dir, f)).size,
|
|
3509
3688
|
}))
|
|
3510
3689
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
3511
3690
|
const cutoff = Date.now() - olderThan * 24 * 60 * 60 * 1000;
|
|
3512
3691
|
const toDelete = files.slice(keep).filter((f) => f.mtime.getTime() < cutoff);
|
|
3513
3692
|
for (const file of toDelete) {
|
|
3514
3693
|
if (!dryRun) {
|
|
3515
|
-
|
|
3694
|
+
unlinkSync(file.path);
|
|
3516
3695
|
}
|
|
3517
3696
|
result.deleted++;
|
|
3518
3697
|
result.freedBytes += file.size;
|
|
@@ -3532,12 +3711,12 @@ class CBrowser {
|
|
|
3532
3711
|
getStorageStats() {
|
|
3533
3712
|
const stats = {};
|
|
3534
3713
|
const countDir = (dir, pattern) => {
|
|
3535
|
-
if (!
|
|
3714
|
+
if (!existsSync(dir))
|
|
3536
3715
|
return { count: 0, size: 0 };
|
|
3537
|
-
const files =
|
|
3716
|
+
const files = readdirSync(dir).filter((f) => pattern.test(f));
|
|
3538
3717
|
let size = 0;
|
|
3539
3718
|
for (const file of files) {
|
|
3540
|
-
size +=
|
|
3719
|
+
size += statSync(join(dir, file)).size;
|
|
3541
3720
|
}
|
|
3542
3721
|
return { count: files.length, size };
|
|
3543
3722
|
};
|
|
@@ -3560,12 +3739,12 @@ class CBrowser {
|
|
|
3560
3739
|
* Save a visual baseline screenshot.
|
|
3561
3740
|
*/
|
|
3562
3741
|
async saveBaseline(name, url) {
|
|
3563
|
-
const baselinesDir =
|
|
3564
|
-
if (!
|
|
3565
|
-
|
|
3742
|
+
const baselinesDir = join(this.paths.dataDir, "baselines");
|
|
3743
|
+
if (!existsSync(baselinesDir)) {
|
|
3744
|
+
mkdirSync(baselinesDir, { recursive: true });
|
|
3566
3745
|
}
|
|
3567
3746
|
const page = await this.getPage();
|
|
3568
|
-
const screenshotPath =
|
|
3747
|
+
const screenshotPath = join(baselinesDir, `${name}.png`);
|
|
3569
3748
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
3570
3749
|
const baseline = {
|
|
3571
3750
|
name,
|
|
@@ -3575,25 +3754,25 @@ class CBrowser {
|
|
|
3575
3754
|
created: new Date().toISOString(),
|
|
3576
3755
|
lastUsed: new Date().toISOString(),
|
|
3577
3756
|
};
|
|
3578
|
-
const metaPath =
|
|
3579
|
-
|
|
3757
|
+
const metaPath = join(baselinesDir, `${name}.json`);
|
|
3758
|
+
writeFileSync(metaPath, JSON.stringify(baseline, null, 2));
|
|
3580
3759
|
return screenshotPath;
|
|
3581
3760
|
}
|
|
3582
3761
|
/**
|
|
3583
3762
|
* Compare current page to a baseline.
|
|
3584
3763
|
*/
|
|
3585
3764
|
async compareBaseline(name, threshold = 0.1) {
|
|
3586
|
-
const baselinesDir =
|
|
3587
|
-
const metaPath =
|
|
3588
|
-
if (!
|
|
3765
|
+
const baselinesDir = join(this.paths.dataDir, "baselines");
|
|
3766
|
+
const metaPath = join(baselinesDir, `${name}.json`);
|
|
3767
|
+
if (!existsSync(metaPath)) {
|
|
3589
3768
|
throw new Error(`Baseline not found: ${name}`);
|
|
3590
3769
|
}
|
|
3591
|
-
const baseline = JSON.parse(
|
|
3770
|
+
const baseline = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
3592
3771
|
const page = await this.getPage();
|
|
3593
|
-
const currentPath =
|
|
3772
|
+
const currentPath = join(baselinesDir, `${name}-current-${Date.now()}.png`);
|
|
3594
3773
|
await page.screenshot({ path: currentPath, fullPage: true });
|
|
3595
|
-
const baselineBuffer =
|
|
3596
|
-
const currentBuffer =
|
|
3774
|
+
const baselineBuffer = readFileSync(baseline.screenshotPath);
|
|
3775
|
+
const currentBuffer = readFileSync(currentPath);
|
|
3597
3776
|
const sizeDiff = Math.abs(baselineBuffer.length - currentBuffer.length);
|
|
3598
3777
|
const maxSize = Math.max(baselineBuffer.length, currentBuffer.length);
|
|
3599
3778
|
const diffPercentage = sizeDiff / maxSize;
|
|
@@ -3608,10 +3787,10 @@ class CBrowser {
|
|
|
3608
3787
|
* List all visual baselines.
|
|
3609
3788
|
*/
|
|
3610
3789
|
listBaselines() {
|
|
3611
|
-
const baselinesDir =
|
|
3612
|
-
if (!
|
|
3790
|
+
const baselinesDir = join(this.paths.dataDir, "baselines");
|
|
3791
|
+
if (!existsSync(baselinesDir))
|
|
3613
3792
|
return [];
|
|
3614
|
-
return
|
|
3793
|
+
return readdirSync(baselinesDir)
|
|
3615
3794
|
.filter(f => f.endsWith(".json"))
|
|
3616
3795
|
.map(f => f.replace(".json", ""));
|
|
3617
3796
|
}
|
|
@@ -3709,17 +3888,17 @@ class CBrowser {
|
|
|
3709
3888
|
* Save recording to file.
|
|
3710
3889
|
*/
|
|
3711
3890
|
saveRecording(name, actions) {
|
|
3712
|
-
const recordingsDir =
|
|
3713
|
-
if (!
|
|
3714
|
-
|
|
3891
|
+
const recordingsDir = join(this.paths.dataDir, "recordings");
|
|
3892
|
+
if (!existsSync(recordingsDir)) {
|
|
3893
|
+
mkdirSync(recordingsDir, { recursive: true });
|
|
3715
3894
|
}
|
|
3716
3895
|
const recording = {
|
|
3717
3896
|
name,
|
|
3718
3897
|
actions: actions || this.recordingActions,
|
|
3719
3898
|
created: new Date().toISOString(),
|
|
3720
3899
|
};
|
|
3721
|
-
const filePath =
|
|
3722
|
-
|
|
3900
|
+
const filePath = join(recordingsDir, `${name}.json`);
|
|
3901
|
+
writeFileSync(filePath, JSON.stringify(recording, null, 2));
|
|
3723
3902
|
return filePath;
|
|
3724
3903
|
}
|
|
3725
3904
|
/**
|
|
@@ -3755,7 +3934,7 @@ class CBrowser {
|
|
|
3755
3934
|
* Export test results as JUnit XML.
|
|
3756
3935
|
*/
|
|
3757
3936
|
exportJUnit(suite, outputPath) {
|
|
3758
|
-
const filename = outputPath ||
|
|
3937
|
+
const filename = outputPath || join(this.paths.dataDir, `junit-${Date.now()}.xml`);
|
|
3759
3938
|
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
|
3760
3939
|
xml += `<testsuite name="${suite.name}" tests="${suite.tests.length}">\n`;
|
|
3761
3940
|
for (const test of suite.tests) {
|
|
@@ -3766,21 +3945,21 @@ class CBrowser {
|
|
|
3766
3945
|
xml += ` </testcase>\n`;
|
|
3767
3946
|
}
|
|
3768
3947
|
xml += `</testsuite>\n`;
|
|
3769
|
-
|
|
3948
|
+
writeFileSync(filename, xml);
|
|
3770
3949
|
return filename;
|
|
3771
3950
|
}
|
|
3772
3951
|
/**
|
|
3773
3952
|
* Export test results as TAP format.
|
|
3774
3953
|
*/
|
|
3775
3954
|
exportTAP(suite, outputPath) {
|
|
3776
|
-
const filename = outputPath ||
|
|
3955
|
+
const filename = outputPath || join(this.paths.dataDir, `tap-${Date.now()}.tap`);
|
|
3777
3956
|
let tap = `TAP version 13\n`;
|
|
3778
3957
|
tap += `1..${suite.tests.length}\n`;
|
|
3779
3958
|
suite.tests.forEach((test, i) => {
|
|
3780
3959
|
const status = test.status === "passed" ? "ok" : "not ok";
|
|
3781
3960
|
tap += `${status} ${i + 1} ${test.name}\n`;
|
|
3782
3961
|
});
|
|
3783
|
-
|
|
3962
|
+
writeFileSync(filename, tap);
|
|
3784
3963
|
return filename;
|
|
3785
3964
|
}
|
|
3786
3965
|
// =========================================================================
|
|
@@ -3856,11 +4035,10 @@ class CBrowser {
|
|
|
3856
4035
|
return new FluentCBrowser(this);
|
|
3857
4036
|
}
|
|
3858
4037
|
}
|
|
3859
|
-
exports.CBrowser = CBrowser;
|
|
3860
4038
|
/**
|
|
3861
4039
|
* Fluent wrapper for chainable API.
|
|
3862
4040
|
*/
|
|
3863
|
-
class FluentCBrowser {
|
|
4041
|
+
export class FluentCBrowser {
|
|
3864
4042
|
browser;
|
|
3865
4043
|
constructor(browser) {
|
|
3866
4044
|
this.browser = browser;
|
|
@@ -3892,5 +4070,4 @@ class FluentCBrowser {
|
|
|
3892
4070
|
return this.browser;
|
|
3893
4071
|
}
|
|
3894
4072
|
}
|
|
3895
|
-
exports.FluentCBrowser = FluentCBrowser;
|
|
3896
4073
|
//# sourceMappingURL=browser.js.map
|