browser-commander 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/release.yml +296 -0
- package/.husky/pre-commit +1 -0
- package/.jscpd.json +20 -0
- package/.prettierignore +7 -0
- package/.prettierrc +10 -0
- package/CHANGELOG.md +32 -0
- package/LICENSE +24 -0
- package/README.md +320 -0
- package/bunfig.toml +3 -0
- package/deno.json +7 -0
- package/eslint.config.js +125 -0
- package/examples/react-test-app/index.html +25 -0
- package/examples/react-test-app/package.json +19 -0
- package/examples/react-test-app/src/App.jsx +473 -0
- package/examples/react-test-app/src/main.jsx +10 -0
- package/examples/react-test-app/src/styles.css +323 -0
- package/examples/react-test-app/vite.config.js +9 -0
- package/package.json +89 -0
- package/scripts/changeset-version.mjs +38 -0
- package/scripts/create-github-release.mjs +93 -0
- package/scripts/create-manual-changeset.mjs +86 -0
- package/scripts/format-github-release.mjs +83 -0
- package/scripts/format-release-notes.mjs +216 -0
- package/scripts/instant-version-bump.mjs +121 -0
- package/scripts/merge-changesets.mjs +260 -0
- package/scripts/publish-to-npm.mjs +126 -0
- package/scripts/setup-npm.mjs +37 -0
- package/scripts/validate-changeset.mjs +262 -0
- package/scripts/version-and-commit.mjs +237 -0
- package/src/ARCHITECTURE.md +270 -0
- package/src/README.md +517 -0
- package/src/bindings.js +298 -0
- package/src/browser/launcher.js +93 -0
- package/src/browser/navigation.js +513 -0
- package/src/core/constants.js +24 -0
- package/src/core/engine-adapter.js +466 -0
- package/src/core/engine-detection.js +49 -0
- package/src/core/logger.js +21 -0
- package/src/core/navigation-manager.js +503 -0
- package/src/core/navigation-safety.js +160 -0
- package/src/core/network-tracker.js +373 -0
- package/src/core/page-session.js +299 -0
- package/src/core/page-trigger-manager.js +564 -0
- package/src/core/preferences.js +46 -0
- package/src/elements/content.js +197 -0
- package/src/elements/locators.js +243 -0
- package/src/elements/selectors.js +360 -0
- package/src/elements/visibility.js +166 -0
- package/src/exports.js +121 -0
- package/src/factory.js +192 -0
- package/src/high-level/universal-logic.js +206 -0
- package/src/index.js +17 -0
- package/src/interactions/click.js +684 -0
- package/src/interactions/fill.js +383 -0
- package/src/interactions/scroll.js +341 -0
- package/src/utilities/url.js +33 -0
- package/src/utilities/wait.js +135 -0
- package/tests/e2e/playwright.e2e.test.js +442 -0
- package/tests/e2e/puppeteer.e2e.test.js +408 -0
- package/tests/helpers/mocks.js +542 -0
- package/tests/unit/bindings.test.js +218 -0
- package/tests/unit/browser/navigation.test.js +345 -0
- package/tests/unit/core/constants.test.js +72 -0
- package/tests/unit/core/engine-adapter.test.js +170 -0
- package/tests/unit/core/engine-detection.test.js +81 -0
- package/tests/unit/core/logger.test.js +80 -0
- package/tests/unit/core/navigation-safety.test.js +202 -0
- package/tests/unit/core/network-tracker.test.js +198 -0
- package/tests/unit/core/page-trigger-manager.test.js +358 -0
- package/tests/unit/elements/content.test.js +318 -0
- package/tests/unit/elements/locators.test.js +236 -0
- package/tests/unit/elements/selectors.test.js +302 -0
- package/tests/unit/elements/visibility.test.js +234 -0
- package/tests/unit/factory.test.js +174 -0
- package/tests/unit/high-level/universal-logic.test.js +299 -0
- package/tests/unit/interactions/click.test.js +340 -0
- package/tests/unit/interactions/fill.test.js +378 -0
- package/tests/unit/interactions/scroll.test.js +330 -0
- package/tests/unit/utilities/url.test.js +63 -0
- package/tests/unit/utilities/wait.test.js +207 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetworkTracker - Track all HTTP requests and wait for network idle
|
|
3
|
+
*
|
|
4
|
+
* This module monitors all network requests on a page and provides
|
|
5
|
+
* methods to wait until all requests are complete (network idle).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a NetworkTracker instance for a page
|
|
10
|
+
* @param {Object} options - Configuration options
|
|
11
|
+
* @param {Object} options.page - Playwright or Puppeteer page object
|
|
12
|
+
* @param {string} options.engine - 'playwright' or 'puppeteer'
|
|
13
|
+
* @param {Function} options.log - Logger instance
|
|
14
|
+
* @param {number} options.idleTimeout - Time to wait after last request completes (default: 500ms)
|
|
15
|
+
* @param {number} options.requestTimeout - Maximum time to wait for a single request (default: 30000ms)
|
|
16
|
+
* @returns {Object} - NetworkTracker API
|
|
17
|
+
*/
|
|
18
|
+
export function createNetworkTracker(options = {}) {
|
|
19
|
+
const {
|
|
20
|
+
page,
|
|
21
|
+
engine,
|
|
22
|
+
log,
|
|
23
|
+
idleTimeout = 500,
|
|
24
|
+
requestTimeout = 30000,
|
|
25
|
+
} = options;
|
|
26
|
+
|
|
27
|
+
if (!page) {
|
|
28
|
+
throw new Error('page is required in options');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Track pending requests by URL
|
|
32
|
+
const pendingRequests = new Map();
|
|
33
|
+
|
|
34
|
+
// Track request start times for timeout detection
|
|
35
|
+
const requestStartTimes = new Map();
|
|
36
|
+
|
|
37
|
+
// Event listeners
|
|
38
|
+
const listeners = {
|
|
39
|
+
onRequestStart: [],
|
|
40
|
+
onRequestEnd: [],
|
|
41
|
+
onNetworkIdle: [],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// State
|
|
45
|
+
let isTracking = false;
|
|
46
|
+
let idleTimer = null;
|
|
47
|
+
let navigationId = 0; // Track navigation sessions
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get unique request key
|
|
51
|
+
*/
|
|
52
|
+
function getRequestKey(request) {
|
|
53
|
+
// Use URL + method as unique key (handles redirects properly)
|
|
54
|
+
const url = typeof request.url === 'function' ? request.url() : request.url;
|
|
55
|
+
const method =
|
|
56
|
+
typeof request.method === 'function' ? request.method() : request.method;
|
|
57
|
+
return `${method}:${url}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Handle request start
|
|
62
|
+
*/
|
|
63
|
+
function onRequest(request) {
|
|
64
|
+
const key = getRequestKey(request);
|
|
65
|
+
const url = typeof request.url === 'function' ? request.url() : request.url;
|
|
66
|
+
|
|
67
|
+
// Ignore data URLs and blob URLs
|
|
68
|
+
if (url.startsWith('data:') || url.startsWith('blob:')) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
pendingRequests.set(key, request);
|
|
73
|
+
requestStartTimes.set(key, Date.now());
|
|
74
|
+
|
|
75
|
+
// Clear idle timer since we have a new request
|
|
76
|
+
if (idleTimer) {
|
|
77
|
+
clearTimeout(idleTimer);
|
|
78
|
+
idleTimer = null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
log.debug(
|
|
82
|
+
() =>
|
|
83
|
+
`📤 Request started: ${url.substring(0, 80)}... (pending: ${pendingRequests.size})`
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Notify listeners
|
|
87
|
+
listeners.onRequestStart.forEach((fn) =>
|
|
88
|
+
fn({ url, pendingCount: pendingRequests.size })
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Handle request completion (success or failure)
|
|
94
|
+
*/
|
|
95
|
+
function onRequestEnd(request) {
|
|
96
|
+
const key = getRequestKey(request);
|
|
97
|
+
const url = typeof request.url === 'function' ? request.url() : request.url;
|
|
98
|
+
|
|
99
|
+
if (!pendingRequests.has(key)) {
|
|
100
|
+
return; // Request was filtered out or already completed
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
pendingRequests.delete(key);
|
|
104
|
+
requestStartTimes.delete(key);
|
|
105
|
+
|
|
106
|
+
log.debug(
|
|
107
|
+
() =>
|
|
108
|
+
`📥 Request ended: ${url.substring(0, 80)}... (pending: ${pendingRequests.size})`
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Notify listeners
|
|
112
|
+
listeners.onRequestEnd.forEach((fn) =>
|
|
113
|
+
fn({ url, pendingCount: pendingRequests.size })
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Check if we're now idle
|
|
117
|
+
checkIdle();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if network is idle and trigger idle event
|
|
122
|
+
*/
|
|
123
|
+
function checkIdle() {
|
|
124
|
+
if (pendingRequests.size === 0 && !idleTimer) {
|
|
125
|
+
// Start idle timer
|
|
126
|
+
idleTimer = setTimeout(() => {
|
|
127
|
+
if (pendingRequests.size === 0) {
|
|
128
|
+
log.debug(() => '🌐 Network idle detected');
|
|
129
|
+
listeners.onNetworkIdle.forEach((fn) => fn());
|
|
130
|
+
}
|
|
131
|
+
idleTimer = null;
|
|
132
|
+
}, idleTimeout);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Start tracking network requests
|
|
138
|
+
*/
|
|
139
|
+
function startTracking() {
|
|
140
|
+
if (isTracking) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
isTracking = true;
|
|
145
|
+
navigationId++;
|
|
146
|
+
|
|
147
|
+
// Clear any existing state
|
|
148
|
+
pendingRequests.clear();
|
|
149
|
+
requestStartTimes.clear();
|
|
150
|
+
if (idleTimer) {
|
|
151
|
+
clearTimeout(idleTimer);
|
|
152
|
+
idleTimer = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Setup event listeners based on engine
|
|
156
|
+
if (engine === 'playwright') {
|
|
157
|
+
page.on('request', onRequest);
|
|
158
|
+
page.on('requestfinished', onRequestEnd);
|
|
159
|
+
page.on('requestfailed', onRequestEnd);
|
|
160
|
+
} else {
|
|
161
|
+
// Puppeteer
|
|
162
|
+
page.on('request', onRequest);
|
|
163
|
+
page.on('requestfinished', onRequestEnd);
|
|
164
|
+
page.on('requestfailed', onRequestEnd);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
log.debug(() => '🔌 Network tracking started');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Stop tracking network requests
|
|
172
|
+
*/
|
|
173
|
+
function stopTracking() {
|
|
174
|
+
if (!isTracking) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
isTracking = false;
|
|
179
|
+
|
|
180
|
+
// Remove event listeners
|
|
181
|
+
if (engine === 'playwright') {
|
|
182
|
+
page.off('request', onRequest);
|
|
183
|
+
page.off('requestfinished', onRequestEnd);
|
|
184
|
+
page.off('requestfailed', onRequestEnd);
|
|
185
|
+
} else {
|
|
186
|
+
page.off('request', onRequest);
|
|
187
|
+
page.off('requestfinished', onRequestEnd);
|
|
188
|
+
page.off('requestfailed', onRequestEnd);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Clear state
|
|
192
|
+
pendingRequests.clear();
|
|
193
|
+
requestStartTimes.clear();
|
|
194
|
+
if (idleTimer) {
|
|
195
|
+
clearTimeout(idleTimer);
|
|
196
|
+
idleTimer = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
log.debug(() => '🔌 Network tracking stopped');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Wait for network to become idle
|
|
204
|
+
* @param {Object} options - Configuration options
|
|
205
|
+
* @param {number} options.timeout - Maximum time to wait (default: 30000ms)
|
|
206
|
+
* @param {number} options.idleTime - Time network must be idle (default: idleTimeout)
|
|
207
|
+
* @returns {Promise<boolean>} - True if idle, false if timeout
|
|
208
|
+
*/
|
|
209
|
+
async function waitForNetworkIdle(opts = {}) {
|
|
210
|
+
const { timeout = 30000, idleTime = idleTimeout } = opts;
|
|
211
|
+
|
|
212
|
+
const startTime = Date.now();
|
|
213
|
+
const currentNavId = navigationId;
|
|
214
|
+
|
|
215
|
+
// If already idle, wait for idle time
|
|
216
|
+
if (pendingRequests.size === 0) {
|
|
217
|
+
await new Promise((r) => setTimeout(r, idleTime));
|
|
218
|
+
|
|
219
|
+
// Check if still idle and no navigation happened
|
|
220
|
+
if (pendingRequests.size === 0 && navigationId === currentNavId) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return new Promise((resolve) => {
|
|
226
|
+
let resolved = false;
|
|
227
|
+
let checkTimer = null;
|
|
228
|
+
let timeoutTimer = null;
|
|
229
|
+
|
|
230
|
+
const cleanup = () => {
|
|
231
|
+
if (checkTimer) {
|
|
232
|
+
clearInterval(checkTimer);
|
|
233
|
+
}
|
|
234
|
+
if (timeoutTimer) {
|
|
235
|
+
clearTimeout(timeoutTimer);
|
|
236
|
+
}
|
|
237
|
+
resolved = true;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// Timeout handler
|
|
241
|
+
timeoutTimer = setTimeout(() => {
|
|
242
|
+
if (!resolved) {
|
|
243
|
+
cleanup();
|
|
244
|
+
log.debug(
|
|
245
|
+
() =>
|
|
246
|
+
`⚠️ Network idle timeout after ${timeout}ms (${pendingRequests.size} pending)`
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Log stuck requests
|
|
250
|
+
if (pendingRequests.size > 0) {
|
|
251
|
+
const stuckRequests = [];
|
|
252
|
+
for (const [key, req] of pendingRequests) {
|
|
253
|
+
const startTime = requestStartTimes.get(key);
|
|
254
|
+
const duration = Date.now() - startTime;
|
|
255
|
+
const url = typeof req.url === 'function' ? req.url() : req.url;
|
|
256
|
+
stuckRequests.push(
|
|
257
|
+
` ${url.substring(0, 60)}... (${duration}ms)`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
log.debug(() => `⚠️ Stuck requests:\n${stuckRequests.join('\n')}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
resolve(false);
|
|
264
|
+
}
|
|
265
|
+
}, timeout);
|
|
266
|
+
|
|
267
|
+
// Check periodically for idle state
|
|
268
|
+
checkTimer = setInterval(async () => {
|
|
269
|
+
if (resolved) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Check for navigation change (abort wait)
|
|
274
|
+
if (navigationId !== currentNavId) {
|
|
275
|
+
cleanup();
|
|
276
|
+
resolve(false);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Check for timed out requests and remove them
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
for (const [key, startTime] of requestStartTimes) {
|
|
283
|
+
if (now - startTime > requestTimeout) {
|
|
284
|
+
log.debug(() => `⚠️ Request timed out, removing: ${key}`);
|
|
285
|
+
pendingRequests.delete(key);
|
|
286
|
+
requestStartTimes.delete(key);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Check if idle
|
|
291
|
+
if (pendingRequests.size === 0) {
|
|
292
|
+
// Wait for idle time to confirm
|
|
293
|
+
await new Promise((r) => setTimeout(r, idleTime));
|
|
294
|
+
|
|
295
|
+
if (
|
|
296
|
+
!resolved &&
|
|
297
|
+
pendingRequests.size === 0 &&
|
|
298
|
+
navigationId === currentNavId
|
|
299
|
+
) {
|
|
300
|
+
cleanup();
|
|
301
|
+
resolve(true);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}, 100);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Get current pending request count
|
|
310
|
+
*/
|
|
311
|
+
function getPendingCount() {
|
|
312
|
+
return pendingRequests.size;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Get list of pending request URLs
|
|
317
|
+
*/
|
|
318
|
+
function getPendingUrls() {
|
|
319
|
+
const urls = [];
|
|
320
|
+
for (const [key, req] of pendingRequests) {
|
|
321
|
+
const url = typeof req.url === 'function' ? req.url() : req.url;
|
|
322
|
+
urls.push(url);
|
|
323
|
+
}
|
|
324
|
+
return urls;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Add event listener
|
|
329
|
+
*/
|
|
330
|
+
function on(event, callback) {
|
|
331
|
+
if (listeners[event]) {
|
|
332
|
+
listeners[event].push(callback);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Remove event listener
|
|
338
|
+
*/
|
|
339
|
+
function off(event, callback) {
|
|
340
|
+
if (listeners[event]) {
|
|
341
|
+
const index = listeners[event].indexOf(callback);
|
|
342
|
+
if (index !== -1) {
|
|
343
|
+
listeners[event].splice(index, 1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Reset tracking (clear all pending requests)
|
|
350
|
+
* Called on navigation to start fresh
|
|
351
|
+
*/
|
|
352
|
+
function reset() {
|
|
353
|
+
navigationId++;
|
|
354
|
+
pendingRequests.clear();
|
|
355
|
+
requestStartTimes.clear();
|
|
356
|
+
if (idleTimer) {
|
|
357
|
+
clearTimeout(idleTimer);
|
|
358
|
+
idleTimer = null;
|
|
359
|
+
}
|
|
360
|
+
log.debug(() => '🔄 Network tracker reset');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
startTracking,
|
|
365
|
+
stopTracking,
|
|
366
|
+
waitForNetworkIdle,
|
|
367
|
+
getPendingCount,
|
|
368
|
+
getPendingUrls,
|
|
369
|
+
reset,
|
|
370
|
+
on,
|
|
371
|
+
off,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PageSession - Abstraction for page lifecycle management
|
|
3
|
+
*
|
|
4
|
+
* A PageSession represents a single "page context" - from when a page
|
|
5
|
+
* is loaded until navigation to a different page occurs.
|
|
6
|
+
*
|
|
7
|
+
* Key features:
|
|
8
|
+
* - Automatic cleanup when navigation occurs
|
|
9
|
+
* - Scope handlers to current page only
|
|
10
|
+
* - Provide context for automation logic
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a PageSession factory
|
|
15
|
+
* @param {Object} options - Configuration options
|
|
16
|
+
* @param {Object} options.navigationManager - NavigationManager instance
|
|
17
|
+
* @param {Object} options.networkTracker - NetworkTracker instance
|
|
18
|
+
* @param {Function} options.log - Logger instance
|
|
19
|
+
* @returns {Object} - PageSession factory
|
|
20
|
+
*/
|
|
21
|
+
export function createPageSessionFactory(options = {}) {
|
|
22
|
+
const { navigationManager, networkTracker, log } = options;
|
|
23
|
+
|
|
24
|
+
const activeSessions = new Map();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a new PageSession for the current page
|
|
28
|
+
* @param {Object} sessionOptions - Session options
|
|
29
|
+
* @param {string} sessionOptions.name - Session name for debugging
|
|
30
|
+
* @param {RegExp|Function} sessionOptions.urlPattern - URL pattern to match (optional)
|
|
31
|
+
* @returns {Object} - PageSession instance
|
|
32
|
+
*/
|
|
33
|
+
function createSession(sessionOptions = {}) {
|
|
34
|
+
const { name = 'unnamed', urlPattern = null } = sessionOptions;
|
|
35
|
+
|
|
36
|
+
const sessionId = navigationManager.getSessionId();
|
|
37
|
+
const startUrl = navigationManager.getCurrentUrl();
|
|
38
|
+
|
|
39
|
+
// State
|
|
40
|
+
let isActive = true;
|
|
41
|
+
let cleanupCallbacks = [];
|
|
42
|
+
let eventListeners = [];
|
|
43
|
+
|
|
44
|
+
log.debug(
|
|
45
|
+
() =>
|
|
46
|
+
`📄 Creating page session "${name}" (id: ${sessionId}) for: ${startUrl}`
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if URL matches the session pattern
|
|
51
|
+
*/
|
|
52
|
+
function matchesUrl(url) {
|
|
53
|
+
if (!urlPattern) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (urlPattern instanceof RegExp) {
|
|
57
|
+
return urlPattern.test(url);
|
|
58
|
+
}
|
|
59
|
+
if (typeof urlPattern === 'function') {
|
|
60
|
+
return urlPattern(url);
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Handle navigation - deactivate session if URL no longer matches
|
|
67
|
+
*/
|
|
68
|
+
function handleUrlChange({ previousUrl, newUrl }) {
|
|
69
|
+
if (!isActive) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// If we have a URL pattern, check if we're still on a matching page
|
|
74
|
+
if (urlPattern && !matchesUrl(newUrl)) {
|
|
75
|
+
log.debug(
|
|
76
|
+
() => `📄 Session "${name}" ending - URL no longer matches: ${newUrl}`
|
|
77
|
+
);
|
|
78
|
+
deactivate();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Handle navigation start - cleanup before leaving
|
|
84
|
+
*/
|
|
85
|
+
async function handleBeforeNavigate() {
|
|
86
|
+
if (!isActive) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
log.debug(
|
|
91
|
+
() => `📄 Session "${name}" cleanup triggered (navigation starting)`
|
|
92
|
+
);
|
|
93
|
+
await runCleanup();
|
|
94
|
+
deactivate();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Subscribe to navigation events
|
|
98
|
+
navigationManager.on('onUrlChange', handleUrlChange);
|
|
99
|
+
navigationManager.on('onBeforeNavigate', handleBeforeNavigate);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run all cleanup callbacks
|
|
103
|
+
*/
|
|
104
|
+
async function runCleanup() {
|
|
105
|
+
for (const callback of cleanupCallbacks) {
|
|
106
|
+
try {
|
|
107
|
+
await callback();
|
|
108
|
+
} catch (e) {
|
|
109
|
+
log.debug(() => `⚠️ Session cleanup error: ${e.message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
cleanupCallbacks = [];
|
|
113
|
+
|
|
114
|
+
// Remove all event listeners
|
|
115
|
+
for (const { target, event, handler } of eventListeners) {
|
|
116
|
+
try {
|
|
117
|
+
target.off(event, handler);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
// Ignore removal errors
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
eventListeners = [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Deactivate the session
|
|
127
|
+
*/
|
|
128
|
+
function deactivate() {
|
|
129
|
+
if (!isActive) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
isActive = false;
|
|
134
|
+
|
|
135
|
+
// Unsubscribe from navigation events
|
|
136
|
+
navigationManager.off('onUrlChange', handleUrlChange);
|
|
137
|
+
navigationManager.off('onBeforeNavigate', handleBeforeNavigate);
|
|
138
|
+
|
|
139
|
+
// Remove from active sessions
|
|
140
|
+
activeSessions.delete(sessionId);
|
|
141
|
+
|
|
142
|
+
log.debug(() => `📄 Session "${name}" deactivated`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Register cleanup callback
|
|
147
|
+
* @param {Function} callback - Cleanup callback
|
|
148
|
+
*/
|
|
149
|
+
function onCleanup(callback) {
|
|
150
|
+
if (!isActive) {
|
|
151
|
+
log.debug(
|
|
152
|
+
() => `⚠️ Cannot register cleanup on inactive session "${name}"`
|
|
153
|
+
);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
cleanupCallbacks.push(callback);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Run code only if session is still active
|
|
161
|
+
* @param {Function} fn - Async function to run
|
|
162
|
+
* @returns {Promise<any>} - Result or null if session inactive
|
|
163
|
+
*/
|
|
164
|
+
async function ifActive(fn) {
|
|
165
|
+
if (!isActive) {
|
|
166
|
+
log.debug(() => `⏭️ Skipping action - session "${name}" is inactive`);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return fn();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Add event listener that will be cleaned up with session
|
|
174
|
+
* @param {Object} target - Event target (page, navigationManager, etc.)
|
|
175
|
+
* @param {string} event - Event name
|
|
176
|
+
* @param {Function} handler - Event handler
|
|
177
|
+
*/
|
|
178
|
+
function addEventListener(target, event, handler) {
|
|
179
|
+
if (!isActive) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
target.on(event, handler);
|
|
184
|
+
eventListeners.push({ target, event, handler });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Check if session is still active
|
|
189
|
+
*/
|
|
190
|
+
function checkActive() {
|
|
191
|
+
return isActive;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get session info
|
|
196
|
+
*/
|
|
197
|
+
function getInfo() {
|
|
198
|
+
return {
|
|
199
|
+
name,
|
|
200
|
+
sessionId,
|
|
201
|
+
startUrl,
|
|
202
|
+
isActive,
|
|
203
|
+
currentUrl: navigationManager.getCurrentUrl(),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Wait for network idle within this session
|
|
209
|
+
*/
|
|
210
|
+
async function waitForNetworkIdle(opts = {}) {
|
|
211
|
+
if (!isActive) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
if (!networkTracker) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
return networkTracker.waitForNetworkIdle(opts);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Wait for page to be ready within this session
|
|
222
|
+
*/
|
|
223
|
+
async function waitForPageReady(opts = {}) {
|
|
224
|
+
if (!isActive) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
return navigationManager.waitForPageReady(opts);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Manually end the session
|
|
232
|
+
*/
|
|
233
|
+
async function end() {
|
|
234
|
+
if (!isActive) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
log.debug(() => `📄 Session "${name}" ending (manual)`);
|
|
239
|
+
await runCleanup();
|
|
240
|
+
deactivate();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const session = {
|
|
244
|
+
// State
|
|
245
|
+
get isActive() {
|
|
246
|
+
return isActive;
|
|
247
|
+
},
|
|
248
|
+
get sessionId() {
|
|
249
|
+
return sessionId;
|
|
250
|
+
},
|
|
251
|
+
get startUrl() {
|
|
252
|
+
return startUrl;
|
|
253
|
+
},
|
|
254
|
+
get currentUrl() {
|
|
255
|
+
return navigationManager.getCurrentUrl();
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
// Lifecycle
|
|
259
|
+
onCleanup,
|
|
260
|
+
end,
|
|
261
|
+
checkActive,
|
|
262
|
+
getInfo,
|
|
263
|
+
|
|
264
|
+
// Utilities
|
|
265
|
+
ifActive,
|
|
266
|
+
addEventListener,
|
|
267
|
+
waitForNetworkIdle,
|
|
268
|
+
waitForPageReady,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Track active session
|
|
272
|
+
activeSessions.set(sessionId, session);
|
|
273
|
+
|
|
274
|
+
return session;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Get all active sessions
|
|
279
|
+
*/
|
|
280
|
+
function getActiveSessions() {
|
|
281
|
+
return Array.from(activeSessions.values());
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* End all active sessions
|
|
286
|
+
*/
|
|
287
|
+
async function endAllSessions() {
|
|
288
|
+
const sessions = Array.from(activeSessions.values());
|
|
289
|
+
for (const session of sessions) {
|
|
290
|
+
await session.end();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
createSession,
|
|
296
|
+
getActiveSessions,
|
|
297
|
+
endAllSessions,
|
|
298
|
+
};
|
|
299
|
+
}
|