cbrowser 8.8.0 → 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 +5 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +222 -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
|
|
@@ -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.
|
|
@@ -2115,7 +2188,7 @@ class CBrowser {
|
|
|
2115
2188
|
* Get the selector cache file path.
|
|
2116
2189
|
*/
|
|
2117
2190
|
getSelectorCachePath() {
|
|
2118
|
-
return
|
|
2191
|
+
return join(this.paths.dataDir, "selector-cache.json");
|
|
2119
2192
|
}
|
|
2120
2193
|
/**
|
|
2121
2194
|
* Load the selector cache from disk.
|
|
@@ -2124,9 +2197,9 @@ class CBrowser {
|
|
|
2124
2197
|
if (this.selectorCache)
|
|
2125
2198
|
return this.selectorCache;
|
|
2126
2199
|
const cachePath = this.getSelectorCachePath();
|
|
2127
|
-
if (
|
|
2200
|
+
if (existsSync(cachePath)) {
|
|
2128
2201
|
try {
|
|
2129
|
-
const data =
|
|
2202
|
+
const data = readFileSync(cachePath, "utf-8");
|
|
2130
2203
|
this.selectorCache = JSON.parse(data);
|
|
2131
2204
|
return this.selectorCache;
|
|
2132
2205
|
}
|
|
@@ -2144,7 +2217,7 @@ class CBrowser {
|
|
|
2144
2217
|
if (!this.selectorCache)
|
|
2145
2218
|
return;
|
|
2146
2219
|
const cachePath = this.getSelectorCachePath();
|
|
2147
|
-
|
|
2220
|
+
writeFileSync(cachePath, JSON.stringify(this.selectorCache, null, 2));
|
|
2148
2221
|
}
|
|
2149
2222
|
/**
|
|
2150
2223
|
* Get cache key for a selector (includes domain for context).
|
|
@@ -2706,7 +2779,7 @@ class CBrowser {
|
|
|
2706
2779
|
// Take screenshot
|
|
2707
2780
|
const dir = options.debugDir || this.paths.screenshotsDir;
|
|
2708
2781
|
const filename = `debug-${Date.now()}.png`;
|
|
2709
|
-
const filepath =
|
|
2782
|
+
const filepath = join(dir, filename);
|
|
2710
2783
|
await page.screenshot({ path: filepath, fullPage: false });
|
|
2711
2784
|
// Clean up highlights
|
|
2712
2785
|
await page.evaluate(() => {
|
|
@@ -2891,10 +2964,30 @@ class CBrowser {
|
|
|
2891
2964
|
const [role, name] = selector.slice(5).split("/");
|
|
2892
2965
|
return page.getByRole(role, { name }).first();
|
|
2893
2966
|
}
|
|
2894
|
-
// Strategy 3: Text content
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
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();
|
|
2898
2991
|
}
|
|
2899
2992
|
// Strategy 4: Placeholder
|
|
2900
2993
|
const byPlaceholder = page.getByPlaceholder(selector).first();
|
|
@@ -3074,7 +3167,7 @@ class CBrowser {
|
|
|
3074
3167
|
*/
|
|
3075
3168
|
async screenshot(path) {
|
|
3076
3169
|
const page = await this.getPage();
|
|
3077
|
-
const filename = path ||
|
|
3170
|
+
const filename = path || join(this.paths.screenshotsDir, `screenshot-${Date.now()}.png`);
|
|
3078
3171
|
await page.screenshot({ path: filename, fullPage: false });
|
|
3079
3172
|
return filename;
|
|
3080
3173
|
}
|
|
@@ -3134,18 +3227,18 @@ class CBrowser {
|
|
|
3134
3227
|
localStorage,
|
|
3135
3228
|
sessionStorage,
|
|
3136
3229
|
};
|
|
3137
|
-
const sessionPath =
|
|
3138
|
-
|
|
3230
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3231
|
+
writeFileSync(sessionPath, JSON.stringify(session, null, 2));
|
|
3139
3232
|
}
|
|
3140
3233
|
/**
|
|
3141
3234
|
* Load a saved session.
|
|
3142
3235
|
*/
|
|
3143
3236
|
async loadSession(name) {
|
|
3144
|
-
const sessionPath =
|
|
3145
|
-
if (!
|
|
3237
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3238
|
+
if (!existsSync(sessionPath)) {
|
|
3146
3239
|
return { success: false, name, cookiesRestored: 0, localStorageKeysRestored: 0, sessionStorageKeysRestored: 0 };
|
|
3147
3240
|
}
|
|
3148
|
-
const session = JSON.parse(
|
|
3241
|
+
const session = JSON.parse(readFileSync(sessionPath, "utf-8"));
|
|
3149
3242
|
const page = await this.getPage();
|
|
3150
3243
|
const context = this.context;
|
|
3151
3244
|
const result = {
|
|
@@ -3190,29 +3283,29 @@ class CBrowser {
|
|
|
3190
3283
|
await page.reload({ waitUntil: "networkidle" });
|
|
3191
3284
|
// Update lastUsed
|
|
3192
3285
|
session.lastUsed = new Date().toISOString();
|
|
3193
|
-
|
|
3286
|
+
writeFileSync(sessionPath, JSON.stringify(session, null, 2));
|
|
3194
3287
|
return result;
|
|
3195
3288
|
}
|
|
3196
3289
|
/**
|
|
3197
3290
|
* List all saved session names.
|
|
3198
3291
|
*/
|
|
3199
3292
|
listSessions() {
|
|
3200
|
-
const files =
|
|
3293
|
+
const files = readdirSync(this.paths.sessionsDir);
|
|
3201
3294
|
return files.filter((f) => f.endsWith(".json") && f !== "last-session.json").map((f) => f.replace(".json", ""));
|
|
3202
3295
|
}
|
|
3203
3296
|
/**
|
|
3204
3297
|
* List all saved sessions with rich metadata.
|
|
3205
3298
|
*/
|
|
3206
3299
|
listSessionsDetailed() {
|
|
3207
|
-
const files =
|
|
3300
|
+
const files = readdirSync(this.paths.sessionsDir);
|
|
3208
3301
|
const sessions = [];
|
|
3209
3302
|
for (const file of files) {
|
|
3210
3303
|
if (!file.endsWith(".json") || file === "last-session.json")
|
|
3211
3304
|
continue;
|
|
3212
|
-
const filePath =
|
|
3305
|
+
const filePath = join(this.paths.sessionsDir, file);
|
|
3213
3306
|
try {
|
|
3214
|
-
const data = JSON.parse(
|
|
3215
|
-
const stats =
|
|
3307
|
+
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
3308
|
+
const stats = statSync(filePath);
|
|
3216
3309
|
sessions.push({
|
|
3217
3310
|
name: data.name || file.replace(".json", ""),
|
|
3218
3311
|
created: data.created,
|
|
@@ -3235,11 +3328,11 @@ class CBrowser {
|
|
|
3235
3328
|
* Get detailed info for a single session.
|
|
3236
3329
|
*/
|
|
3237
3330
|
getSessionDetails(name) {
|
|
3238
|
-
const sessionPath =
|
|
3239
|
-
if (!
|
|
3331
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3332
|
+
if (!existsSync(sessionPath))
|
|
3240
3333
|
return null;
|
|
3241
3334
|
try {
|
|
3242
|
-
return JSON.parse(
|
|
3335
|
+
return JSON.parse(readFileSync(sessionPath, "utf-8"));
|
|
3243
3336
|
}
|
|
3244
3337
|
catch {
|
|
3245
3338
|
return null;
|
|
@@ -3249,9 +3342,9 @@ class CBrowser {
|
|
|
3249
3342
|
* Delete a saved session.
|
|
3250
3343
|
*/
|
|
3251
3344
|
deleteSession(name) {
|
|
3252
|
-
const sessionPath =
|
|
3253
|
-
if (
|
|
3254
|
-
|
|
3345
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3346
|
+
if (existsSync(sessionPath)) {
|
|
3347
|
+
unlinkSync(sessionPath);
|
|
3255
3348
|
return true;
|
|
3256
3349
|
}
|
|
3257
3350
|
return false;
|
|
@@ -3263,17 +3356,17 @@ class CBrowser {
|
|
|
3263
3356
|
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
|
|
3264
3357
|
const deleted = [];
|
|
3265
3358
|
const kept = [];
|
|
3266
|
-
const files =
|
|
3359
|
+
const files = readdirSync(this.paths.sessionsDir);
|
|
3267
3360
|
for (const file of files) {
|
|
3268
3361
|
if (!file.endsWith(".json") || file === "last-session.json")
|
|
3269
3362
|
continue;
|
|
3270
|
-
const filePath =
|
|
3363
|
+
const filePath = join(this.paths.sessionsDir, file);
|
|
3271
3364
|
const name = file.replace(".json", "");
|
|
3272
3365
|
try {
|
|
3273
|
-
const data = JSON.parse(
|
|
3366
|
+
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
3274
3367
|
const lastUsed = new Date(data.lastUsed).getTime();
|
|
3275
3368
|
if (lastUsed < cutoff) {
|
|
3276
|
-
|
|
3369
|
+
unlinkSync(filePath);
|
|
3277
3370
|
deleted.push(name);
|
|
3278
3371
|
}
|
|
3279
3372
|
else {
|
|
@@ -3290,24 +3383,24 @@ class CBrowser {
|
|
|
3290
3383
|
* Export a session to a portable JSON file.
|
|
3291
3384
|
*/
|
|
3292
3385
|
exportSession(name, outputPath) {
|
|
3293
|
-
const sessionPath =
|
|
3294
|
-
if (!
|
|
3386
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3387
|
+
if (!existsSync(sessionPath))
|
|
3295
3388
|
return false;
|
|
3296
|
-
const data =
|
|
3297
|
-
|
|
3389
|
+
const data = readFileSync(sessionPath, "utf-8");
|
|
3390
|
+
writeFileSync(outputPath, data);
|
|
3298
3391
|
return true;
|
|
3299
3392
|
}
|
|
3300
3393
|
/**
|
|
3301
3394
|
* Import a session from a JSON file.
|
|
3302
3395
|
*/
|
|
3303
3396
|
importSession(inputPath, name) {
|
|
3304
|
-
if (!
|
|
3397
|
+
if (!existsSync(inputPath))
|
|
3305
3398
|
return false;
|
|
3306
3399
|
try {
|
|
3307
|
-
const data = JSON.parse(
|
|
3400
|
+
const data = JSON.parse(readFileSync(inputPath, "utf-8"));
|
|
3308
3401
|
data.name = name;
|
|
3309
|
-
const sessionPath =
|
|
3310
|
-
|
|
3402
|
+
const sessionPath = join(this.paths.sessionsDir, `${name}.json`);
|
|
3403
|
+
writeFileSync(sessionPath, JSON.stringify(data, null, 2));
|
|
3311
3404
|
return true;
|
|
3312
3405
|
}
|
|
3313
3406
|
catch {
|
|
@@ -3322,7 +3415,7 @@ class CBrowser {
|
|
|
3322
3415
|
*/
|
|
3323
3416
|
async journey(options) {
|
|
3324
3417
|
const { persona: personaName, startUrl, goal, maxSteps = 20 } = options;
|
|
3325
|
-
const persona =
|
|
3418
|
+
const persona = getPersona(personaName) || BUILTIN_PERSONAS["first-timer"];
|
|
3326
3419
|
this.currentPersona = persona;
|
|
3327
3420
|
// Set viewport based on persona
|
|
3328
3421
|
if (persona.context?.viewport) {
|
|
@@ -3412,8 +3505,8 @@ class CBrowser {
|
|
|
3412
3505
|
consoleLogs,
|
|
3413
3506
|
};
|
|
3414
3507
|
// Save journey results
|
|
3415
|
-
const journeyFile =
|
|
3416
|
-
|
|
3508
|
+
const journeyFile = join(this.paths.dataDir, `journey-${Date.now()}.json`);
|
|
3509
|
+
writeFileSync(journeyFile, JSON.stringify(result, null, 2));
|
|
3417
3510
|
return result;
|
|
3418
3511
|
}
|
|
3419
3512
|
/**
|
|
@@ -3470,13 +3563,13 @@ class CBrowser {
|
|
|
3470
3563
|
result,
|
|
3471
3564
|
persona: this.currentPersona?.name,
|
|
3472
3565
|
};
|
|
3473
|
-
const auditFile =
|
|
3566
|
+
const auditFile = join(this.paths.auditDir, `audit-${new Date().toISOString().split("T")[0]}.json`);
|
|
3474
3567
|
let entries = [];
|
|
3475
|
-
if (
|
|
3476
|
-
entries = JSON.parse(
|
|
3568
|
+
if (existsSync(auditFile)) {
|
|
3569
|
+
entries = JSON.parse(readFileSync(auditFile, "utf-8"));
|
|
3477
3570
|
}
|
|
3478
3571
|
entries.push(entry);
|
|
3479
|
-
|
|
3572
|
+
writeFileSync(auditFile, JSON.stringify(entries, null, 2));
|
|
3480
3573
|
}
|
|
3481
3574
|
// =========================================================================
|
|
3482
3575
|
// Cleanup
|
|
@@ -3497,22 +3590,22 @@ class CBrowser {
|
|
|
3497
3590
|
},
|
|
3498
3591
|
};
|
|
3499
3592
|
const cleanDir = (dir, pattern, keep, category) => {
|
|
3500
|
-
if (!
|
|
3593
|
+
if (!existsSync(dir))
|
|
3501
3594
|
return;
|
|
3502
|
-
const files =
|
|
3595
|
+
const files = readdirSync(dir)
|
|
3503
3596
|
.filter((f) => pattern.test(f))
|
|
3504
3597
|
.map((f) => ({
|
|
3505
3598
|
name: f,
|
|
3506
|
-
path:
|
|
3507
|
-
mtime:
|
|
3508
|
-
size:
|
|
3599
|
+
path: join(dir, f),
|
|
3600
|
+
mtime: statSync(join(dir, f)).mtime,
|
|
3601
|
+
size: statSync(join(dir, f)).size,
|
|
3509
3602
|
}))
|
|
3510
3603
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
3511
3604
|
const cutoff = Date.now() - olderThan * 24 * 60 * 60 * 1000;
|
|
3512
3605
|
const toDelete = files.slice(keep).filter((f) => f.mtime.getTime() < cutoff);
|
|
3513
3606
|
for (const file of toDelete) {
|
|
3514
3607
|
if (!dryRun) {
|
|
3515
|
-
|
|
3608
|
+
unlinkSync(file.path);
|
|
3516
3609
|
}
|
|
3517
3610
|
result.deleted++;
|
|
3518
3611
|
result.freedBytes += file.size;
|
|
@@ -3532,12 +3625,12 @@ class CBrowser {
|
|
|
3532
3625
|
getStorageStats() {
|
|
3533
3626
|
const stats = {};
|
|
3534
3627
|
const countDir = (dir, pattern) => {
|
|
3535
|
-
if (!
|
|
3628
|
+
if (!existsSync(dir))
|
|
3536
3629
|
return { count: 0, size: 0 };
|
|
3537
|
-
const files =
|
|
3630
|
+
const files = readdirSync(dir).filter((f) => pattern.test(f));
|
|
3538
3631
|
let size = 0;
|
|
3539
3632
|
for (const file of files) {
|
|
3540
|
-
size +=
|
|
3633
|
+
size += statSync(join(dir, file)).size;
|
|
3541
3634
|
}
|
|
3542
3635
|
return { count: files.length, size };
|
|
3543
3636
|
};
|
|
@@ -3560,12 +3653,12 @@ class CBrowser {
|
|
|
3560
3653
|
* Save a visual baseline screenshot.
|
|
3561
3654
|
*/
|
|
3562
3655
|
async saveBaseline(name, url) {
|
|
3563
|
-
const baselinesDir =
|
|
3564
|
-
if (!
|
|
3565
|
-
|
|
3656
|
+
const baselinesDir = join(this.paths.dataDir, "baselines");
|
|
3657
|
+
if (!existsSync(baselinesDir)) {
|
|
3658
|
+
mkdirSync(baselinesDir, { recursive: true });
|
|
3566
3659
|
}
|
|
3567
3660
|
const page = await this.getPage();
|
|
3568
|
-
const screenshotPath =
|
|
3661
|
+
const screenshotPath = join(baselinesDir, `${name}.png`);
|
|
3569
3662
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
3570
3663
|
const baseline = {
|
|
3571
3664
|
name,
|
|
@@ -3575,25 +3668,25 @@ class CBrowser {
|
|
|
3575
3668
|
created: new Date().toISOString(),
|
|
3576
3669
|
lastUsed: new Date().toISOString(),
|
|
3577
3670
|
};
|
|
3578
|
-
const metaPath =
|
|
3579
|
-
|
|
3671
|
+
const metaPath = join(baselinesDir, `${name}.json`);
|
|
3672
|
+
writeFileSync(metaPath, JSON.stringify(baseline, null, 2));
|
|
3580
3673
|
return screenshotPath;
|
|
3581
3674
|
}
|
|
3582
3675
|
/**
|
|
3583
3676
|
* Compare current page to a baseline.
|
|
3584
3677
|
*/
|
|
3585
3678
|
async compareBaseline(name, threshold = 0.1) {
|
|
3586
|
-
const baselinesDir =
|
|
3587
|
-
const metaPath =
|
|
3588
|
-
if (!
|
|
3679
|
+
const baselinesDir = join(this.paths.dataDir, "baselines");
|
|
3680
|
+
const metaPath = join(baselinesDir, `${name}.json`);
|
|
3681
|
+
if (!existsSync(metaPath)) {
|
|
3589
3682
|
throw new Error(`Baseline not found: ${name}`);
|
|
3590
3683
|
}
|
|
3591
|
-
const baseline = JSON.parse(
|
|
3684
|
+
const baseline = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
3592
3685
|
const page = await this.getPage();
|
|
3593
|
-
const currentPath =
|
|
3686
|
+
const currentPath = join(baselinesDir, `${name}-current-${Date.now()}.png`);
|
|
3594
3687
|
await page.screenshot({ path: currentPath, fullPage: true });
|
|
3595
|
-
const baselineBuffer =
|
|
3596
|
-
const currentBuffer =
|
|
3688
|
+
const baselineBuffer = readFileSync(baseline.screenshotPath);
|
|
3689
|
+
const currentBuffer = readFileSync(currentPath);
|
|
3597
3690
|
const sizeDiff = Math.abs(baselineBuffer.length - currentBuffer.length);
|
|
3598
3691
|
const maxSize = Math.max(baselineBuffer.length, currentBuffer.length);
|
|
3599
3692
|
const diffPercentage = sizeDiff / maxSize;
|
|
@@ -3608,10 +3701,10 @@ class CBrowser {
|
|
|
3608
3701
|
* List all visual baselines.
|
|
3609
3702
|
*/
|
|
3610
3703
|
listBaselines() {
|
|
3611
|
-
const baselinesDir =
|
|
3612
|
-
if (!
|
|
3704
|
+
const baselinesDir = join(this.paths.dataDir, "baselines");
|
|
3705
|
+
if (!existsSync(baselinesDir))
|
|
3613
3706
|
return [];
|
|
3614
|
-
return
|
|
3707
|
+
return readdirSync(baselinesDir)
|
|
3615
3708
|
.filter(f => f.endsWith(".json"))
|
|
3616
3709
|
.map(f => f.replace(".json", ""));
|
|
3617
3710
|
}
|
|
@@ -3709,17 +3802,17 @@ class CBrowser {
|
|
|
3709
3802
|
* Save recording to file.
|
|
3710
3803
|
*/
|
|
3711
3804
|
saveRecording(name, actions) {
|
|
3712
|
-
const recordingsDir =
|
|
3713
|
-
if (!
|
|
3714
|
-
|
|
3805
|
+
const recordingsDir = join(this.paths.dataDir, "recordings");
|
|
3806
|
+
if (!existsSync(recordingsDir)) {
|
|
3807
|
+
mkdirSync(recordingsDir, { recursive: true });
|
|
3715
3808
|
}
|
|
3716
3809
|
const recording = {
|
|
3717
3810
|
name,
|
|
3718
3811
|
actions: actions || this.recordingActions,
|
|
3719
3812
|
created: new Date().toISOString(),
|
|
3720
3813
|
};
|
|
3721
|
-
const filePath =
|
|
3722
|
-
|
|
3814
|
+
const filePath = join(recordingsDir, `${name}.json`);
|
|
3815
|
+
writeFileSync(filePath, JSON.stringify(recording, null, 2));
|
|
3723
3816
|
return filePath;
|
|
3724
3817
|
}
|
|
3725
3818
|
/**
|
|
@@ -3755,7 +3848,7 @@ class CBrowser {
|
|
|
3755
3848
|
* Export test results as JUnit XML.
|
|
3756
3849
|
*/
|
|
3757
3850
|
exportJUnit(suite, outputPath) {
|
|
3758
|
-
const filename = outputPath ||
|
|
3851
|
+
const filename = outputPath || join(this.paths.dataDir, `junit-${Date.now()}.xml`);
|
|
3759
3852
|
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
|
3760
3853
|
xml += `<testsuite name="${suite.name}" tests="${suite.tests.length}">\n`;
|
|
3761
3854
|
for (const test of suite.tests) {
|
|
@@ -3766,21 +3859,21 @@ class CBrowser {
|
|
|
3766
3859
|
xml += ` </testcase>\n`;
|
|
3767
3860
|
}
|
|
3768
3861
|
xml += `</testsuite>\n`;
|
|
3769
|
-
|
|
3862
|
+
writeFileSync(filename, xml);
|
|
3770
3863
|
return filename;
|
|
3771
3864
|
}
|
|
3772
3865
|
/**
|
|
3773
3866
|
* Export test results as TAP format.
|
|
3774
3867
|
*/
|
|
3775
3868
|
exportTAP(suite, outputPath) {
|
|
3776
|
-
const filename = outputPath ||
|
|
3869
|
+
const filename = outputPath || join(this.paths.dataDir, `tap-${Date.now()}.tap`);
|
|
3777
3870
|
let tap = `TAP version 13\n`;
|
|
3778
3871
|
tap += `1..${suite.tests.length}\n`;
|
|
3779
3872
|
suite.tests.forEach((test, i) => {
|
|
3780
3873
|
const status = test.status === "passed" ? "ok" : "not ok";
|
|
3781
3874
|
tap += `${status} ${i + 1} ${test.name}\n`;
|
|
3782
3875
|
});
|
|
3783
|
-
|
|
3876
|
+
writeFileSync(filename, tap);
|
|
3784
3877
|
return filename;
|
|
3785
3878
|
}
|
|
3786
3879
|
// =========================================================================
|
|
@@ -3856,11 +3949,10 @@ class CBrowser {
|
|
|
3856
3949
|
return new FluentCBrowser(this);
|
|
3857
3950
|
}
|
|
3858
3951
|
}
|
|
3859
|
-
exports.CBrowser = CBrowser;
|
|
3860
3952
|
/**
|
|
3861
3953
|
* Fluent wrapper for chainable API.
|
|
3862
3954
|
*/
|
|
3863
|
-
class FluentCBrowser {
|
|
3955
|
+
export class FluentCBrowser {
|
|
3864
3956
|
browser;
|
|
3865
3957
|
constructor(browser) {
|
|
3866
3958
|
this.browser = browser;
|
|
@@ -3892,5 +3984,4 @@ class FluentCBrowser {
|
|
|
3892
3984
|
return this.browser;
|
|
3893
3985
|
}
|
|
3894
3986
|
}
|
|
3895
|
-
exports.FluentCBrowser = FluentCBrowser;
|
|
3896
3987
|
//# sourceMappingURL=browser.js.map
|