GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
PuppeteerCommunicator.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using System;
18using System.Collections.Concurrent;
19using System.Collections.Generic;
20using System.Drawing;
21using System.IO;
22using System.Linq;
23using System.Net.WebSockets;
24using System.Runtime.InteropServices;
25using System.Text;
26//using System.Text.Json;
27using System.Threading;
28using System.Threading.Tasks;
29using Newtonsoft.Json;
30using Newtonsoft.Json.Linq;
32using static GenerallyPositive.Enums;
33
34// written with help from grok
36{
37 public class WindowSession
38 {
42 public string BrowserContextId { get; set; }
46 public ConcurrentQueue<KeyValuePair<string, string>> TabQueue { get; set; }
50 public int ActiveTabIndex { get; set; }
51
52 public WindowSession(string browserContextId, ConcurrentQueue<KeyValuePair<string, string>> tabQueue, int activeTabIndex = 0)
53 {
54 BrowserContextId = browserContextId;
55 TabQueue = tabQueue ?? new ConcurrentQueue<KeyValuePair<string, string>>();
56 ActiveTabIndex = activeTabIndex;
57 }
58 }
59
60 public class PuppeteerCommunicator
61 {
62 private string _puppeteerUrl;
63 private ClientWebSocket _webSocket;
64
65 public string webSocketUrl { get; private set; }
66
67 private int _activeWindowIndex = 0;
68
72 public int ServerResponseCode {
73 get => Browser.ServerResponseCode;
74 set => Browser.ServerResponseCode = value;
75 }
76
79 private Dictionary<string, long> FrameIdToContextId { get; set; } = new Dictionary<string, long>(StringComparer.Ordinal);
83 private string CurrentFrameSessionId { get; set; }
84
88 private long? CurrentContextId { get; set; }
92 private string CurrentFrameId { get; set; }
96 // what selectors are resolved against while a scope is set: a shadow root from InShadowDom, or an
97 // element from InElement. querySelector is the same call on either, so one field serves both.
98 // it holds a remote object, which belongs to one execution context and is dropped when that
99 // context changes
100 private string CurrentRootObjectId { get; set; }
101
110 internal async Task EnablePageEvents()
111 {
112 await SendCommand<object>(DevToolsMethods.PageEnable, new { }, GetEffectiveSessionId()).ConfigureAwait(false);
113 }
114
115 private ConcurrentDictionary<long, TaskCompletionSource<dynamic>> _responseTasks { get; } = new ConcurrentDictionary<long, TaskCompletionSource<dynamic>>();
116 private ConcurrentQueue<(string Event, dynamic Data)> _events { get; } = new ConcurrentQueue<(string, dynamic)>();
120 internal ConcurrentQueue<WindowSession> _windowSessionsQueue { get; set; } = new ConcurrentQueue<WindowSession>();
124 public ConcurrentQueue<KeyValuePair<string, string>> CurrentSessions
125 {
126 get
127 {
128 var tabQueue = GetActiveWindowTabQueue();
129 return tabQueue ?? new ConcurrentQueue<KeyValuePair<string, string>>();
130 }
131 }
132
135 public string CurrentTargetID
136 {
137 get => ((PuppeteerClient)PuppeteerClient)._currentTargetId;
138 }
139 private int _commandId = 0xdead;
143 internal bool _isRunning { get; set; }
147 internal bool _usePipes { get; set; }
148 private CancellationTokenSource _cts;
149 private readonly SemaphoreSlim _sendSemaphore = new SemaphoreSlim(1, 1); // Added for SendAsync serialization
153 internal IPuppeteerClient PuppeteerClient { get; set; }
157 internal Browser Browser { get; set; }
161 private bool _suppressNetworkEvents { get; set; } = false;
162
163 // every request the page issued while capture was on, in the order chrome reported them. off by default,
164 // because recording every request of every run costs memory nobody asked to spend
165 internal bool CapturingCalls { get; set; } = false;
166 internal List<GPALCall> CapturedCalls { get; } = new List<GPALCall>();
167
174 public List<GPALCall> GetCapturedCalls()
175 {
176 return new List<GPALCall>(CapturedCalls);
177 }
178
182 public void ClearCapturedCalls()
183 {
184 CapturedCalls.Clear();
185 }
186
187 // requestId to the call it belongs to, so the status the site answered with lands on the right one
188 private readonly Dictionary<string, GPALCall> _capturedById = new Dictionary<string, GPALCall>();
192 internal Task ReceiveTask { get; set; }
196 internal TaskCompletionSource<bool> _readerReadyTcs { get; set; }
197
198 // repeat browser messages are suppressed per browser. shared, one browser swallows another's lines and
199 // the session token that clears them is whichever browser changed session last
200 List<string> lastErrorMessage = new List<string>();
201 bool supressedMessage = false;
202 string lastSessionToken;
203 // this browser is on its way out, not every browser. shared, one browser closing stops every other
204 // one in the process from sending a command, and says nothing while it does it
205 bool inExit = false;
206
207 internal class CastDevice
208 {
209 public CastDevice(string name, string id)
210 {
211 this.name = name;
212 this.id = id;
213 }
214 public string name;
215 public string id;
216 }
217
218 internal List<CastDevice> CastDevices = new List<CastDevice>();
222 internal string SinkName { get; set; } = null;
223
224 internal PuppeteerCommunicator(string puppeteerUrl, IBrowser browser, bool usePipes = false, bool capturingCalls = false)
225 {
226 // set before initializing, because initializing is what attaches to the page and turns the Network
227 // domain on, and it reads this to decide whether to
228 CapturingCalls = capturingCalls;
229
230 Browser = (Browser)browser;
231 // one per communicator, because the client holds which target it is talking to and is bound to
232 // a communicator below. shared, the browser built second rebinds it and the browser built
233 // first sends everything down the second one's socket, into the wrong browser
234 PuppeteerClient = new PuppeteerClient();
235 _usePipes = usePipes;
236 _windowSessionsQueue = new ConcurrentQueue<WindowSession>(); // Initialize with new type
237
238 _puppeteerUrl = puppeteerUrl;
239
240 // longer than the wait inside GetWebSocketUrl, so a browser that is merely slow to open its debug
241 // port is waited out rather than killed by this while that one is still counting
242 var initTask = InitializeCommunication(Browser);
243 bool initFinished;
244
245 try
246 {
247 initFinished = initTask.Wait(TimeSpan.FromSeconds(45));
248 }
249 catch (AggregateException ex)
250 {
251 // Wait wraps what the init threw. the reason leaves here instead, with its own stack
252 Exception reason = ex.Flatten().InnerExceptions.FirstOrDefault() ?? ex;
253
254 System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(reason).Throw();
255
256 throw; // never reached, the compiler cannot know that
257 }
258
259 if (false == initFinished)
260 {
261 string msg = $"Puppeteer communication initialization timed out talking to [{puppeteerUrl}]. Workflow canot run without it, Try again.";
262
263 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, this, GPALObjectType.PuppeteerCommunicator);
264
265 // GPAL's own, so it arrives on the GPAL channel and a workflow that catches GPALException catches
266 // this too. a bare framework exception out of a constructor is nobody's to answer for
267 throw new GPALException($"{GPAL.MyMethodName()}: {msg}");
268 }
269 ReceiveTask = initTask.GetAwaiter().GetResult(); // Already finished, this just unwraps results/
270
271 //InitializeCommunication().GetAwaiter().GetResult();
272
273 if (!string.IsNullOrEmpty(puppeteerUrl))
274 PuppeteerClient.WithAPIBase(puppeteerUrl).WithPuppeteerCommunicator(this);
275
276 Browser.Started = true;
277 }
278
279 public PuppeteerCommunicator()
280 {
281 }
282
290 private const int MaxConnectAttempts = 3;
291
296 private const int ReaderStopWaitMs = 2_000;
297
303 private int _connectAttempts;
304
305 internal async Task Reconnect()
306 {
307 // if we lost pipes, then we closed, but ports can be closed due to bad cdp commands or other 'aborted connection' issues
308 if (false == _usePipes)
309 {
310 if (_webSocket != null)
311 {
312 try
313 {
314 _webSocket.Dispose();
315 }
316 catch { }
317 }
318 else
319 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Cannot restart CDP pipes. Please restart workflow", this, GPALObjectType.PuppeteerCommunicator);
320
321 _cts?.Cancel();
322
323 // cancelling only asks the readers to stop. disposing in the same breath races one that has
324 // not unwound. WhenAny does not rethrow, so a reader that faulted is left to whoever
325 // already reported it
326 if (null != ReceiveTask)
327 await Task.WhenAny(ReceiveTask, Task.Delay(ReaderStopWaitMs)).ConfigureAwait(false);
328
329 _cts?.Dispose();
330
331 await InitializeCommunication(Browser).ConfigureAwait(false);
332 }
333 }
345 private async Task<Task> InitializeCommunication(Browser Browser)
346 {
347 dynamic sessionId = null;
348 dynamic session = null;
349 dynamic targets = null;
350 bool _foundWhatsNew = false;
351 Task ourTask = null;
352
353 _readerReadyTcs = new TaskCompletionSource<bool>();
354 _cts = new CancellationTokenSource();
355 _isRunning = true;
356
357 if (true == _usePipes) // Check if pipe transport is enabled
358 {
359 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Using Puppeteer Pipes", this, GPALObjectType.PuppeteerCommunicator);
360 try
361 {
362 // Ensure Chrome process is running with pipe transport
363 if (true == Browser?.Process?.HasExited)
364 {
365 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Chrome process not running or exited for pipe transport", this, GPALObjectType.PuppeteerCommunicator);
366 return null;
367 }
368
369 ourTask = Task.Run(async () =>
370 {
371 try
372 {
373 await ReceivePipeMessages(_cts.Token).ConfigureAwait(false);
374 }
375 catch
376 {
377 _readerReadyTcs.TrySetCanceled();
378 }
379 }, _cts.Token);
380
381 // Wait for reader to be ready
382 //await _readerReadyTcs.Task; // - now safe
383 }
384 catch (Exception ex)
385 {
386 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Pipe initialization failed", this, GPALObjectType.PuppeteerCommunicator, ex);
387 return null;
388 }
389 }
390 else // WebSocket transport
391 {
392 var ws = new ClientWebSocket();
393 ws.Options.SetBuffer(65536, 65536); // 4MB
394 ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(30); // Ping every 30s
395 _webSocket = ws;
396 _webSocket.Options.KeepAliveInterval = TimeSpan.Zero;
397 webSocketUrl = GetWebSocketUrl(_puppeteerUrl);
398
399 if (string.IsNullOrEmpty(webSocketUrl))
400 {
401 // no websocket url is no browser, and a communicator that cannot talk to one fails later
402 // on whatever the workflow does first rather than here where the reason is known
403 string msg = $"The browser at [{_puppeteerUrl}] never opened its debug port, so there is nothing to drive";
404
405 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, this, GPALObjectType.PuppeteerCommunicator);
406
407 throw new GPALException($"{GPAL.MyMethodName()}: {msg}");
408 }
409
410 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Using Puppeteer Ports Connecting to WebSocket: [{webSocketUrl}]", this, GPALObjectType.PuppeteerCommunicator);
411 try
412 {
413 await _webSocket.ConnectAsync(new Uri(webSocketUrl), _cts.Token).ConfigureAwait(false);
414 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"WebSocket state after connect: [{_webSocket.State}]", this, GPALObjectType.PuppeteerCommunicator);
415
416 _connectAttempts = 0;
417
418 // Yield control for 10ms to let the connection handshake completely settle
419 await Task.Delay(10).ConfigureAwait(false);
420
421 // Use async/await inside Task.Run so .NET correctly tracks the background worker
422 ourTask = Task.Run(async () => await ReceiveMessages(_cts.Token).ConfigureAwait(false));
423 }
424 catch (Exception ex)
425 {
426 // a socket drops for reasons worth surviving: a heavy navigation, a target being recreated,
427 // a transient error under load. a dead browser looks identical, so the retry is bounded
428 _connectAttempts++;
429
430 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"WebSocket connection failed on attempt [{_connectAttempts}] of [{MaxConnectAttempts}]", this, GPALObjectType.PuppeteerCommunicator, ex);
431
432 if (MaxConnectAttempts <= _connectAttempts)
433 {
434 string msg = $"The browser at [{_puppeteerUrl}] refused its debug socket on [{_connectAttempts}] attempts, so there is nothing to drive";
435
436 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, this, GPALObjectType.PuppeteerCommunicator);
437
438 throw new GPALException($"{GPAL.MyMethodName()}: {msg}");
439 }
440
441 // longer each time, because a browser still opening is the case worth waiting on and a
442 // browser that is gone costs only the wait before it is called
443 await Task.Delay(_connectAttempts * 1_000).ConfigureAwait(false);
444
445 await Reconnect().ConfigureAwait(false);
446
447 return await InitializeCommunication(Browser).ConfigureAwait(false);
448 }
449 }
450
451 try
452 {
453 // Enable target discovery
454 await SendCommand<object>(DevToolsMethods.TargetSetDiscoverTargets, new { discover = true }, null).ConfigureAwait(false);
455
456 // Get all targets
457 for (int cnt = 3; 0 < cnt && false == _foundWhatsNew; cnt--)
458 {
459 targets = await SendCommand<object>(DevToolsMethods.TargetGetTargets, new { }, null).ConfigureAwait(false);
460 if (targets == null || targets.targetInfos == null)
461 {
462 // the socket is open and the browser answered, but there is no page to drive
463 string msg = $"The browser at [{_puppeteerUrl}] reported no targets, so there is no page to drive";
464
465 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, this, GPALObjectType.PuppeteerCommunicator);
466
467 throw new GPALException($"{GPAL.MyMethodName()}: {msg}");
468 }
469
470 // Close any tab with URL "chrome://whats-new/"
471 // NOTE: now handled in temp profile
472 //foreach (dynamic target in targets.targetInfos)
473 //{
474 // string targetType = target.type?.ToString();
475 // string targetUrl = target.url?.ToString();
476 // if (targetType == "page" && targetUrl == "chrome://whats-new/")
477 // {
478 // string targetId = target.targetId?.ToString();
479 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Closing tab with URL: [{targetUrl}] (targetId: [{targetId}])", this, GPALObjectType.PuppeteerCommunicator);
480 // await SendCommand<object>(DevToolsMethods.TargetCloseTarget, new { targetId }, null);
481 // _foundWhatsNew = true;
482 // goto restartAfterClose;
483 // }
484 //}
485 Thread.Sleep(1_000);
486 }
487
488 // Enable Target domain to receive Target.created events
489 await SendCommand<object>(DevToolsMethods.TargetSetDiscoverTargets, new { discover = true }, null).ConfigureAwait(false);
490 //await SendCommand<object>(DevToolsMethods.TargetSetAutoAttach, new { autoAttach = true, waitForDebuggerOnStart = false, flatten = true }, null);
491
492 // Find and attach to a page target
493 var pageTarget = ((IEnumerable<dynamic>)targets.targetInfos)
494 .FirstOrDefault(t => t.type == "page" && t.url != "");
495
496 if (pageTarget != null)
497 {
498 var targetId = (string)pageTarget.targetId;
499 session = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new
500 {
501 targetId,
502 flatten = true
503 }, null).ConfigureAwait(false);
504 sessionId = (string)session?.sessionId;
505
506 if (sessionId != null)
507 {
508 string browserContextId = pageTarget.browserContextId?.ToString() ?? "";
509 var tabQueue = new ConcurrentQueue<KeyValuePair<string, string>>();
510 tabQueue.Enqueue(new KeyValuePair<string, string>(targetId, sessionId));
511 _windowSessionsQueue.Enqueue(new WindowSession(browserContextId, tabQueue, 0)); // Default context, activeTabIndex=0
512 _activeWindowIndex = 0;
513 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
514 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Attached to targetId: [{targetId}], sessionId: [{sessionId}], URL: [{pageTarget.url}]", this, GPALObjectType.PuppeteerCommunicator);
515
516 // .CaptureCalls can be said before there is a browser. the flag is carried in when this
517 // communicator is built, and the Network domain is turned on here, where there is finally
518 // a session to turn it on for, so recording starts before the first navigation
519 if (true == CapturingCalls)
520 await CaptureCalls(true, sessionId).ConfigureAwait(false);
521 }
522 else
523 {
524 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to attach to targetId: [{targetId}]", this, GPALObjectType.PuppeteerCommunicator);
525 }
526 }
527 else
528 {
529 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "No page target found after initialization", this, GPALObjectType.PuppeteerCommunicator);
530 }
531 //await SendCommand<object>(DevToolsMethods.PageEnable, new { }, sessionId);
532 //await SendCommand<object>(DevToolsMethods.NetworkEnable, new { }, sessionId);
533 }
534 catch (ObjectDisposedException ex) when (ex.ObjectName == "System.Net.WebSockets.ClientWebSocket")
535 {
536 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"WebSocket disposed", this, GPALObjectType.Puppeteer, ex);
537 await Reconnect().ConfigureAwait(false);
538 return await InitializeCommunication(Browser).ConfigureAwait(false);
539 }
540 catch (Exception ex)
541 {
542 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Initialization failed", this, GPALObjectType.Puppeteer, ex);
543 }
544
545 // the reader this init started, so Reconnect waits on the one that is actually running rather than
546 // whichever one the constructor first saw
547 ReceiveTask = ourTask;
548
549 return ourTask;
550 }
551
557 private async Task InitializeWebSocket()
558 {
559 webSocketUrl ??= GetWebSocketUrl(_puppeteerUrl);
560 if (string.IsNullOrEmpty(webSocketUrl))
561 {
562 string msg = $"The browser at [{_puppeteerUrl}] never opened its debug port, so there is nothing to reconnect to";
563
564 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, this, GPALObjectType.PuppeteerCommunicator);
565
566 throw new GPALException($"{GPAL.MyMethodName()}: {msg}");
567 }
568
569 _webSocket = new ClientWebSocket();
570 await _webSocket.ConnectAsync(new Uri(webSocketUrl), _cts.Token).ConfigureAwait(false);
571 }
572
580 private async Task ApplyStealthProtection(string sessionId = null)
581 {
582 sessionId ??= GetEffectiveSessionId();
583 // Combine anti-bot scripts with enhanced toString override
584 string antiAntiBotScript = @"
585 (function() {
586 var originalError = Error;
587 Object.defineProperty(Error.prototype, 'stack', {
588 configurable: false,
589 enumerable: true,
590 writable: true,
591 value: (function() {
592 try {
593 throw new originalError();
594 } catch (e) {
595 return e.stack;
596 }
597 })()
598 });
599
600 window.Error = new Proxy(originalError, {
601 construct(target, args) {
602 var instance = new target(...args);
603 return Object.freeze(instance);
604 }
605 });
606
607 // Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
608
609 // Enhanced toString override to hide script contents
610 (function() {
611 // Store the original toString
612 const originalToString = Object.prototype.toString;
613
614 // Create a proxy handler to intercept toString calls
615 const handler = {
616 apply: function(target, thisArg, argumentsList) {
617 if (thisArg instanceof Function && /antiAntiBotScript|CDP/.test(originalToString.call(thisArg))) {
618 return 'function () { [native code] }';
619 }
620 return target.apply(thisArg, argumentsList);
621 }
622 };
623
624 // Wrap toString with a proxy
625 Object.prototype.toString = new Proxy(originalToString, handler);
626
627 console.log('toString override applied via Proxy');
628 })();
629
630 console.log('CDP protection with toString override applied');
631 })();
632 ";
633
634 // Enable Fetch script injection for the initial page load
635 await EnableFetchScriptInjection(antiAntiBotScript, sessionId).ConfigureAwait(false);
636
637 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Stealth CDP protection with toString override applied via Fetch.requestPaused.", this, GPALObjectType.Browser);
638 }
639 [DllImport("user32.dll")]
640 private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
641
648 internal const int DefaultCommandTimeoutSeconds = 10;
649 internal const int LongCommandTimeoutSeconds = 60;
650
651 // the commands whose work is not answering us: fetching a page over the network, repainting it, encoding an
652 // image. everything else is a question the browser already knows the answer to
653 private static readonly HashSet<DevToolsMethods> slowCommands = new HashSet<DevToolsMethods>
654 {
655 DevToolsMethods.PageNavigate,
656 DevToolsMethods.PageNavigateToHistoryEntry,
657 DevToolsMethods.PageReload,
658 DevToolsMethods.PageCaptureScreenshot,
659 };
660
661 public async Task<dynamic> SendCommand<TParameters>(DevToolsMethods method, TParameters parameters, string sessionId, int retries = 3, bool doNotGetSemaphore = false, int timeoutSeconds = 0)
662 {
663 const int SW_MINIMIZE = 6;
664
665 // 0 means the caller did not care, so pick by what the command has to do to answer
666 if (0 >= timeoutSeconds)
667 timeoutSeconds = slowCommands.Contains(method) ? LongCommandTimeoutSeconds : DefaultCommandTimeoutSeconds;
668 const int SW_RESTORE = 9;
669 const int SW_MAXIMIZE = 3;
670
671 if (true == inExit)
672 return null;
673
674 // For browser-level commands (Target.*), force null sessionId - we should have this properly managed in our code - but if someone else calls this from their code...
675 if (method.ToString().StartsWith("Target") && null != sessionId)
676 {
677 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Session ID [{sessionId}] cannot be used for 'Target' CDP commands. Using null.", this, GPALObjectType.PuppeteerCommunicator);
678 sessionId = null;
679 }
680
681 // Handle window state commands directly
682 if (method == DevToolsMethods.MinimizeWindow)
683 {
684 try
685 {
686 if (false == Browser?.Process?.HasExited)
687 {
688 ShowWindow(Browser.Process.MainWindowHandle, SW_MINIMIZE);
689 return true;
690 }
691 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No valid browser process to minimize", this, GPALObjectType.PuppeteerCommunicator);
692 return false;
693 }
694 catch (Exception ex)
695 {
696 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Failed to minimize window", this, GPALObjectType.PuppeteerCommunicator, ex);
697 return false;
698 }
699 }
700 else if (method == DevToolsMethods.RestoreWindow)
701 {
702 try
703 {
704 if (false == Browser?.Process?.HasExited)
705 {
706 ShowWindow(Browser.Process.MainWindowHandle, SW_RESTORE);
707 return true;
708 }
709 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No valid browser process to restore", this, GPALObjectType.PuppeteerCommunicator);
710 return false;
711 }
712 catch (Exception ex)
713 {
714 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Failed to restore window", this, GPALObjectType.PuppeteerCommunicator, ex);
715 return false;
716 }
717 }
718 else if (method == DevToolsMethods.MaximizeWindow)
719 {
720 try
721 {
722 if (false == Browser?.Process?.HasExited)
723 {
724 ShowWindow(Browser.Process.MainWindowHandle, SW_MAXIMIZE);
725 return true;
726 }
727 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No valid browser process to maximize", this, GPALObjectType.PuppeteerCommunicator);
728 return false;
729 }
730 catch (Exception ex)
731 {
732 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Failed to maximize window", this, GPALObjectType.PuppeteerCommunicator, ex);
733 return false;
734 }
735 }
736 else if (method == DevToolsMethods.CheckNetworkIdle)
737 {
738 return CheckNetworkIdle();
739 }
740
741 if (true == Browser?.Process?.HasExited)
742 {
743 string msg = "The browser process has exited, so there is nothing left to send a command to";
744
745 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, this, GPALObjectType.PuppeteerCommunicator);
746
747 if (false == inExit)
748 {
749 inExit = true;
750 BrowserHelper.KillAllRunningProcesses(true, Browser);
751 }
752
753 // cleanup first, then the workflow is told
754 throw new GPALException($"{GPAL.MyMethodName()}: {msg}");
755 }
756 else if (false == _usePipes && (_webSocket == null || _webSocket.State != WebSocketState.Open))
757 {
758 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"WebSocket in [{_webSocket?.State}] state, reconnecting", this, GPALObjectType.PuppeteerCommunicator);
759 await Reconnect().ConfigureAwait(false);
760 }
761
762 dynamic data = null;
763
764 try
765 {
766 if (false == doNotGetSemaphore)
767 await _sendSemaphore.WaitAsync(_cts.Token).ConfigureAwait(false);
768
769 try
770 {
771 long id = Interlocked.Increment(ref _commandId);
772 int retry = 2;
773 int rootNodeId = 1;
774
775 while (0 < retry)
776 {
777 var tcs = new TaskCompletionSource<dynamic>(TaskCreationOptions.RunContinuationsAsynchronously);
778 _responseTasks[id] = tcs;
779
780 var command = DevToolsCommandBuilder.BuildCommand(method, parameters, id, sessionId, rootNodeId);
781 var bytes = Encoding.UTF8.GetBytes(command);
782
783 if (_usePipes)
784 {
785 var stream = Browser.BrowserSettings.InboundPipe;
786
787 // Write 4-byte length header (big-endian uint32)
788 var lengthBytes = BitConverter.GetBytes((uint)bytes.Length);
789 if (BitConverter.IsLittleEndian)
790 Array.Reverse(lengthBytes); // Convert to big-endian
791 await stream.WriteAsync(lengthBytes, 0, lengthBytes.Length, _cts.Token).ConfigureAwait(false);
792
793 // Write JSON command
794 await stream.WriteAsync(bytes, 0, bytes.Length, _cts.Token).ConfigureAwait(false);
795
796 // Flush to ensure immediate send
797 await stream.FlushAsync(_cts.Token).ConfigureAwait(false);
798 }
799 else
800 {
801 await _webSocket.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, _cts.Token).ConfigureAwait(false);
802 }
803
804 // --- Add timeout handling to prevent hanging ---
805 dynamic message = null;
806 try
807 {
808 message = await tcs.Task.TimeoutAfter(TimeSpan.FromSeconds(timeoutSeconds), method.ToString()).ConfigureAwait(false);
809 }
810 catch (TimeoutException)
811 {
812 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Command [{method}] timed out, releasing semaphore.", this, GPALObjectType.PuppeteerCommunicator);
813 // Remove TCS to prevent blocking future commands
814 _responseTasks.TryRemove(id, out _);
815 throw; // optionally rethrow or handle gracefully
816 }
817 finally
818 {
819 // Ensure TCS is removed even on success or exception
820 _responseTasks.TryRemove(id, out _);
821 }
822
823 try
824 {
825 if (message is string)
826 {
827 if (true == "Session with given id not found.".Equals(message))
828 {
829 if (1 < retry--)
830 {
831 ClearCurrentSessionId(); // the stored one is dead, so drop it and let GetCurrentSessionId attach a fresh one
832 CurrentFrameSessionId = null;
833 sessionId = GetEffectiveSessionId();
834 continue;
835 }
836 }
837 else if (true == "Sink not found".Equals(message))
838 {
839 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "DIAL devices not supported. Use ID from chrome://media-router-internals/", this, GPALObjectType.PuppeteerCommunicator);
840 }
841
842 string msg = $"CDP error : [{message}].";
843 if (false == lastErrorMessage.Contains(msg)) // we are not in control of lastmessages.clear, whomever calls us is
844 {
845 lastErrorMessage.Add(msg);
846 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, this, GPALObjectType.PuppeteerCommunicator);
847 supressedMessage = false;
848 }
849 else if (false == supressedMessage)
850 {
851 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", msg, GPALObjectType.Other);
852 supressedMessage = true;
853 }
854
855 throw new ApplicationException(msg);
856 }
857 else if (message?.error != null)
858 {
859 if (true == "Session with given id not found.".Equals(message?.error?.message?.ToString()))
860 {
861 if (1 < retry--)
862 {
863 ClearCurrentSessionId(); // the stored one is dead, so drop it and let GetCurrentSessionId attach a fresh one
864 CurrentFrameSessionId = null;
865 sessionId = GetEffectiveSessionId();
866 continue;
867 }
868 }
869
870 string msg = $"CDP error : [{message.error.message}].";
871 if (false == lastErrorMessage.Contains(msg)) // we are not in control of lastmessages.clear, whomever calls us is
872 {
873 lastErrorMessage.Add(msg);
874 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, this, GPALObjectType.PuppeteerCommunicator);
875 supressedMessage = false;
876 }
877 else if (false == supressedMessage)
878 {
879 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", msg, GPALObjectType.Other);
880 supressedMessage = true;
881 }
882
883 throw new ApplicationException(msg);
884 }
885 }
886 catch //(Exception ex)
887 {
888 //var noErrorMessage = true;
889 }
890
891 if (null == resultExtractors)
892 {
893 var protocolJson = GetProtocolJSON();
894 resultExtractors = CdpResultExtractors.Build(protocolJson);
895 }
896 // Handle response data
897 if (resultExtractors != null && resultExtractors.TryGetValue(method, out var extractor))
898 {
899 if (null == message || message is string || message.result == null)
900 {
901 string msg = $"Command [{method}] returned null result.";
902 if (false == lastErrorMessage.Contains(msg))
903 {
904 lastErrorMessage.Add(msg);
905 GPAL.PublishSimpleEvent(GPALEventType.WARNING, msg, this, GPALObjectType.PuppeteerCommunicator);
906 supressedMessage = false;
907 }
908 else if (false == supressedMessage)
909 {
910 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", msg, GPALObjectType.Other);
911 supressedMessage = true;
912 }
913 return null;
914 }
915 data = extractor((Newtonsoft.Json.Linq.JObject)message?.result);
916 }
917 else
918 {
919 data = message.result?.ToObject<dynamic>() ?? new { };
920 }
921
922 break;
923 //if (data?.Length > 50_000_000) // Limit to 50MB
924 //{
925 // GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Message data too large", this, GPALObjectType.PuppeteerCommunicator);
926 // return null;
927 //}
928 }
929 }
930 finally
931 {
932 if (false == doNotGetSemaphore)
933 _sendSemaphore.Release();
934 }
935 }
936 catch (Exception ex)
937 {
938 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Command [{method}][{parameters}] failed.", null, GPALObjectType.None, ex);
939 }
940
941 return data;
942 }
951 public async Task<string> EnableFetchScriptInjection(string scriptToInject, string sessionId = null)
952 {
953 sessionId ??= GetEffectiveSessionId();
954 string identifier = null;
955 try
956 {
957 if (true == Browser.UseSelenium)
958 {
959 // Use Selenium CDP command for non-Puppeteer case
960 identifier = BrowserHelper.SeleniumAddScriptToEvaluateOnNewDocument(Browser.BrowserSettings, scriptToInject);
961 }
962 else if (true == Browser.UsePuppeteer)
963 {
964 // Use Puppeteer CDP command directly
965 var result = await SendCommand<object>(DevToolsMethods.PageAddScriptToEvaluateOnNewDocument, new
966 {
967 source = scriptToInject
968 }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId).ConfigureAwait(false);
969 identifier = result?.identifier?.ToString();
970 }
971
972 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Script injected via CDP for sessionId: [{sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
973 }
974 catch (Exception ex)
975 {
976 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to inject script via CDP", this, GPALObjectType.Puppeteer, ex);
977 throw;
978 }
979
980 return identifier;
981 }
982
989 public async Task RemoveInjectedScript(string identifier, string sessionId = null)
990 {
991 if (string.IsNullOrEmpty(identifier))
992 {
993 return;
994 }
995
996 sessionId ??= GetEffectiveSessionId();
997 try
998 {
999 if (true == Browser.UseSelenium)
1000 {
1001 BrowserHelper.SeleniumRemoveScriptToEvaluateOnNewDocument(Browser.BrowserSettings, identifier);
1002 }
1003 else if (true == Browser.UsePuppeteer)
1004 {
1005 await SendCommand<object>(DevToolsMethods.PageRemoveScriptToEvaluateOnNewDocument, new
1006 {
1007 identifier
1008 }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId).ConfigureAwait(false);
1009 }
1010
1011 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Injected script [{identifier}] removed via CDP for sessionId: [{sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
1012 }
1013 catch (Exception ex)
1014 {
1015 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to remove injected script via CDP", this, GPALObjectType.Puppeteer, ex);
1016 throw;
1017 }
1018 }
1019
1020 private readonly List<string> _injectedScriptIdentifiers = new List<string>();
1021
1026 public async Task<string> InjectScript(string scriptToInject, string sessionId = null)
1027 {
1028 var identifier = await EnableFetchScriptInjection(scriptToInject, sessionId).ConfigureAwait(false);
1029 if (!string.IsNullOrEmpty(identifier))
1030 {
1031 _injectedScriptIdentifiers.Add(identifier);
1032 }
1033 return identifier;
1034 }
1035
1039 public async Task ClearInjectedScripts(string sessionId = null)
1040 {
1041 foreach (var identifier in _injectedScriptIdentifiers)
1042 {
1043 await RemoveInjectedScript(identifier, sessionId).ConfigureAwait(false);
1044 }
1045 _injectedScriptIdentifiers.Clear();
1046 }
1047 // using fetchenable request - doesn't work due to nonce issues
1048 // the concept here is to inject javascript in a page response before the website loads it, but the above seems to work, iunno, i think this was for antibot scripts
1049 // but we are already stealthy except synthetic events, but then use hardware and chrome mode, not headless
1050 //public async Task EnableFetchScriptInjection(string scriptToInject, string sessionId = null)
1051 //{
1052 // try
1053 // {
1054 // // Ensure Fetch domain is enabled with retries
1055 // int maxRetries = 3;
1056 // for (int attempt = 0; attempt < maxRetries; attempt++)
1057 // {
1058 // try
1059 // {
1060 // await SendCommand<object>(DevToolsMethods.FetchEnable, new
1061 // {
1062 // patterns = new[]
1063 // {
1064 // new { urlPattern = "*", resourceType = "Document", requestStage = "Response" }
1065 // }
1066 // }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId);
1067 // GPAL.PublishSimpleEvent(GPALEventType.INFO, $"FetchEnable successful on attempt {attempt + 1}", this, GPALObjectType.PuppeteerCommunicator);
1068 // break;
1069 // }
1070 // catch (Exception ex)
1071 // {
1072 // GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"FetchEnable failed on attempt {attempt + 1}: {ex.Message}", this, GPALObjectType.PuppeteerCommunicator);
1073 // if (attempt == maxRetries - 1) throw;
1074 // await Task.Delay(500); // Wait before retrying
1075 // }
1076 // }
1077
1078 // Action<object, dynamic> handler = async (sender, e) =>
1079 // {
1080 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Fetch.requestPaused event data: {Newtonsoft.Json.JsonConvert.SerializeObject(e)}", this, GPALObjectType.PuppeteerCommunicator);
1081
1082 // var requestIdToken = e["requestId"];
1083 // if (requestIdToken == null)
1084 // {
1085 // GPAL.PublishSimpleEvent(GPALEventType.WARNING, "requestId is null in Fetch.requestPaused event. Full event data logged.", this, GPALObjectType.PuppeteerCommunicator);
1086 // // Add more context for debugging
1087 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Event context: sessionId={e.sessionId}, targetId={e.targetInfo?.targetId}, url={e.targetInfo?.url}", this, GPALObjectType.PuppeteerCommunicator);
1088 // return; // Exit if requestId is missing
1089 // }
1090
1091 // var requestId = requestIdToken.ToString();
1092 // var responseStatusCode = e["responseStatusCode"]?.ToObject<int>() ?? 200;
1093 // var resourceType = e["resourceType"]?.ToString();
1094
1095 // if (string.IsNullOrEmpty(resourceType) || (resourceType == "Document" && responseStatusCode >= 200 && responseStatusCode < 300))
1096 // {
1097 // try
1098 // {
1099 // var response = await SendCommand<object>(DevToolsMethods.FetchGetResponseBody, new { requestId }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId);
1100 // var body = response["body"]?.ToString();
1101 // var isBase64 = response["base64Encoded"]?.ToObject<bool>() ?? false;
1102
1103 // if (string.IsNullOrEmpty(body))
1104 // {
1105 // GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Empty body received for requestId: {requestId}", this, GPALObjectType.PuppeteerCommunicator);
1106 // await SendCommand<object>(DevToolsMethods.FetchContinueRequest, new { requestId }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId);
1107 // return;
1108 // }
1109
1110 // if (isBase64)
1111 // {
1112 // body = Encoding.UTF8.GetString(Convert.FromBase64String(body));
1113 // }
1114
1115 // // Extract the existing nonce from responseHeaders
1116 // string existingNonce = null;
1117 // var responseHeaders = e["responseHeaders"] as IEnumerable<dynamic>;
1118 // string cspHeader = null;
1119 // if (responseHeaders != null)
1120 // {
1121 // cspHeader = responseHeaders
1122 // .Where(h => h["name"].ToString().ToLower() == "content-security-policy")
1123 // .Select(h => h["value"].ToString())
1124 // .FirstOrDefault();
1125 // if (!string.IsNullOrEmpty(cspHeader))
1126 // {
1127 // var nonceMatch = System.Text.RegularExpressions.Regex.Match(cspHeader, @"nonce-([a-zA-Z0-9+/=]{16})");
1128 // if (nonceMatch.Success)
1129 // {
1130 // existingNonce = nonceMatch.Groups[1].Value;
1131 // }
1132 // }
1133 // }
1134
1135 // // Use the existing nonce if found, otherwise generate a new one
1136 // var nonce = !string.IsNullOrEmpty(existingNonce) ? $"nonce-{existingNonce}" : $"nonce-{Guid.NewGuid().ToString("N").Substring(0, 16)}";
1137 // var modifiedBody = body.Replace(
1138 // "<head>",
1139 // $"<head><script {nonce}>{scriptToInject}</script>"
1140 // );
1141
1142 // var responseHeadersList = new List<object>
1143 // {
1144 // new { name = "Content-Type", value = "text/html" }
1145 // };
1146
1147 // if (!string.IsNullOrEmpty(existingNonce) && cspHeader != null)
1148 // {
1149 // responseHeadersList.Add(new { name = "Content-Security-Policy", value = cspHeader });
1150 // }
1151 // else if (!string.IsNullOrEmpty(existingNonce))
1152 // {
1153 // responseHeadersList.Add(new { name = "Content-Security-Policy", value = $"script-src 'nonce-{existingNonce}' 'strict-dynamic' https: http:; object-src 'none';" });
1154 // }
1155 // else
1156 // {
1157 // responseHeadersList.Add(new { name = "Content-Security-Policy", value = $"script-src {nonce} 'strict-dynamic' https: http:; object-src 'none';" });
1158 // }
1159
1160 // await SendCommand<object>(DevToolsMethods.FetchFulfillRequest, new
1161 // {
1162 // requestId,
1163 // responseCode = responseStatusCode,
1164 // responseHeaders = responseHeadersList,
1165 // body = Convert.ToBase64String(Encoding.UTF8.GetBytes(modifiedBody))
1166 // }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId);
1167
1168 // GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Injected script into HTML response for requestId: {requestId} with nonce: {nonce}", this, GPALObjectType.PuppeteerCommunicator);
1169 // }
1170 // catch (Exception ex)
1171 // {
1172 // GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Failed to inject script: {ex.Message}", this, GPALObjectType.Puppeteer, ex);
1173 // await SendCommand<object>(DevToolsMethods.FetchContinueRequest, new { requestId }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId);
1174 // }
1175 // }
1176 // else
1177 // {
1178 // await SendCommand<object>(DevToolsMethods.FetchContinueRequest, new { requestId }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId);
1179 // }
1180 // };
1181
1182 // string eventKey = $"Fetch.requestPaused_{sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId}";
1183 // _eventHandlers[eventKey] = handler;
1184
1185 // GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Subscribed to Fetch.requestPaused for sessionId: {sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId}", this, GPALObjectType.PuppeteerCommunicator);
1186 // }
1187 // catch (Exception ex)
1188 // {
1189 // GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to enable Fetch script injection: {ex.Message}", this, GPALObjectType.Puppeteer, ex);
1190 // throw;
1191 // }
1192 //}
1203 internal async Task ReceivePipeMessages(CancellationToken ct)
1204 {
1205 // Signal: "I'm alive and reading"
1206 _readerReadyTcs?.SetResult(true);
1207
1208 var stream = Browser.BrowserSettings.OutboundPipe; // FileStream (byte-oriented)
1209 if (stream == null || !stream.IsConnected || !stream.CanRead)
1210 {
1211 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "OutboundPipe null, disconnected, or not readable", this, GPALObjectType.PuppeteerCommunicator);
1212 return;
1213 }
1214
1215 var encoding = Encoding.UTF8;
1216
1217 // Helper to process one complete JSON text message (unchanged)
1218 void ProcessMessageString(string text)
1219 {
1220 try
1221 {
1222 dynamic message = Newtonsoft.Json.JsonConvert.DeserializeObject(text);
1223 if (message == null) return;
1224
1225 if (message.id != null)
1226 {
1227 if (_responseTasks.TryRemove((int)message.id, out TaskCompletionSource<dynamic> tcs))
1228 {
1229 if (message.error != null)
1230 {
1231 string errorMessage = (message.error.message != null ? message.error.message.ToString() :
1232 message.error.ToString()) ?? "Unknown CDP error";
1233 if (!errorMessage.Contains("wasn't found"))
1234 GPAL.PublishSimpleEvent(GPALEventType.ERROR, errorMessage, this, GPALObjectType.PuppeteerCommunicator);
1235 tcs.TrySetResult(errorMessage);
1236 }
1237 else
1238 {
1239 tcs.TrySetResult(message);
1240 }
1241 }
1242 }
1243 else if (message.method != null)
1244 {
1245 string method = (string)message.method;
1246
1247 // turning the Page domain on to hear about dialogs brings every lifecycle and frame event with it,
1248 // and the dynamic reads below are not cheap enough to run on all of them for nothing
1249 if (true == method.StartsWith("Page.") && "Page.javascriptDialogOpening" != method)
1250 return;
1251
1252 dynamic paramsData = message.@params;
1253 string title = paramsData?.targetInfo?.title;
1254 string targetType = paramsData?.targetInfo?.type?.ToString();
1255 string sessionId = message.sessionId?.ToString() ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId;
1256 string url = paramsData?.targetInfo?.url?.ToString() ?? "unknown";
1257
1258 if (method.StartsWith("Network."))
1259 {
1260 if (method == "Network.requestWillBeSent" ||
1261 method == "Network.loadingFinished" ||
1262 method == "Network.loadingFailed" ||
1263 method == "Network.requestServedFromCache" ||
1264 method == "Network.requestWillBeSentExtraInfo")
1265 {
1266 _events.Enqueue((method, paramsData));
1267 }
1268
1269 // recorded before the suppression check, so a capture still sees the requests made
1270 // while something else is waiting for the network to go quiet
1271 if (true == CapturingCalls && "Network.requestWillBeSent" == method)
1272 RecordCall(paramsData);
1273 else if (true == CapturingCalls && "Network.responseReceived" == method)
1274 RecordStatus(paramsData);
1275
1276 if (_suppressNetworkEvents)
1277 return;
1278 }
1279
1280 // a dialog stops the page dead until it is answered, so it is answered from here rather than
1281 // handed to a workflow that cannot reach the page while one is up. the page's own alert,
1282 // confirm and prompt are untouched, so nothing about this is visible to it
1283 if ("Page.javascriptDialogOpening" == method && null != Browser.BrowserSettings.DialogsAccepted)
1284 {
1285 bool accept = true == Browser.BrowserSettings.DialogsAccepted;
1286 string typed = Browser.BrowserSettings.DialogText;
1287
1288 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Answering a [{paramsData?.type}] with accept [{accept}] text [{typed}]", this, GPALObjectType.PuppeteerCommunicator);
1289
1290 // not awaited, because this reader is the only thing that will ever see the reply arrive.
1291 // and sent past the send semaphore, because the click that raised the dialog is holding it
1292 // and cannot let go until the dialog is answered, which is what this is
1293 _ = SendCommand<object>(DevToolsMethods.PageHandleJavaScriptDialog,
1294 null == typed ? (object)new { accept } : new { accept, promptText = typed },
1295 sessionId, 3, true);
1296 }
1297
1298 if (method == "Target.targetCreated" && _sendSemaphore.CurrentCount == 1)
1299 {
1300 if (true == targetType?.Equals("page") && false == url?.StartsWith("chrome://") && false == url?.Equals("about:blank"))
1301 {
1302 string targetId = paramsData?.targetInfo?.targetId?.ToString();
1303 string browserContextId = paramsData?.targetInfo?.browserContextId?.ToString();
1304 if (!string.IsNullOrEmpty(targetId))
1305 {
1306 // TODO: this needs to be pushed onto a queue, then the next time send
1307 // Fire-and-forget is fine here
1308 _ = HandleNewTarget(targetId, browserContextId, null, true);
1309 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Detected new uncontrolled tab: TargetId [{targetId}] in Window [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
1310 }
1311 }
1312 }
1313
1314 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"Received (pipe): [{text}]", this, GPALObjectType.PuppeteerCommunicator);
1315 }
1316 }
1317 catch (Exception ex)
1318 {
1319 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Error processing pipe message", this, GPALObjectType.PuppeteerCommunicator, ex);
1320 }
1321 }
1322
1323 // CDP over a pipe separates messages with a nul byte and never says how long one is. read bytes
1324 // until one turns up, and what came before it is a whole message
1325 var pending = new List<byte>();
1326 var chunk = new byte[65536];
1327
1328 try
1329 {
1330 while (_isRunning && !Browser.BrowserSettings.Process.HasExited && !ct.IsCancellationRequested)
1331 {
1332 var readTask = stream.ReadAsync(chunk, 0, chunk.Length, ct);
1333
1334 if (await Task.WhenAny(readTask, Task.Delay(5000, ct)).ConfigureAwait(false) != readTask)
1335 {
1336 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No data on OutboundPipe after 5s - Chrome not responding", this, GPALObjectType.PuppeteerCommunicator);
1337 continue;
1338 }
1339
1340 int bytesRead = readTask.Result;
1341
1342 if (0 == bytesRead)
1343 break; // EOF
1344
1345 for (int i = 0; i < bytesRead; i++)
1346 {
1347 if (0 != chunk[i])
1348 {
1349 pending.Add(chunk[i]);
1350 continue;
1351 }
1352
1353 // a nul with nothing before it is a separator after a message already taken
1354 if (0 < pending.Count)
1355 {
1356 ProcessMessageString(encoding.GetString(pending.ToArray()));
1357 pending.Clear();
1358 }
1359 }
1360 }
1361 }
1362 catch (Exception ex)
1363 {
1364 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"ReceivePipeMessages failed", this, GPALObjectType.PuppeteerCommunicator, ex);
1365 }
1366 }
1378 private async Task ReceiveMessages(CancellationToken ct)
1379 {
1380 var buffer = new byte[102400];
1381
1382 while (_isRunning && _webSocket.State == WebSocketState.Open)
1383 {
1384 // Check if the cancellation token was canceled before proceeding
1385 // prolly connecting to a new page after closing a tab or window?
1386 if (ct.IsCancellationRequested)
1387 {
1388 // Handle cancellation gracefully
1389 try
1390 {
1391 await _webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Cancellation requested", CancellationToken.None).ConfigureAwait(false);
1392 _webSocket.Dispose();
1393 }
1394 catch (Exception ex)
1395 {
1396 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Error during WebSocket cleanup on cancellation", this, GPALObjectType.PuppeteerCommunicator, ex);
1397 }
1398 break;
1399 }
1400
1401 try
1402 {
1403 using var ms = new System.IO.MemoryStream();
1404 WebSocketReceiveResult result;
1405 do
1406 {
1407 result = await _webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), ct).ConfigureAwait(false);
1408 ms.Write(buffer, 0, result.Count);
1409 } while (!result.EndOfMessage);
1410
1411 try
1412 {
1413 if (result.MessageType == WebSocketMessageType.Close)
1414 {
1415 await _webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "WebSocket closed", CancellationToken.None).ConfigureAwait(false);
1416 break;
1417 }
1418 }
1419 catch (OperationCanceledException)
1420 {
1421 // Graceful cancellation
1422 }
1423 catch (WebSocketException ex)
1424 {
1425 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"WebSocket error", this, GPALObjectType.PuppeteerCommunicator, ex);
1426 }
1427 finally
1428 {
1429 if (_webSocket.State == WebSocketState.Closed || _webSocket.State == WebSocketState.Aborted)
1430 {
1431 try { await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Cleanup", CancellationToken.None).ConfigureAwait(false); } catch { }
1432 _webSocket.Dispose();
1433 _webSocket = null;
1434 }
1435 }
1436
1437 if (null != ms)
1438 {
1439 var json = Encoding.UTF8.GetString(ms.ToArray());
1440 var message = Newtonsoft.Json.JsonConvert.DeserializeObject<dynamic>(json);
1441
1442 if (message?.id != null)
1443 {
1444 if (_responseTasks.TryRemove((int)message.id, out TaskCompletionSource<dynamic> tcs))
1445 {
1446 if (message.error != null)
1447 {
1448 string errorMessage = (message.error.message != null ? message.error.message.ToString() :
1449 message.error.ToString()) ?? "Unknown CDP error";
1450 if (false == errorMessage.Contains("wasn't found")) // trying to remove a token we removed already
1451 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, errorMessage, this, GPALObjectType.PuppeteerCommunicator);
1452 tcs.SetResult(errorMessage);
1453 }
1454 else
1455 tcs.SetResult(message);
1456 }
1457 }
1458 else if (message.method != null)
1459 {
1460 string method = (string)message.method;
1461
1462 // turning the Page domain on to hear about dialogs brings every lifecycle and frame event with it,
1463 // and the dynamic reads below are not cheap enough to run on all of them for nothing
1464 // continue, not return: this one is the receive loop itself, and returning from it
1465 // ends the reader for the life of the browser
1466 if (true == method.StartsWith("Page.") && "Page.javascriptDialogOpening" != method)
1467 continue;
1468
1469 dynamic paramsData = message.@params;
1470 string title = paramsData.targetInfo?.title;
1471 string targetType = paramsData.targetInfo?.type?.ToString();
1472 string sessionId = message.sessionId?.ToString() ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId;
1473 string url = paramsData.targetInfo?.url?.ToString() ?? "unknown";
1474
1475
1476 // Filter network events when suppressed
1477 if (method.StartsWith("Network."))
1478 {
1479 // Always enqueue relevant network events
1480 if (method == "Network.requestWillBeSent" ||
1481 method == "Network.loadingFinished" ||
1482 method == "Network.loadingFailed" ||
1483 method == "Network.requestServedFromCache" ||
1484 method == "Network.requestWillBeSentExtraInfo")
1485 {
1486 _events.Enqueue((method, paramsData));
1487 }
1488
1489 // recorded before the suppression check, so a capture still sees the requests made
1490 // while something else is waiting for the network to go quiet
1491 if (true == CapturingCalls && "Network.requestWillBeSent" == method)
1492 RecordCall(paramsData);
1493 else if (true == CapturingCalls && "Network.responseReceived" == method)
1494 RecordStatus(paramsData);
1495
1496 if (method == "Network.responseReceived")
1497 {
1498 // paramsData is message.@params
1499 string type = paramsData.type?.ToString();
1500
1501 // We only care about "Document" (the actual HTML page)
1502 if (type == "Document")
1503 {
1504 this.ServerResponseCode = (int)paramsData.response.status;
1505 string responseUrl = paramsData.response.url?.ToString();
1506
1507 Browser.ServerResponseCode = this.ServerResponseCode;
1508 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
1509 $"HTTP Status Captured: [{this.ServerResponseCode}] for [{responseUrl}]",
1510 this, GPALObjectType.PuppeteerCommunicator);
1511
1512 // You might want to store this in a field like: this.LastStatusCode = statusCode;
1513 }
1514 }
1515 if (_suppressNetworkEvents)
1516 continue; // Skip logging and further processing
1517 }
1518
1519 if (method.StartsWith("Cast.")) // prolly just Cast.sinksUpdated
1520 {
1521 try
1522 {
1523 JObject payload = paramsData;
1524 JToken sink = payload["sinks"];
1525 try
1526 {
1527 if (null != sink)
1528 CastDevices.Add(new CastDevice(sink[0]["name"]?.ToString(), sink[0]["id"]?.ToString()));
1529 } catch { } // index issue
1530
1531 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
1532 $"[{method}] [{Newtonsoft.Json.JsonConvert.SerializeObject(paramsData)}]",
1533 this, GPALObjectType.PuppeteerCommunicator);
1534
1535 // TODO: Store sinks globally so CastTo() can read them later and decipher friendly name
1536 // LastKnownSinks = paramsData.sinks;
1537 }
1538 catch (Exception ex)
1539 {
1540 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
1541 $"Error processing [{method}]",
1542 this, GPALObjectType.PuppeteerCommunicator, ex);
1543 }
1544 }
1545
1546 // a dialog stops the page dead until it is answered, so it is answered from here rather than
1547 // handed to a workflow that cannot reach the page while one is up. the page's own alert,
1548 // confirm and prompt are untouched, so nothing about this is visible to it
1549 if ("Page.javascriptDialogOpening" == method && null != Browser.BrowserSettings.DialogsAccepted)
1550 {
1551 bool accept = true == Browser.BrowserSettings.DialogsAccepted;
1552 string typed = Browser.BrowserSettings.DialogText;
1553
1554 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Answering a [{paramsData?.type}] with accept [{accept}] text [{typed}]", this, GPALObjectType.PuppeteerCommunicator);
1555
1556 // not awaited, because this reader is the only thing that will ever see the reply arrive.
1557 // and sent past the send semaphore, because the click that raised the dialog is holding it
1558 // and cannot let go until the dialog is answered, which is what this is
1559 _ = SendCommand<object>(DevToolsMethods.PageHandleJavaScriptDialog,
1560 null == typed ? (object)new { accept } : new { accept, promptText = typed },
1561 sessionId, 3, true);
1562 }
1563
1564 if (method == "Target.targetCreated" && _sendSemaphore.CurrentCount == 1)
1565 {
1566 if (true == targetType.Equals("page") && false == url?.StartsWith("chrome://") && false == url?.Equals("about:blank"))
1567 {
1568 string targetId = paramsData.targetInfo?.targetId?.ToString();
1569 string browserContextId = paramsData.targetInfo?.browserContextId?.ToString();
1570
1571 if (!string.IsNullOrEmpty(targetId))
1572 {
1573 // Handle like NewTab: attach, enable domains, add to queue
1574 // TODO: this needs to be pushed onto a queue, then the next time send
1575 await HandleNewTarget(targetId, browserContextId, null, true).ConfigureAwait(false);
1576 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Detected new uncontrolled tab: TargetId [{targetId}] in Window [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
1577 }
1578 }
1579 }
1580 //else if (method == "Target.targetInfoChanged" && _sendSemaphore.CurrentCount == 1)
1581 //{
1582 // string targetId = paramsData.targetInfo?.targetId?.ToString();
1583 // string browserContextId = paramsData.targetInfo?.browserContextId?.ToString();
1584 // targetType = paramsData.targetInfo?.type?.ToString();
1585 // url = paramsData.targetInfo?.url?.ToString() ?? "unknown";
1586
1587 // if (targetType == "page" && targetId != null && true == Browser.Started)
1588 // {
1589 // if (url.StartsWith("chrome://") || url == "about:blank")
1590 // {
1591 // ; GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Skipping tab info change: TargetId [{targetId}], URL [{url}]", this, GPALObjectType.PuppeteerCommunicator);
1592 // }
1593 // else
1594 // {
1595 // // Re-attach to ensure valid sessionId
1596 // var session = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new { targetId, flatten = true }, null, 3, true);
1597 // string newSessionId = session?.sessionId?.ToString();
1598 // if (newSessionId != null)
1599 // {
1600 // await HandleNewTarget(targetId, browserContextId, newSessionId);
1601 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Processed tab info change: TargetId [{targetId}] in Window [{browserContextId}], Session [{newSessionId}]", this, GPALObjectType.PuppeteerCommunicator);
1602 // }
1603 // else
1604 // {
1605 // GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to re-attach on Target.targetInfoChanged: TargetId [{targetId}]", this, GPALObjectType.PuppeteerCommunicator);
1606 // }
1607 // }
1608 // }
1609 //}
1610 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"Received: [{Newtonsoft.Json.JsonConvert.SerializeObject(message)}]", this, GPALObjectType.PuppeteerCommunicator);
1611 }
1612 }
1613 }
1614 catch (Exception ex) // or catch (WebSocketException ex) if you want to be more specific
1615 {
1616 string innerMsg = "";
1617
1618 // Option 1 – Simple & most common (recommended for logging)
1619 if (ex.InnerException != null)
1620 {
1621 innerMsg = $" Inner: [{ex.InnerException.Message}]";
1622 // Optional: also add type if helpful
1623 // innerMsg += $" ({ex.InnerException.GetType().Name})";
1624 }
1625
1626 string exMsg = $"Error receiving WebSocket message: [{ex.Message}][{innerMsg}]";
1627
1628 // Optional – even better: walk the full inner chain (very deep nesting is rare in WebSocket scenarios)
1629 // string fullInnerChain = GetFullInnerExceptionMessage(ex);
1630
1631 GPAL.PublishSimpleEvent(
1632 GPALEventType.DEEPDEBUG,
1633 exMsg,
1634 this,
1635 GPALObjectType.Puppeteer,
1636 ex); // - still pass original ex (it already contains .InnerException)
1637
1638 // throw new ApplicationException(exMsg, ex); // - Important: pass original ex as InnerException! // can't do this kills everything
1639 }
1640 }
1641 }
1642
1652 private async Task HandleNewTarget(string targetId, string browserContextId, string newSessionId = null, bool doNotGetSendSemaphore = false)
1653 {
1654 bool activeTab = !(null == newSessionId); // if no sessionid, we are creating via newTab, else, from receiveMessages and we don't know if it's active
1655
1656 // Add to the window's tab queue
1657 await AddTabToQueue(targetId, newSessionId, activeTab, doNotGetSendSemaphore).ConfigureAwait(false);
1658
1659 // Set as active tab (mimic NewTab behavior)
1660 //SetActiveTabIndex(CurrentSessions.Count - 1);
1661
1662 // a new tab is a new session with the Network domain off, so a capture that was on for the page that
1663 // opened it would go quiet exactly where the interesting request is
1664 if (true == CapturingCalls)
1665 await CaptureCalls(true, newSessionId).ConfigureAwait(false);
1666
1667 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Handled new target: ID [{targetId}] in Window [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
1668 }
1682 public async Task WaitForEvent(string eventName, TimeSpan timeout)
1683 {
1684 var start = DateTime.UtcNow;
1685 while (DateTime.UtcNow - start < timeout)
1686 {
1687 if (_events.TryDequeue(out var evt) && evt.Event == eventName)
1688 return;
1689 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Waiting for [{eventName}] got [{evt.Event}]", this, GPALObjectType.PuppeteerCommunicator);
1690 await Task.Delay(100).ConfigureAwait(false);
1691 }
1692 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Event [{eventName}] not received within [{timeout.TotalSeconds}]s", this, GPALObjectType.PuppeteerCommunicator);
1693 }
1694
1714 public async Task<bool> WaitForLayoutReady(GPALElement element, string sessionId, int maxWaitMs = 3000, int pollIntervalMs = 150)
1715 {
1716 var stopwatch = System.Diagnostics.Stopwatch.StartNew();
1717 sessionId ??= GetEffectiveSessionId();
1718
1719 while (stopwatch.ElapsedMilliseconds < maxWaitMs)
1720 {
1721 // Option A: Try CDP box model (most accurate)
1722 var box = await SendCommand<dynamic>(
1723 DevToolsMethods.DOMGetBoxModel,
1724 new { backendNodeId = element.ElementBackendNodeId },
1725 sessionId
1726 ).ConfigureAwait(false);
1727
1728 if (box?.model?.content != null)
1729 {
1730 var quad = box.model.content;
1731 if (quad.Count >= 8 && quad[2] > quad[0] && quad[7] > quad[1]) // width & height > 0
1732 {
1733 return true;
1734 }
1735 }
1736 else if (null == box) // no box model? maybe it got detached? try to find the new element?
1737 {
1738 List<GPALElement> elems = await EvaluateSelector(element.Css, sessionId).ConfigureAwait(false);
1739 if (0 < elems.Count)
1740 {
1741 element.ElementBackendNodeId = elems[0].ElementBackendNodeId;
1742 element.ElementHandle = elems[0].ElementHandle;
1743 element.ElementNodeId = elems[0].ElementNodeId;
1744 element.Css = elems[0].Css;
1745 element.Xpath = elems[0].Xpath;
1746 }
1747 }
1748
1749 // Option B: Fallback to JS dimensions check (works even if CDP box fails)
1750 var jsResult = await SendCommand<dynamic>(
1751 DevToolsMethods.RuntimeEvaluate,
1752 new
1753 {
1754 expression = $@"
1755 (function() {{
1756 const el = document.querySelector('{element.Css}') ||
1757 document.evaluate('{element.Xpath}', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
1758 if (!el) return false;
1759 const r = el.getBoundingClientRect();
1760 return r.width > 0.1 && r.height > 0.1 && r.top >= 0; // visible-ish
1761 }})()",
1762 contextId = CurrentContextId,
1763 returnByValue = true
1764 },
1765 sessionId
1766 ).ConfigureAwait(false);
1767
1768 if (jsResult?.result?.value is bool visible && visible)
1769 {
1770 return true;
1771 }
1772
1773 await Task.Delay(pollIntervalMs).ConfigureAwait(false);
1774 }
1775
1776 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
1777 $"Timeout waiting for layout on [{element.TagName}][{element.Css ?? element.Xpath}] after [{maxWaitMs}]ms",
1778 this, GPALObjectType.PuppeteerCommunicator);
1779
1780 return false;
1781 }
1782
1791 /// <param name="sessionId">The CDP session used to issue the creation commands; if null, the effective session ID is resolved automatically.</param>
1792 /// <returns>A task that resolves to the CDP target ID of the newly created tab.</returns>
1793 /// <example>
1794 /// <code>
1795 /// string targetId = await communicator.CreateTarget("https://example.com", newWindow: true);
1796 /// </code>
1797 /// </example>
1798 public async Task<string> CreateTarget(string url, bool newWindow = false, string sessionId = null)
1799 {
1800 sessionId ??= GetEffectiveSessionId();
1801 url = MagicHelper.GetFullUrl(url, Browser, out Browser._areRobotsAllowed);
1802
1803 if (false == Browser.AreRobotsAllowed && true == Browser.BrowserSettings.ObeyRobotsTxt)
1804 {
1805 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Visiting [{url}] is disallowed by robots.txt and your request to honor it. Not going to URL. Workflow will fail.", this, GPALObjectType.PuppeteerCommunicator);
1806 url = "https://google.com";
1807 }
1808
1809 object parameters = new { url };
1810 if (newWindow)
1811 {
1812 var context = await SendCommand<object>(DevToolsMethods.TargetCreateBrowserContext, new { }, sessionId).ConfigureAwait(false);
1813 string browserContextId = context.browserContextId.ToString();
1814 parameters = new { url, browserContextId };
1815 }
1816
1817 var result = await SendCommand<object>(DevToolsMethods.TargetCreateTarget, parameters, null).ConfigureAwait(false); // null for browser-level
1818 string targetId = result.targetId.ToString();
1819 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId }, null).ConfigureAwait(false);
1820 var session = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new { targetId, flatten = true }, null).ConfigureAwait(false);
1821 string newSessionId = session.sessionId.ToString();
1822 await AddTabToQueue(targetId, newSessionId).ConfigureAwait(false); // Add to queue, set active
1823
1824 // Enable Page domain for new tab
1825 //await SendCommand<object>(DevToolsMethods.PageEnable, new { }, newSessionId);
1826
1827 return targetId;
1828 }
1834 internal async Task CloseOutputWebSocketAsync()
1835 {
1836 // Close the WebSocket connection with a normal closure status and an empty description
1837 try
1838 {
1839 if (null != _webSocket)
1840 await _webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Client closing", CancellationToken.None).ConfigureAwait(false);
1841 }
1842 catch
1843 {
1844 // we are terminating anyways...
1845 }
1846 }
1853 internal async Task<string> GetInitialTargetId(string sessionId)
1854 {
1855 dynamic targets = await SendCommand<dynamic>(DevToolsMethods.TargetGetTargets, new { }, null).ConfigureAwait(false);
1856 var pageTarget = ((IEnumerable<dynamic>)targets.targetInfos).FirstOrDefault(t => (string)t.type == "page"); // Cast to IEnumerable<dynamic> and use typed lambda
1857 return pageTarget != null ? (string)pageTarget.targetId : null;
1858 }
1865 internal async Task<int> TabCount()
1866 {
1867 dynamic targets = await SendCommand<dynamic>(DevToolsMethods.TargetGetTargets, new { }, null).ConfigureAwait(false);
1868 return ((IEnumerable<dynamic>)targets.targetInfos).Count(t => (string)t.type == "page");
1869 }
1876 /// <param name="sessionId">Unused; reserved for future use.</param>
1877 /// <returns>A task that resolves to the browser context ID (window ID) of the window containing a matching tab, or null if no match is found.</returns>
1878 /// <example>
1879 /// <code>
1880 /// string windowId = await communicator.GetTargetWindowIdByUrl("https://example.com/dashboard");
1881 /// </code>
1882 /// </example>
1883 public async Task<string> GetTargetWindowIdByUrl(string url, string sessionId = null)
1884 {
1885 if (string.IsNullOrWhiteSpace(url))
1886 {
1887 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "URL is empty or null in GetTargetWindowIdByUrl", this, GPALObjectType.PuppeteerCommunicator);
1888 return null;
1889 }
1890
1891 url = MagicHelper.GetFullUrl(url, Browser, out bool _); // Normalize URL (e.g., add "https://" if needed)
1892 var windowArray = _windowSessionsQueue.ToArray();
1893 foreach (var window in windowArray)
1894 {
1895 var tabArray = window.TabQueue.ToArray();
1896 foreach (var tab in tabArray)
1897 {
1898 var targetInfo = await SendCommand<object>(DevToolsMethods.TargetGetTargetInfo, new { targetId = tab.Key }, null).ConfigureAwait(false);
1899 string targetUrl = targetInfo?.targetInfo?.url?.ToString();
1900 if (targetUrl != null && targetUrl.Contains(url))
1901 {
1902 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Found window with browserContextId [{window.BrowserContextId}] for URL [{url}]", this, GPALObjectType.PuppeteerCommunicator);
1903 return window.BrowserContextId; // Return the window's BrowserContextId
1904 }
1905 }
1906 }
1907
1908 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No window found with a tab matching URL [{url}]", this, GPALObjectType.PuppeteerCommunicator);
1909 return null;
1910 }
1917 /// <param name="sessionId">The CDP session used to resolve the effective session if needed; if null, the effective session ID is resolved automatically.</param>
1918 /// <returns>A task that resolves to the CDP target ID of the matching tab, the current target ID if <paramref name="url"/> was empty, or null if no match is found.</returns>
1919 /// <example>
1920 /// <code>
1921 /// string tabId = await communicator.GetTargetTabIdByUrl("https://example.com/page2");
1922 /// </code>
1923 /// </example>
1924 public async Task<string> GetTargetTabIdByUrl(string url, string sessionId = null)
1925 {
1926 sessionId ??= GetEffectiveSessionId();
1927
1928 if (string.IsNullOrWhiteSpace(url))
1929 {
1930 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "URL is empty or null in GetTargetTabIdByUrl", this, GPALObjectType.PuppeteerCommunicator);
1931 return GetCurrentTargetId();
1932 }
1933
1934 url = MagicHelper.GetFullUrl(url, Browser, out bool _);
1935 var windowArray = _windowSessionsQueue.ToArray();
1936
1937 if (_activeWindowIndex >= 0 && _activeWindowIndex < windowArray.Length)
1938 {
1939 var tabArray = windowArray[_activeWindowIndex].TabQueue.ToArray();
1940 foreach (var tab in tabArray)
1941 {
1942 var targetInfo = await SendCommand<object>(DevToolsMethods.TargetGetTargetInfo, new { targetId = tab.Key }, null).ConfigureAwait(false);
1943 string targetUrl = targetInfo?.targetInfo?.url?.ToString();
1944 if (targetUrl != null)
1945 {
1946 string decodedTarget = Uri.UnescapeDataString(targetUrl);
1947 string decodedUrl = Uri.UnescapeDataString(url);
1948
1949 if (true == decodedTarget.Contains(decodedUrl))
1950 {
1951 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
1952 $"Found tab with targetId [{tab.Key}] for URL [{url}] in active window",
1953 this, GPALObjectType.PuppeteerCommunicator);
1954
1955 return tab.Key;
1956 }
1957 }
1958 }
1959 }
1960
1961 for (int i = 0; i < windowArray.Length; i++)
1962 {
1963 if (i == _activeWindowIndex) continue;
1964 var tabArray = windowArray[i].TabQueue.ToArray();
1965 foreach (var tab in tabArray)
1966 {
1967 var targetInfo = await SendCommand<object>(DevToolsMethods.TargetGetTargetInfo, new { targetId = tab.Key }, null).ConfigureAwait(false);
1968 string targetUrl = targetInfo?.targetInfo?.url?.ToString();
1969 if (targetUrl != null && targetUrl.Contains(url))
1970 {
1971 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Found tab with targetId [{tab.Key}] for URL [{url}] in window [{i}]", this, GPALObjectType.PuppeteerCommunicator);
1972 return tab.Key;
1973 }
1974 }
1975 }
1976
1977 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No tab found with URL [{url}]", this, GPALObjectType.PuppeteerCommunicator);
1978 return null;
1979 }
1987 internal async Task<string> GetNextWindowId(string id, string sessionId = null)
1988 {
1989 sessionId ??= GetEffectiveSessionId();
1990 var targets = await SendCommand<object>(DevToolsMethods.TargetGetTargets, new { }, null).ConfigureAwait(false);
1991 var windowList = ((IEnumerable<dynamic>)targets.targetInfos)
1992 .Where(t => t.type == "browser" || t.type == "page")
1993 .GroupBy(t => t.browserContextId ?? t.targetId)
1994 .Select(g => g.First())
1995 .OrderBy(t => t.targetId)
1996 .ToList();
1997 for (int i = 0; i < windowList.Count; i++)
1998 {
1999 if (windowList[i].targetId.ToString() == id)
2000 {
2001 return i < windowList.Count - 1 ? windowList[i + 1].targetId.ToString() : null;
2002 }
2003 }
2004 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No next window found for ID: [{id}]", this, GPALObjectType.PuppeteerCommunicator);
2005 return null;
2006 }
2014 internal async Task<string> GetPreviousWindowId(string id, string sessionId = null)
2015 {
2016 sessionId ??= GetEffectiveSessionId();
2017 var targets = await SendCommand<object>(DevToolsMethods.TargetGetTargets, new { }, null).ConfigureAwait(false);
2018 var windowList = ((IEnumerable<dynamic>)targets.targetInfos)
2019 .Where(t => t.type == "browser" || t.type == "page")
2020 .GroupBy(t => t.browserContextId ?? t.targetId)
2021 .Select(g => g.First())
2022 .OrderBy(t => t.targetId)
2023 .ToList();
2024 for (int i = 0; i < windowList.Count; i++)
2025 {
2026 if (windowList[i].targetId.ToString() == id)
2027 {
2028 return i > 0 ? windowList[i - 1].targetId.ToString() : null;
2029 }
2030 }
2031 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No previous window found for ID: [{id}]", this, GPALObjectType.PuppeteerCommunicator);
2032 return null;
2040 public async Task<int> QueryByCss(string selector, string sessionId = null)
2041 {
2042 sessionId ??= GetEffectiveSessionId();
2043 //await WaitForEvent("Page.loadEventFired", TimeSpan.FromSeconds(10));
2044
2045 var doc = await SendCommand<object>(DevToolsMethods.DOMGetDocument, new { depth = 0 }, sessionId).ConfigureAwait(false);
2046 var result = await SendCommand<object>(DevToolsMethods.DOMQuerySelector, new { nodeId = (int)doc.root.nodeId, selector }, sessionId).ConfigureAwait(false);
2047
2048 var nodeId = (int)result.nodeId;
2049
2050 if (nodeId == 0)
2051 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No nodes found for Css: [{selector}]", this, GPALObjectType.PuppeteerCommunicator);
2052
2053 return nodeId;
2054 }
2055 public class Node
2056 {
2060 public string ObjectId { get; set; }
2063
2064 public int BackendNodeId { get; set; }
2068 public int NodeId { get; set; }
2069 }
2070
2071 private readonly SemaphoreSlim _evaluateSelectorSemaphore = new SemaphoreSlim(1, 1);
2072
2073 /// <summary>
2074 /// Resolves an iframe element matching the given selector and switches the current evaluation context
2075 /// into that iframe by attaching to its frame target (for cross-origin frames) and creating an isolated
2076 /// JavaScript execution world. Subsequent DOM/JS operations will operate inside the iframe until
2077 /// the context is reset.
2078 /// </summary>
2079 /// <param name="frameSelector">A CSS or XPath selector identifying the iframe element.</param>
2080 public async Task SwitchToFrame(string frameSelector)
2081 {
2082 // a remote object belongs to the context it came from, so a scope set before this one would point
2083 // into the page we are leaving and answer nothing, in a way that reads as a bad selector
2084 CurrentRootObjectId = null;
2085
2086
2087 var elements = await EvaluateSelector(frameSelector, isRecursive: true).ConfigureAwait(false);
2088 if (elements.Count == 0)
2089 {
2090 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Frame not found: " + frameSelector, this, GPALObjectType.PuppeteerCommunicator);
2091 return;
2092 }
2093
2094 var iframeElement = elements[0];
2095
2096 var describe = await SendCommand<dynamic>(DevToolsMethods.DOMDescribeNode, new
2097 {
2098 objectId = iframeElement.ElementHandle,
2099 pierce = true
2100 }, GetEffectiveSessionId()).ConfigureAwait(false);
2101
2102 string frameId = describe?.node?.frameId?.ToString();
2103
2104 if (string.IsNullOrEmpty(frameId))
2105 {
2106 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "No frameId for frame [" + frameSelector + "]", this, GPALObjectType.PuppeteerCommunicator);
2107 return;
2108 }
2109
2110 CurrentFrameId = frameId;
2111
2112 // drop any session left over from a previously resolved iframe - the attach below is allowed to fail
2113 // silently for same-origin frames, and without this a stale cross-origin session would be reused
2114 CurrentFrameSessionId = null;
2115
2116 // Try to attach to frame target (works for cross-origin OOPIFs)
2117 try
2118 {
2119 var attach = await SendCommand<dynamic>(DevToolsMethods.TargetAttachToTarget, new
2120 {
2121 targetId = frameId,
2122 flatten = true
2123 }, null).ConfigureAwait(false);
2124
2125 string frameSessionId = attach?.sessionId?.ToString();
2126 if (!string.IsNullOrEmpty(frameSessionId))
2127 CurrentFrameSessionId = frameSessionId;
2128 }
2129 catch
2130 {
2131 // Normal for same-origin iframes - ignore
2132 }
2133
2134 // Always create isolated world (works for both same-origin and cross-origin)
2135 await Task.Delay(300).ConfigureAwait(false);
2136
2137 var isolated = await SendCommand<dynamic>(DevToolsMethods.PageCreateIsolatedWorld, new
2138 {
2139 frameId = frameId,
2140 worldName = "gpal_isolated_" + Guid.NewGuid().ToString("N"),
2141 grantUniversalAccess = false
2142 // a cross-origin frame lives in its own target, so the world has to be created on that target's
2143 // session - the page session does not know the frame at all. same-origin falls back to the page.
2144 }, GetEffectiveSessionId()).ConfigureAwait(false);
2145
2146 CurrentContextId = isolated?.executionContextId?.ToObject<long?>();
2147
2148 if (CurrentContextId.HasValue)
2149 {
2150 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
2151 "Resolved iframe [" + frameSelector + "] | FrameId: " + frameId + " | ContextId: " + CurrentContextId.Value,
2152 this, GPALObjectType.PuppeteerCommunicator);
2153 }
2154 else
2155 {
2156 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Failed to create isolated world for " + frameSelector, this, GPALObjectType.PuppeteerCommunicator);
2157 }
2158 }
2159
2168 /// the element: there is no current element in CDP, so the scope is ours to keep.
2169 /// <br/><br/>
2170 /// This is not how a frame is entered. A frame needs its own execution context, which
2171 /// <see cref="SwitchToFrame"/> attaches to, and an iframe from another origin cannot be reached as a
2172 /// node at all.
2173 /// </summary>
2174 /// <param name="elementSelector">A CSS or XPath selector identifying the element to search inside.</param>
2175 public async Task SwitchToElement(string elementSelector)
2176 {
2177 var found = await EvaluateSelector(elementSelector, isRecursive: true).ConfigureAwait(false);
2178
2179 if (0 == found.Count)
2180 {
2181 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Element to search inside not found: " + elementSelector, this, GPALObjectType.PuppeteerCommunicator);
2182 return;
2183 }
2184
2185 // the element's own remote object, which is what querySelector is called on. a shadow root takes one
2186 // more hop to reach, and that is the whole difference between this and SwitchToShadowDom
2187 CurrentRootObjectId = found[0].ElementHandle;
2188
2189 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
2190 "Searching inside [" + elementSelector + "] | ObjectId: " + CurrentRootObjectId,
2191 this, GPALObjectType.PuppeteerCommunicator);
2192 }
2193
2194 public async Task SwitchToShadowDom(string shadowHostSelector)
2195 {
2196 var hosts = await EvaluateSelector(shadowHostSelector, isRecursive: true).ConfigureAwait(false);
2197 if (hosts.Count == 0)
2198 {
2199 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Shadow host not found: " + shadowHostSelector, this, GPALObjectType.PuppeteerCommunicator);
2200 return;
2201 }
2202
2203 var host = hosts[0];
2204 var s = GetEffectiveSessionId();
2205
2206 var describe = await SendCommand<dynamic>(DevToolsMethods.DOMDescribeNode, new
2207 {
2208 objectId = host.ElementHandle,
2209 pierce = true,
2210 depth = 1
2211 }, s).ConfigureAwait(false);
2212
2213 var shadowRoots = describe?.node?.shadowRoots as IEnumerable<dynamic>;
2214
2215 if (shadowRoots?.Any() != true)
2216 {
2217 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "No shadow root on host: " + shadowHostSelector, this, GPALObjectType.PuppeteerCommunicator);
2218 return;
2219 }
2220
2221 var shadowRoot = shadowRoots.First();
2222
2223 if (shadowRoot.backendNodeId != null)
2224 {
2225 var resolve = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, new
2226 {
2227 backendNodeId = (long)shadowRoot.backendNodeId
2228 }, s).ConfigureAwait(false);
2229
2230 if (resolve?.@object?.objectId != null)
2231 {
2232 CurrentRootObjectId = (string)resolve.@object.objectId;
2233 }
2234 }
2235
2236 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
2237 "Resolved shadow DOM [" + shadowHostSelector + "] | ObjectId: " + CurrentRootObjectId,
2238 this, GPALObjectType.PuppeteerCommunicator);
2239 }
2240
2245 /// (via <see cref="SwitchToFrame"/>). If nothing is found and the call is not recursive, it performs
2246 /// a deep search by recursively evaluating the selector inside any iframe targets.
2247 /// </summary>
2248 /// <param name="selector">A CSS selector, XPath expression, numeric node id, or CDP remote object id.</param>
2249 /// <param name="sessionId">CDP session id; the effective session (or current session, if inside an isolated iframe context) is used if null.</param>
2250 /// <param name="isRecursive">True when this call is a recursive deep search inside an iframe; suppresses the outer iframe search and the evaluation semaphore.</param>
2251 /// <returns>Task that resolves to the list of matching elements, or an empty list if none were found or an error occurred.</returns>
2252 public async Task<List<GPALElement>> EvaluateSelector(string selector, string sessionId = null, bool isRecursive = false)
2253 {
2254 var elements = new List<GPALElement>();
2255
2256 // an explicit session (a recursive deep search) wins, otherwise use the effective one: the isolated
2257 // world is created on whichever target owns the frame, so its context id is only meaningful on that
2258 // same session. cross-origin frames have their own, same-origin frames fall back to the page session.
2259 string effectiveSessionId = (!string.IsNullOrEmpty(sessionId) && sessionId != GetCurrentSessionId())
2260 ? sessionId
2262
2263 if (false == isRecursive)
2264 await _evaluateSelectorSemaphore.WaitAsync().ConfigureAwait(false);
2265
2266 try
2267 {
2268 var nodes = new List<Node>();
2269
2270 bool inRootContext = !string.IsNullOrEmpty(CurrentRootObjectId);
2271
2272 // When we have an isolated world (from ResolveIframe), use MAIN session for runtime commands
2273 // but keep effectiveSessionId for DOM commands when finding child iframes
2274 bool useIsolatedContext = CurrentContextId.HasValue;
2275
2276 if (inRootContext)
2277 {
2278 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
2279 "Evaluating inside the scope set by InShadowDom or InElement, for selector [" + selector + "]",
2280 this, GPALObjectType.PuppeteerCommunicator);
2281 }
2282
2283 try
2284 {
2285 // NodeId / ObjectId handling
2286 bool isNodeId = int.TryParse(selector, out int tmpNodeId);
2287 bool isObjectId = System.Text.RegularExpressions.Regex.IsMatch(selector, @"^-?\d+\.\d+\.\d+$");
2288
2289 // Enable pierce so shadow roots (and iframes) are visible in the DOM tree
2290 await SendCommand<object>(DevToolsMethods.DOMGetDocument, new
2291 {
2292 pierce = true,
2293 depth = 20
2294 }, effectiveSessionId).ConfigureAwait(false);
2295
2296 // === SCOPED QUERY (when .InShadowDom() or .InElement() is active) ===
2297 if (inRootContext && !isNodeId && !isObjectId)
2298 {
2299 try
2300 {
2301 var shadowQueryResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeCallFunctionOn, new
2302 {
2303 objectId = CurrentRootObjectId,
2304 functionDeclaration = @"
2305 function(selector) {
2306 let nodes = [];
2307 try {
2308 const cssNodes = this.querySelectorAll(selector);
2309 if (cssNodes?.length > 0) {
2310 nodes = Array.from(cssNodes);
2311 }
2312 } catch (e) {}
2313
2314 if (nodes.length === 0) {
2315 try {
2316 const result = document.evaluate(selector, this, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
2317 for (let i = 0; i < result.snapshotLength; i++) {
2318 const node = result.snapshotItem(i);
2319 if (node?.getBoundingClientRect) {
2320 node.getBoundingClientRect();
2321 nodes.push(node);
2322 }
2323 }
2324 } catch (e) {}
2325 }
2326 return { nodes: nodes };
2327 }",
2328 arguments = new[] { new { value = selector } },
2329 contextId = CurrentContextId,
2330 returnByValue = false
2331 }, effectiveSessionId).ConfigureAwait(false);
2332
2333 if (shadowQueryResult?.result?.objectId != null)
2334 {
2335 // Get wrapper { nodes: [...] }
2336 var wrapper = await SendCommand<dynamic>(DevToolsMethods.RuntimeGetProperties,
2337 new {
2338 objectId = (string)shadowQueryResult.result.objectId,
2339 contextId = CurrentContextId,
2340 ownProperties = true
2341 }, effectiveSessionId).ConfigureAwait(false);
2342
2343 string nodesArrayId = null;
2344
2345 // Find "nodes" property without lambda
2346 if (wrapper?.result != null)
2347 {
2348 foreach (var p in wrapper.result)
2349 {
2350 if (p.name == "nodes" && p.value?.objectId != null)
2351 {
2352 nodesArrayId = (string)p.value.objectId;
2353 break;
2354 }
2355 }
2356 }
2357
2358 if (!string.IsNullOrEmpty(nodesArrayId))
2359 {
2360 // Get array items
2361 var arrayProps = await SendCommand<dynamic>(DevToolsMethods.RuntimeGetProperties,
2362 new {
2363 objectId = nodesArrayId,
2364 contextId = CurrentContextId,
2365 ownProperties = true
2366 }, effectiveSessionId).ConfigureAwait(false);
2367
2368 nodes.Clear();
2369
2370 if (arrayProps?.result != null)
2371 {
2372 foreach (var item in arrayProps.result)
2373 {
2374 var val = item.value;
2375 if (val != null
2376 && val.type == "object"
2377 && val.subtype == "node"
2378 && val.objectId != null)
2379 {
2380 nodes.Add(new Node { ObjectId = (string)val.objectId });
2381 }
2382 }
2383 }
2384 }
2385
2386 if (nodes.Count > 0)
2387 goto ProcessFoundNodes;
2388 }
2389 }
2390 catch (Exception ex)
2391 {
2392 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
2393 $"Shadow DOM query failed for [{selector}]. Falling back to main path.",
2394 this, GPALObjectType.PuppeteerCommunicator, ex);
2395 }
2396 }
2397
2398 if (isNodeId || isObjectId)
2399 {
2400 var requestNodeResult = await SendCommand<object>(DevToolsMethods.DOMRequestNode, new { objectId = selector }, effectiveSessionId).ConfigureAwait(false);
2401 if (requestNodeResult?.nodeId != null)
2402 {
2403 var nodeId = (int)requestNodeResult.nodeId;
2404 var resolveNodeResult = await SendCommand<object>(DevToolsMethods.DOMResolveNode, new { nodeId }, effectiveSessionId).ConfigureAwait(false);
2405 if (resolveNodeResult?.@object?.objectId != null)
2406 {
2407 var describeNode = await SendCommand<object>(DevToolsMethods.DOMDescribeNode, new { objectId = (string)resolveNodeResult.@object.objectId, pierce = true }, effectiveSessionId).ConfigureAwait(false);
2408 nodes.Add(new Node
2409 {
2410 ObjectId = (string)resolveNodeResult.@object.objectId,
2411 BackendNodeId = describeNode?.node?.backendNodeId ?? 0,
2412 NodeId = nodeId
2413 });
2414 }
2415 }
2416 }
2417 else
2418 {
2419 // === YOUR ORIGINAL JS EVALUATION PATH (100% unchanged) ===
2420 string jsFunc = @"
2421 function evaluateSelector(selector) {
2422 let nodes = [];
2423 let type = 'unknown';
2424 let error = null;
2425
2426 // Try CSS first
2427 try {
2428 const cssNodes = document.querySelectorAll(selector);
2429 if (cssNodes.length > 0) {
2430 nodes = Array.from(cssNodes);
2431 type = 'css';
2432 }
2433 } catch (e) {}
2434
2435 // If CSS failed, try XPath
2436 if (nodes.length === 0) {
2437 try {
2438 const result = document.evaluate(
2439 selector,
2440 document,
2441 null,
2442 XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
2443 null
2444 );
2445
2446 // Force CDP to wrap each node by accessing a layout property
2447 for (let i = 0; i < result.snapshotLength; i++) {
2448 const node = result.snapshotItem(i);
2449 if (node && node.getBoundingClientRect) {
2450 // This forces CDP to create a RemoteObject with objectId
2451 node.getBoundingClientRect();
2452 nodes.push(node);
2453 }
2454 }
2455
2456 if (nodes.length > 0) {
2457 type = 'xpath';
2458 } else {
2459 error = 'No elements found for selector [' + selector + ']';
2460 }
2461 } catch (e) {
2462 error = 'Invalid XPath or error: ' + e.message;
2463 }
2464 }
2465
2466 return { nodes: nodes };
2467 }";
2468
2469 string escapedSelector = selector;
2470 var evalResult = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
2471 {
2472 expression = $"({jsFunc})(\"{escapedSelector}\")",
2473 contextId = CurrentContextId,
2474 returnByValue = false
2475 }, effectiveSessionId).ConfigureAwait(false);
2476
2477 if (evalResult?.result?.objectId != null)
2478 {
2479 var nodesProps = await SendCommand<object>(DevToolsMethods.RuntimeGetProperties,
2480 new {
2481 objectId = (string)evalResult.result.objectId,
2482 contextId = CurrentContextId,
2483 ownProperties = true
2484 }, effectiveSessionId).ConfigureAwait(false);
2485 dynamic errorProp = null;
2486
2487 if (nodesProps?.result != null)
2488 {
2489 foreach (var p in nodesProps.result)
2490 {
2491 if (p.name == "error" && p.value != null && p.value.value != null)
2492 {
2493 errorProp = p;
2494 break;
2495 }
2496 }
2497 }
2498
2499 if (errorProp != null)
2500 {
2501 string errMsg = (string)errorProp.value.value ?? "Unknown selector error";
2502 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{errMsg}]", this, GPALObjectType.PuppeteerCommunicator);
2503 if (false == isRecursive)
2504 return new List<GPALElement>();
2505 }
2506
2507 foreach (var prop in nodesProps.result)
2508 {
2509 if (prop.name == "nodes" && prop.value?.objectId != null)
2510 {
2511 var nodeArray = await SendCommand<object>(DevToolsMethods.RuntimeGetProperties,
2512 new {
2513 objectId = (string)prop.value.objectId,
2514 contextId = CurrentContextId,
2515 ownProperties = true
2516 }, effectiveSessionId).ConfigureAwait(false);
2517 foreach (var np in nodeArray.result)
2518 {
2519 if (np.value != null && np.value.type == "object" && np.value.subtype == "node")
2520 nodes.Add(new Node { ObjectId = (string)np.value.objectId });
2521 }
2522 }
2523 }
2524 }
2525 }
2526
2527 ProcessFoundNodes:
2528
2529 // Sequential per-node extraction (your original logic - completely untouched)
2530 foreach (var node in nodes)
2531 {
2532 try
2533 {
2534 var attributes = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
2535
2536 var describeNode = await SendCommand<object>(DevToolsMethods.DOMDescribeNode, new { objectId = node.ObjectId, pierce = true }, effectiveSessionId).ConfigureAwait(false);
2537 if (describeNode?.node != null)
2538 {
2539 node.BackendNodeId = describeNode.node.backendNodeId ?? 0;
2540 var requestNodeResult = await SendCommand<object>(DevToolsMethods.DOMRequestNode, new
2541 {
2542 objectId = node.ObjectId
2543 }, effectiveSessionId).ConfigureAwait(false);
2544
2545 node.NodeId = requestNodeResult.nodeId;
2546 attributes["tagName"] = describeNode.node.nodeName;
2547 }
2548
2549 var jsProps = await SendCommand<object>(DevToolsMethods.RuntimeCallFunctionOn, new
2550 {
2551 functionDeclaration = @"
2552 function() {
2553 const rect = this.getBoundingClientRect();
2554 const cleanText = (str) => (str || '').replace(/\s+/g, ' ').trim();
2555
2556 const attrs = {
2557 enabled: !this.disabled,
2558 selected: this.selected || false,
2559 displayed: this.offsetParent !== null,
2560 href: this.href || '',
2561 src: this.src || '',
2562
2563 value: cleanText(this.value),
2564 text: cleanText(this.text || this.innerText),
2565 placeholder: cleanText(this.placeholder),
2566
2567 index: this.index || '',
2568 length: this.files ? this.files.length : this.length || '',
2569 type: this.type || '',
2570 id: this.id || '',
2571 };
2572 for (let {name,value} of Array.from(this.attributes||[])) attrs[name]=value;
2573
2574 // Force boolean properties last - multiple not getting set above, being dropped by cdp?
2575 attrs.multiple = this.multiple === true;
2576
2577 function getCss(el){
2578 if(!(el instanceof Element)) return '';
2579 const parts=[];
2580 while(el && el.nodeType===1){
2581 let part=el.nodeName.toLowerCase();
2582 if(el.id){part+='#'+el.id; parts.unshift(part); break;}
2583 let sib=el, nth=1;
2584 while((sib=sib.previousElementSibling)!=null){if(sib.nodeName.toLowerCase()===part)nth++;}
2585 if(nth>1) part+=':nth-of-type('+nth+')';
2586 parts.unshift(part); el=el.parentElement;
2587 }
2588 return parts.join(' > ');
2589 }
2590 return {attributes:attrs,css:getCss(this),boundingRect:{x:rect.x,y:rect.y,width:rect.width,height:rect.height,top:rect.top,left:rect.left,bottom:rect.bottom,right:rect.right}};
2591 }",
2592 objectId = node.ObjectId,
2593 contextId = CurrentContextId,
2594 returnByValue = true
2595 }, effectiveSessionId).ConfigureAwait(false);
2596
2597 if (jsProps?.result?.value != null)
2598 {
2599 var dict = ((Newtonsoft.Json.Linq.JObject)jsProps.result.value).ToObject<Dictionary<string, object>>();
2600
2601 if (dict.ContainsKey("attributes"))
2602 {
2603 var jsAttrDict = ((Newtonsoft.Json.Linq.JObject)dict["attributes"]).ToObject<Dictionary<string, object>>();
2604 foreach (var kvp in jsAttrDict) if (!attributes.ContainsKey(kvp.Key)) attributes[kvp.Key] = kvp.Value;
2605 }
2606
2607 if (dict.ContainsKey("boundingRect"))
2608 {
2609 var brObj = ((Newtonsoft.Json.Linq.JObject)dict["boundingRect"]).ToObject<Dictionary<string, object>>();
2610 float ToFloat(object o)
2611 {
2612 if (o is double d) return (float)d;
2613 if (o is long l) return (float)l;
2614 if (o is int i) return (float)i;
2615 if (o is string s && float.TryParse(s, out var r)) return r;
2616 return 0f;
2617 }
2618
2619 var rect = new ClientRectangle
2620 {
2621 X = ToFloat(brObj.ContainsKey("x") ? brObj["x"] : 0),
2622 Y = ToFloat(brObj.ContainsKey("y") ? brObj["y"] : 0),
2623 Width = ToFloat(brObj.ContainsKey("width") ? brObj["width"] : 0),
2624 Height = ToFloat(brObj.ContainsKey("height") ? brObj["height"] : 0),
2625 Top = ToFloat(brObj.ContainsKey("top") ? brObj["top"] : 0),
2626 Left = ToFloat(brObj.ContainsKey("left") ? brObj["left"] : 0),
2627 Bottom = ToFloat(brObj.ContainsKey("bottom") ? brObj["bottom"] : 0),
2628 Right = ToFloat(brObj.ContainsKey("right") ? brObj["right"] : 0)
2629 };
2630
2631 attributes["boundingRect"] = rect;
2632 attributes["location"] = new System.Drawing.Point((int)rect.X, (int)rect.Y);
2633 attributes["size"] = new System.Drawing.Size((int)rect.Width, (int)rect.Height);
2634 }
2635
2636 if (dict.ContainsKey("css")) attributes["css"] = dict["css"]?.ToString();
2637 }
2638
2639 attributes["xpath"] = await GetXPathForElement(node.BackendNodeId).ConfigureAwait(false);
2640
2641 elements.Add(new GPALElement(attributes, Browser)
2642 {
2643 ElementHandle = node.ObjectId,
2644 ElementBackendNodeId = node.BackendNodeId,
2645 ElementNodeId = node.NodeId
2646 });
2647 }
2648 catch
2649 {
2650 // Skip failed nodes
2651 }
2652 }
2653
2654 // Recurse into iframes if still nothing found (questionable if this every found anything - fix the selector)
2655 //if (!elements.Any() && false == isRecursive)
2656 //{
2657 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Element STILL not found, performing deep search in iframes.", this, GPALObjectType.PuppeteerCommunicator);
2658
2659 // var targets = await SendCommand<object>(DevToolsMethods.TargetGetTargets, new { }, null).ConfigureAwait(false);
2660 // var iframeTargets = ((IEnumerable<dynamic>)targets.targetInfos).Where(t => t.type == "iframe");
2661 // foreach (var target in iframeTargets)
2662 // {
2663 // var frameSession = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new { targetId = (string)target.targetId, flatten = true }, null).ConfigureAwait(false);
2664 // var frameSessionId = (string)frameSession.sessionId;
2665
2666 // var frameElements = await EvaluateSelector(selector, frameSessionId, true).ConfigureAwait(false);
2667 // if (frameElements.Any())
2668 // return frameElements;
2669 // }
2670 //}
2671
2672 return elements;
2673 }
2674 catch
2675 {
2676 return new List<GPALElement>();
2677 }
2678 }
2679 finally
2680 {
2681 if (false == isRecursive)
2682 _evaluateSelectorSemaphore.Release();
2683 }
2684 }
2685
2686 // generate selectors from backendnodeid
2687 /// <summary>
2688 /// Generates a CSS selector string that uniquely identifies the element with the given backend node id.
2689 /// </summary>
2690 /// <param name="backendNodeId">The CDP backend node id of the element.</param>
2691 /// <param name="optimized">Whether to prefer a shorter, optimized selector (e.g. using the element's id) when possible.</param>
2692 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
2693 /// <returns>Task that resolves to the generated CSS selector, or an empty string if it could not be determined.</returns>
2694 public async Task<string> GetCssForElement(int backendNodeId, bool optimized = false, string sessionId = null)
2695 {
2696 sessionId ??= GetEffectiveSessionId();
2697 string jsFunc = @"
2698 function getCssSelector(node) {
2699// console.log('getCssSelector called with node:', node);
2700
2701 if (!node || node.nodeType !== Node.ELEMENT_NODE) {
2702 console.error('Invalid node, returning empty string');
2703 return '';
2704 }
2705
2706 if (node.id) {
2707// console.log('Found ID:', node.id);
2708 return '#' + node.id.replace(/([ #.;+*~'!^$[\‍]()=>|\/@])/g, '\\$1');
2709 }
2710
2711 let selector = node.localName.toLowerCase();
2712// console.log('Tag name:', selector);
2713
2714 if (node.classList && node.classList.length > 0) {
2715 const classes = Array.from(node.classList)
2716 .filter(c => c && /^[a-zA-Z][\w-]*$/.test(c))
2717 .map(c => c.replace(/([ #.;+*~'!^$[\‍]()=>|\/@])/g, '\\$1'));
2718 if (classes.length > 0) {
2719 selector += '.' + classes.join('.');
2720 }
2721 // console.log('Selector with classes:', selector);
2722 }
2723
2724 try {
2725 const matches = document.querySelectorAll(selector);
2726 // console.log('Matches for selector:', matches.length, selector);
2727 if (matches.length === 1 && matches[0] === node) {
2728 return selector;
2729 }
2730 } catch (e) {
2731 // console.error('Error in querySelectorAll:', e.message);
2732 return '';
2733 }
2734
2735 // console.log('Returning fallback selector:', selector);
2736 return selector;
2737 }";
2738 return await GetXPathOrCssForElement(backendNodeId, jsFunc, optimized, sessionId).ConfigureAwait(false);
2739 }
2740 /// <summary>
2741 /// Generates an XPath expression that identifies the element with the given backend node id.
2742 /// </summary>
2743 /// <param name="backendNodeId">The CDP backend node id of the element.</param>
2744 /// <param name="optimized">Whether to prefer a shorter, optimized path (e.g. using the element's id) when possible.</param>
2745 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
2746 /// <returns>Task that resolves to the generated XPath expression, or an empty string if it could not be determined.</returns>
2747 public async Task<string> GetXPathForElement(int backendNodeId, bool optimized = true, string sessionId = null)
2748 {
2749 sessionId ??= GetEffectiveSessionId();
2750 // Your full JS template as a string
2751 string jsFunc = @"
2752 function getXPathForElement(node, optimized) {
2753 let Elements = { DOMPath: {} };
2754
2755 Elements.DOMPath.xPath = function(node, optimized) {
2756 if (node.nodeType === Node.DOCUMENT_NODE) return '/';
2757 const steps = [];
2758 let contextNode = node;
2759 while (contextNode) {
2760 const step = Elements.DOMPath._xPathValue(contextNode, optimized);
2761 if (!step) break;
2762 steps.push(step);
2763 if (step.optimized) break;
2764 contextNode = contextNode.parentNode;
2765 }
2766 steps.reverse();
2767 return (steps.length && steps[0].optimized ? '' : '/') + steps.map(step => step.value).join('/');
2768 };
2769
2770 Elements.DOMPath._xPathValue = function(node, optimized) {
2771 let ownValue;
2772 const ownIndex = Elements.DOMPath._xPathIndex(node);
2773 if (ownIndex === -1) return null;
2774 switch (node.nodeType) {
2775 case Node.ELEMENT_NODE:
2776 if (optimized && node.getAttribute('id'))
2777 return new Elements.DOMPath.Step('//*[@id=\'' + node.getAttribute('id') + '\']', true);
2778 ownValue = node.localName;
2779 break;
2780 case Node.ATTRIBUTE_NODE:
2781 ownValue = '@' + node.nodeName;
2782 break;
2783 case Node.TEXT_NODE:
2784 case Node.CDATA_SECTION_NODE:
2785 ownValue = 'text()';
2786 break;
2787 case Node.PROCESSING_INSTRUCTION_NODE:
2788 ownValue = 'processing-instruction()';
2789 break;
2790 case Node.COMMENT_NODE:
2791 ownValue = 'comment()';
2792 break;
2793 case Node.DOCUMENT_NODE:
2794 ownValue = '';
2795 break;
2796 default:
2797 ownValue = '';
2798 break;
2799 }
2800 if (ownIndex > 0)
2801 ownValue += '[' + ownIndex + ']';
2802 return new Elements.DOMPath.Step(ownValue, node.nodeType === Node.DOCUMENT_NODE);
2803 };
2804
2805 Elements.DOMPath._xPathIndex = function(node) {
2806 function areNodesSimilar(left, right) {
2807 if (left === right) return true;
2808 if (left.nodeType === Node.ELEMENT_NODE && right.nodeType === Node.ELEMENT_NODE)
2809 return left.localName === right.localName;
2810 if (left.nodeType === right.nodeType) return true;
2811 const leftType = left.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : left.nodeType;
2812 const rightType = right.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : right.nodeType;
2813 return leftType === rightType;
2814 }
2815 const siblings = node.parentNode ? node.parentNode.children : null;
2816 if (!siblings) return 0;
2817 let hasSameNamedElements;
2818 for (let i = 0; i < siblings.length; ++i) {
2819 if (areNodesSimilar(node, siblings[i]) && siblings[i] !== node) {
2820 hasSameNamedElements = true;
2821 break;
2822 }
2823 }
2824 if (!hasSameNamedElements) return 0;
2825 let ownIndex = 1;
2826 for (let i = 0; i < siblings.length; ++i) {
2827 if (areNodesSimilar(node, siblings[i])) {
2828 if (siblings[i] === node) return ownIndex;
2829 ++ownIndex;
2830 }
2831 }
2832 return -1;
2833 };
2834
2835 Elements.DOMPath.Step = function(value, optimized) {
2836 this.value = value;
2837 this.optimized = optimized || false;
2838 };
2839
2840 return Elements.DOMPath.xPath(node, optimized);
2841 }";
2842 return await GetXPathOrCssForElement(backendNodeId, jsFunc, optimized, sessionId);
2843 }
2847 /// function on it to compute a selector or path string.
2848 /// </summary>
2849 /// <param name="backendNodeId">The CDP backend node id of the element to resolve.</param>
2850 /// <param name="jsFunc">A JavaScript function declaration that takes (node, optimized) and returns the selector/path string.</param>
2851 /// <param name="optimized">Whether the JavaScript function should prefer a shorter, optimized result.</param>
2852 /// <param name="sessionId">CDP session id to use for the resolve and evaluate commands.</param>
2853 /// <returns>Task that resolves to the computed selector/path string, or an empty string on failure.</returns>
2854 public async Task<string> GetXPathOrCssForElement(int backendNodeId, string jsFunc, bool optimized, string sessionId)
2855 {
2856 try
2857 {
2858 // Resolve to objectId using backendNodeId
2859 var resolve = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, new { backendNodeId, pierce = true }, sessionId).ConfigureAwait(false);
2860 string objectId = resolve.@object.objectId;
2861
2862 // Call function
2863 var result = await SendCommand<dynamic>(DevToolsMethods.RuntimeCallFunctionOn, new
2864 {
2865 objectId,
2866 functionDeclaration = $"function(optimized) {{ return ({jsFunc})(this, optimized); }}",
2867 arguments = new object[] { new { value = optimized } },
2868 contextId = CurrentContextId,
2869 returnByValue = true
2870 }, sessionId).ConfigureAwait(false);
2871
2872 // Release objectId to prevent leak
2873 //await SendCommand<object>(DevToolsMethods.RuntimeReleaseObject, new { objectId }, sessionId);
2874
2875 return result?.result?.value?.ToString() ?? "";
2876 }
2877 catch (Exception ex)
2878 {
2879 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
2880 $"Failed to get XPath for backendNodeId [{backendNodeId}]",
2881 this, GPALObjectType.PuppeteerCommunicator, ex);
2882 return "";
2883 }
2884 }
2885
2886 // Helper method to get attributes
2887 static Dictionary<string, string> GetAttributesAsDictionary(string jsonString)
2888 {
2889 try
2890 {
2891 using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(jsonString);
2892
2893 // Navigate to the 'node' object
2894 if (!doc.RootElement.TryGetProperty("node", out System.Text.Json.JsonElement nodeElement))
2895 {
2896 return null;
2897 }
2898
2899 // Navigate to the 'attributes' array
2900 if (!nodeElement.TryGetProperty("attributes", out System.Text.Json.JsonElement attributesElement) ||
2901 attributesElement.ValueKind != System.Text.Json.JsonValueKind.Array)
2902 {
2903 return null;
2904 }
2905
2906 var dict = new Dictionary<string, string>();
2907 for (int i = 0; i < attributesElement.GetArrayLength() - 1; i += 2)
2908 {
2909 string key = attributesElement[i].GetString();
2910 string value = attributesElement[i + 1].GetString();
2911 dict[key] = value;
2912 }
2913
2914 return dict;
2915 }
2916 catch (System.Text.Json.JsonException)
2917 {
2918 return null; // Invalid JSON
2920 }
2926 public async Task LeftClickByCss(string selector, string sessionId = null)
2927 {
2928 List<GPALElement> elems = await EvaluateSelector(selector, sessionId).ConfigureAwait(false);
2929 List<dynamic> responses = new List<dynamic>();
2930
2931 foreach (GPALElement elem in elems)
2932 await ClickElement(elem, sessionId, responses, ClickType.LeftClick).ConfigureAwait(false);
2933 }
2941 public async Task<dynamic> ExecuteJavaScript(string expression, string sessionId = null)
2942 {
2943 sessionId ??= GetEffectiveSessionId();
2944 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
2945 new {
2946 expression,
2947 contextId = CurrentContextId,
2948 returnByValue = true,
2949 awaitPromise = true
2950 }
2951 , sessionId).ConfigureAwait(false);
2952 }
2953
2965 internal string LastNavigationError { get; set; }
2966
2981 /// Starts or stops recording what the browser asks for, reported over the DevTools protocol.
2982 /// Every session GPAL knows about is told, not only the active tab, because a click that opens a tab
2983 /// is exactly the request worth seeing.
2984 /// <example>await browser.PuppeteerCommunicator.CaptureCalls(true);</example>
2985 /// </summary>
2986 /// <param name="capture">True to record, false to stop</param>
2987 /// <param name="sessionId">The session to tell, or null for every session known</param>
2988 public async Task CaptureCalls(bool capture, string sessionId = null)
2989 {
2990 CapturingCalls = capture;
2991
2992 // nothing is reported until the Network domain is on, and it is not on at startup. it is on per
2993 // session rather than per browser, so every tab GPAL knows about is told, not just the active one:
2994 // a click that opens a tab is exactly the request worth seeing. sending it twice to one session is
2995 // harmless, so nothing has to work out which of these it already told
2996 if (true == capture)
2997 foreach (string session in KnownSessions(sessionId))
2998 await SendCommand<object>(DevToolsMethods.NetworkEnable, new { }, session).ConfigureAwait(false);
2999 }
3000
3001 // the session handed in, the effective one, and every tab of every window, without repeats
3002 private List<string> KnownSessions(string sessionId)
3003 {
3004 List<string> retVal = new List<string>();
3005
3006 foreach (string session in new[] { sessionId, GetEffectiveSessionId() })
3007 if (false == string.IsNullOrEmpty(session) && false == retVal.Contains(session))
3008 retVal.Add(session);
3009
3010 foreach (WindowSession window in _windowSessionsQueue.ToArray())
3011 foreach (KeyValuePair<string, string> tab in window.TabQueue.ToArray())
3012 if (false == string.IsNullOrEmpty(tab.Value) && false == retVal.Contains(tab.Value))
3013 retVal.Add(tab.Value);
3014
3015 return retVal;
3016 }
3017
3018 // the status the site answered this request with, matched back to it by the browser's own request id
3019 private void RecordStatus(dynamic paramsData)
3020 {
3021 try
3022 {
3023 string requestId = paramsData?.requestId?.ToString();
3024
3025 if (false == string.IsNullOrEmpty(requestId) && true == _capturedById.TryGetValue(requestId, out GPALCall call))
3026 call.Status = (int)(paramsData?.response?.status ?? 0);
3027 }
3028 catch (Exception ex)
3029 {
3030 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Could not record a response status", this, GPALObjectType.PuppeteerCommunicator, ex);
3031 }
3032 }
3033
3034 // one line of the network tab, kept as the fields a workflow would act on rather than the whole event
3035 private void RecordCall(dynamic paramsData)
3036 {
3037 try
3038 {
3039 GPALCall call = new GPALCall
3040 {
3041 Url = paramsData?.request?.url?.ToString(),
3042 Method = paramsData?.request?.method?.ToString(),
3043 ResourceType = paramsData?.type?.ToString(),
3044 PostData = paramsData?.request?.postData?.ToString(),
3045 Initiator = paramsData?.initiator?.type?.ToString()
3046 };
3047
3048 // what the page's own code put on the request, which is the half a hand-built call is missing
3049 if (paramsData?.request?.headers is Newtonsoft.Json.Linq.JObject headers)
3050 foreach (KeyValuePair<string, Newtonsoft.Json.Linq.JToken> header in headers)
3051 call.Headers[header.Key] = header.Value?.ToString();
3052
3053 // a filter narrows what is kept rather than what is asked for, so nothing is missed by a
3054 // declaration made after the fact, only unrecorded
3055 string filter = Browser?.BrowserSettings?.CallFilter;
3056
3057 if (false == string.IsNullOrEmpty(filter) && true != call.Url?.Contains(filter))
3058 return;
3059
3060 call.RequestId = paramsData?.requestId?.ToString();
3061
3062 if (false == string.IsNullOrEmpty(call.RequestId))
3063 _capturedById[call.RequestId] = call;
3064
3065 CapturedCalls.Add(call);
3066 }
3067 catch (Exception ex)
3068 {
3069 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Could not record a request", this, GPALObjectType.PuppeteerCommunicator, ex);
3070 }
3071 }
3072
3073 internal async Task SetExtraHttpHeaders(Dictionary<string, object> headers, string sessionId = null)
3074 {
3075 sessionId ??= GetEffectiveSessionId();
3076
3077 // the Network domain has to be on before it will accept this, and it is not enabled at startup. Without
3078 // it the command is accepted and quietly does nothing, which reads as the credentials being wrong
3079 await SendCommand<object>(DevToolsMethods.NetworkEnable, new { }, sessionId).ConfigureAwait(false);
3080 await SendCommand<object>(DevToolsMethods.NetworkSetExtraHTTPHeaders, new { headers }, sessionId).ConfigureAwait(false);
3081 }
3082
3083 public async Task<bool> NavigateToUrl(string url, string sessionId)
3084 {
3085 _attachedFilesPerSession.Clear();
3086 var result = await SendCommand<object>(DevToolsMethods.PageNavigate, new { url }, sessionId).ConfigureAwait(false);
3087
3088 // a navigation that fails still answers, and answers with a perfectly good result carrying errorText,
3089 // so the presence of a result says only that the command was accepted, never that a page loaded
3090 LastNavigationError = (result as Newtonsoft.Json.Linq.JObject)?["errorText"]?.ToString();
3091
3092 if (null == result)
3093 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to Go to [{url}]", this, GPALObjectType.PuppeteerCommunicator);
3094 else if (false == string.IsNullOrEmpty(LastNavigationError))
3095 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to Go to [{url}][{LastNavigationError}]", this, GPALObjectType.PuppeteerCommunicator);
3096 else
3097 //await WaitForEvent("Page.loadEventFired", TimeSpan.FromSeconds(30));
3098 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Go to [{url}]", this, GPALObjectType.PuppeteerCommunicator);
3099
3100 return null != result && true == string.IsNullOrEmpty(LastNavigationError);
3101 }
3102
3103 // Implementation for "back"
3107 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
3108 /// <returns>Task that resolves to the navigation result, or null if there is no previous history entry.</returns>
3109 /// <example>
3110 /// <code>
3111 /// await communicator.Back();
3112 /// </code>
3113 /// </example>
3114 public async Task<dynamic> Back(string sessionId = null)
3115 {
3116 sessionId ??= GetEffectiveSessionId();
3117 dynamic history = await SendCommand<object>(DevToolsMethods.PageGetNavigationHistory, new { }, sessionId).ConfigureAwait(false);
3118 if (history?.currentIndex != null)
3119 {
3120 var entries = (IEnumerable<dynamic>)history.entries;
3121 var nextEntry = entries.ElementAtOrDefault((int)history.currentIndex - 1);
3122 if (nextEntry?.id != null)
3123 {
3124 var result = await SendCommand(DevToolsMethods.PageNavigateToHistoryEntry, new { entryId = (int)nextEntry.id }, sessionId).ConfigureAwait(false);
3125 return result;
3126 }
3127 }
3128 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No previous page in history", this, GPALObjectType.PuppeteerClient);
3129 return null;
3130 }
3132 // Implementation for "capture-visible-tab"
3136
3138 public async Task<dynamic> CaptureVisibleTab(string sessionId = null)
3139 {
3140 sessionId ??= GetEffectiveSessionId();
3141 return await SendCommand<object>(DevToolsMethods.PageCaptureScreenshot, new { format = "png" }, sessionId).ConfigureAwait(false);
3142 }
3143
3144 // Implementation for CastDesktop
3149 /// <param name="sinkName">The display name of the Cast sink/device to mirror to.</param>
3150 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
3151 /// <example>
3152 /// <code>
3153 /// await communicator.CastDesktop("Living Room TV", sessionId);
3154 /// </code>
3155 /// </example>
3156 public async Task CastDesktop(string sinkName, string sessionId)
3157 {
3158 CastDevice sink = null;
3159 int retries = 10;
3160 sessionId ??= GetEffectiveSessionId();
3161
3162 await SendCommand<object>(DevToolsMethods.CastDisable, null, sessionId).ConfigureAwait(false);
3163
3164 while (null == sink && 0 < retries--)
3165 {
3166 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Searching for [{sinkName}].", this, GPALObjectType.PuppeteerCommunicator);
3167 await SendCommand<object>(DevToolsMethods.CastEnable, new { }, sessionId).ConfigureAwait(false);
3168
3169 await Task.Delay(3_000).ConfigureAwait(false);
3170
3171 sink = CastDevices.Find(device => device.name == sinkName);
3172 }
3173
3174 await Task.Delay(3_000).ConfigureAwait(false);
3175
3176 if (null != sink)
3177 {
3178 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{sinkName}] found.", this, GPALObjectType.PuppeteerCommunicator);
3179 SinkName = sinkName;
3180 object param = new { sinkName = sink.name };
3181
3182 // cast or javascript, not both
3183 await SendCommand<object>(DevToolsMethods.CastStartDesktopMirroring, param, sessionId).ConfigureAwait(false);
3184 }
3185 else
3186 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{sinkName}] NOT found.", this, GPALObjectType.PuppeteerCommunicator);
3187 }
3188
3189 // Implementation for CastTab
3194 /// <param name="sinkName">The display name of the Cast sink/device to mirror to.</param>
3195 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
3196 /// <example>
3197 /// <code>
3198 /// await communicator.CastTab("Living Room TV", sessionId);
3199 /// </code>
3200 /// </example>
3201 public async Task CastTab(string sinkName, string sessionId)
3202 {
3203 CastDevice sink = null;
3204 int retries = 10;
3205 sessionId ??= GetEffectiveSessionId();
3206
3207 await SendCommand<object>(DevToolsMethods.CastDisable, null, sessionId).ConfigureAwait(false);
3208
3209 while (null == sink && 0 < retries--)
3210 {
3211 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Searching for [{sinkName}].", this, GPALObjectType.PuppeteerCommunicator);
3212 await SendCommand<object>(DevToolsMethods.CastEnable, new { }, sessionId).ConfigureAwait(false);
3213
3214 await Task.Delay(3_000).ConfigureAwait(false);
3215
3216 sink = CastDevices.Find(device => device.name == sinkName);
3217 }
3218
3219 await Task.Delay(3_000).ConfigureAwait(false);
3220
3221 if (null != sink)
3222 {
3223 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{sinkName}] found.", this, GPALObjectType.PuppeteerCommunicator);
3224 SinkName = sinkName;
3225 object param = new { sinkName = sink.name };
3226
3227 // cast or javascript, not both
3228 await SendCommand<object>(DevToolsMethods.CastStartTabMirroring, param, sessionId).ConfigureAwait(false);
3229 }
3230 else
3231 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{sinkName}] NOT found.", this, GPALObjectType.PuppeteerCommunicator);
3232 }
3233
3234 // Implementation for "stop-casting"
3239 /// <param name="sessionId">CDP session id to send the stop-casting command on.</param>
3240 /// <returns>Task that resolves to true once the stop attempt has completed (regardless of whether casting actually stopped).</returns>
3241 /// <example>
3242 /// <code>
3243 /// await communicator.StopCasting(sessionId);
3244 /// </code>
3245 /// </example>
3246 public async Task<bool> StopCasting(string sessionId)
3247 {
3248 // NOTE: there may be no programmatic way to cleanly stop casting except to close the tab or window. This doesn't seem to work.
3249 try
3250 {
3251 await SendCommand<object>(DevToolsMethods.CastStopCasting, new { sinkName = SinkName }, sessionId).ConfigureAwait(false);
3252 }
3253 catch { }
3254
3255 Thread.Sleep(500);
3256
3257 return true;
3258 }
3259
3260 // Implementation for "check-network-idle"
3265 /// </summary>
3266 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
3267 /// <param name="maxConnections">Reserved for future use; currently not enforced.</param>
3268 /// <param name="timeoutMs">The maximum total time, in milliseconds, to wait for the network to become idle.</param>
3269 /// <param name="pruneMs">The duration of inactivity, in milliseconds, required to consider the network idle.</param>
3270 /// <param name="sessionToken">An opaque token used to deduplicate repeated status messages across calls.</param>
3271 /// <returns>Task that resolves to true if the network became idle within the timeout, otherwise false.</returns>
3272 public async Task<bool> CheckNetworkIdle(string sessionId = null, int maxConnections = 0, int timeoutMs = 30000, int pruneMs = 3000, string sessionToken = null)
3273 {
3274 bool retVal = false;
3275 GPALEventType consoleTypeSave = GPAL.GPALSettings.ConsoleEvents;
3276 GPALEventType debugTypeSave = GPAL.GPALSettings.DebugEvents;
3277 string currentErrorMessage = null;
3278
3279
3280 if (lastSessionToken != sessionToken)
3281 {
3282 lastErrorMessage.Clear();
3283 supressedMessage = false;
3284 lastSessionToken = sessionToken;
3285 }
3286
3287 currentErrorMessage = $"Waiting up to [{timeoutMs}] ms for network to idle to [{maxConnections}] connections for [{pruneMs}] ms";
3288
3289 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage, this, GPALObjectType.PuppeteerCommunicator);
3290
3291 try
3292 {
3293 _suppressNetworkEvents = true; // Suppress non-essential network events
3294 await SendCommand(DevToolsMethods.NetworkEnable, new { cacheDisabled = true }, sessionId).ConfigureAwait(false);
3295 var start = DateTime.UtcNow;
3296 DateTime? lastRequestTime = null;
3297 var pendingEvents = new List<(string Event, dynamic Data, DateTime Timestamp)>();
3298 int unmatchedCount = 0; // Track unmatched events to throttle logging
3299 bool isFirstIteration = true; // Flag for initial queue debugging
3300
3301 sessionId ??= GetEffectiveSessionId();
3302
3303 await Task.Delay(500).ConfigureAwait(false);
3304
3305 // Track network events
3306 while (DateTime.UtcNow - start < TimeSpan.FromMilliseconds(timeoutMs))
3307 {
3308 // Collect events with timestamps
3309 while (_events.TryDequeue(out (string Event, dynamic Data) evt))
3310 {
3311 pendingEvents.Add((evt.Event, evt.Data, DateTime.UtcNow));
3312 }
3313
3314 // Sort events by timestamp to process in order
3315 pendingEvents.Sort((a, b) => a.Timestamp.CompareTo(b.Timestamp));
3316
3317 // Process events
3318 foreach (var evt in pendingEvents)
3319 {
3320 string requestId = evt.Data?.requestId?.ToString() ?? "unknown";
3321 string url = evt.Data?.url?.ToString() ?? "unknown";
3322 string resourceType = evt.Data?.resourceType?.ToString() ?? "unknown";
3323 string initiator = evt.Data?.initiator?.type?.ToString() ?? "unknown";
3324 string frameId = evt.Data?.frameId?.ToString() ?? "unknown";
3325 string loaderId = evt.Data?.loaderId?.ToString() ?? "unknown";
3326 string dataJson = evt.Data != null ? Newtonsoft.Json.JsonConvert.SerializeObject(evt.Data) : "null";
3327
3328 if (evt.Event == "Network.requestWillBeSent")
3329 {
3330 lastRequestTime = DateTime.UtcNow;
3331 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"[{(isFirstIteration ? "Initial " : "")}] Request started: requestId=[{requestId}], url=[{url}], resourceType=[{resourceType}], initiator=[{initiator}], frameId=[{frameId}], loaderId=[{loaderId}], data=[{dataJson}]", this, GPALObjectType.PuppeteerCommunicator);
3332 }
3333 else if (evt.Event == "Network.requestWillBeSentExtraInfo")
3334 {
3335 string associatedRequestId = evt.Data?.associatedRequestId?.ToString();
3336 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"[{(isFirstIteration ? "Initial " : "")}] Redirected request: requestId=[{requestId}], associatedRequestId=[{associatedRequestId}], url=[{url}], resourceType=[{resourceType}], initiator=[{initiator}], frameId=[{frameId}], loaderId=[{loaderId}], data=[{dataJson}]", this, GPALObjectType.PuppeteerCommunicator);
3337 }
3338 else if (evt.Event == "Network.loadingFinished" || evt.Event == "Network.loadingFailed")
3339 {
3340 unmatchedCount++;
3341 if (unmatchedCount % 10 == 0) // Throttle logging
3342 {
3343 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"No matching request for [{evt.Event}]: requestId=[{requestId}], url=[{url}], resourceType=[{resourceType}], initiator=[{initiator},] frameId=[{frameId}], loaderId=[{loaderId}], data=[{dataJson}], unmatchedCount=[{unmatchedCount}]", this, GPALObjectType.PuppeteerCommunicator);
3344 }
3345 }
3346 else if (evt.Event == "Network.requestServedFromCache")
3347 {
3348 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"[{(isFirstIteration ? "Initial " : "")}] Cached request: requestId=[{requestId}], url=[{url}], resourceType=[{resourceType}], initiator=[{initiator}], frameId=[{frameId}], loaderId=[{loaderId}], data=[{dataJson}], activeConnections=0", this, GPALObjectType.PuppeteerCommunicator);
3349 }
3350 else if (isFirstIteration)
3351 {
3352 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"Discarded initial event: [{evt.Event}], requestId=[{requestId}], url=[{url}], resourceType=[{resourceType}], initiator=[{initiator}], frameId=[{frameId}], loaderId=[{loaderId}], data=[{dataJson}]", this, GPALObjectType.PuppeteerCommunicator);
3353 }
3354 }
3355 pendingEvents.Clear(); // Clear processed events
3356
3357 // Check for idle condition
3358 if (lastRequestTime == null || (DateTime.UtcNow - lastRequestTime >= TimeSpan.FromMilliseconds(pruneMs)))
3359 {
3360 retVal = true; // No new requests for pruneMs
3361 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Network idle achieved: no new requests for [{pruneMs}]ms", this, GPALObjectType.PuppeteerCommunicator);
3362 break;
3363 }
3364
3365 // Log initial state after first iteration
3366 if (isFirstIteration)
3367 {
3368 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Initial queue processed: unmatchedCount=[{unmatchedCount}]", this, GPALObjectType.PuppeteerCommunicator);
3369 isFirstIteration = false;
3370 }
3371
3372 await Task.Delay(50, _cts.Token).ConfigureAwait(false); // Reduced polling delay for responsiveness
3373 }
3374
3375 // Final debug log
3376 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"CheckNetworkIdle complete: unmatchedCount=[{unmatchedCount}], result=[{retVal}]", this, GPALObjectType.PuppeteerCommunicator);
3377 }
3378 catch (Exception ex)
3379 {
3380 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to check network idle", null, GPALObjectType.PuppeteerCommunicator, ex);
3381 }
3382 finally
3383 {
3384 // only turn the domain off if this method is what turned it on. Credentials presented as a header
3385 // live in the Network domain's state, so disabling here would quietly drop them and the next
3386 // request would come back a 401 with nothing in the log to explain it
3387 if (null == Browser?.BrowserSettings.CredentialsPresented)
3388 await SendCommand(DevToolsMethods.NetworkDisable, new { }, sessionId).ConfigureAwait(false);
3389
3390 _suppressNetworkEvents = false; // Re-enable event processing
3391
3392 currentErrorMessage = $"Network IS{(retVal ? "" : " NOT")} Idle. Status [{retVal}]";
3393
3394 if (false == lastErrorMessage.Contains(currentErrorMessage))
3395 {
3396 lastErrorMessage.Add(currentErrorMessage);
3397 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage, this, GPALObjectType.PuppeteerCommunicator);
3398 supressedMessage = false;
3399 }
3400 else if (false == supressedMessage)
3401 {
3402 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
3403 supressedMessage = true;
3404 }
3405 }
3406 return retVal;
3408
3409 // Implementation for "clear-referrer"
3412
3414 public async Task ClearReferrer(string sessionId = null)
3415 {
3416 sessionId ??= GetEffectiveSessionId();
3417 await SendCommand<object>(DevToolsMethods.NetworkSetExtraHTTPHeaders, new { headers = new { Referer = "" } }, sessionId).ConfigureAwait(false);
3418 }
3419
3425 private void CheckForLastBrowserWindowOpen(string reason)
3426 {
3427 if (_windowSessionsQueue.IsEmpty)
3428 {
3429 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{reason}] : Last window closed, **BE CAREFUL AND DO NOT** try to run browser commands until another browser is opened.", this, GPALObjectType.PuppeteerCommunicator);
3430 }
3431 }
3437 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
3438 /// <returns>Task that resolves to true if the tab (and possibly its now-empty window) was closed successfully.</returns>
3439 /// <example>
3440 /// <code>
3441 /// await communicator.CloseTab(url: "https://example.com");
3442 /// </code>
3443 /// </example>
3444 public async Task<bool> CloseTab(string url = null, string tabId = null, string sessionId = null)
3445 {
3446 sessionId ??= GetEffectiveSessionId();
3447 string targetId = tabId ?? (url != null ? await GetTargetTabIdByUrl(url).ConfigureAwait(false) : GetCurrentTargetId());
3448 return await CloseTab(targetId, sessionId).ConfigureAwait(false);
3449 }
3450 /// <summary>
3451 /// Closes the browser tab with the given target id, removes it from the internal tab queue, and
3452 /// closes its parent window if that was the last remaining tab.
3453 /// </summary>
3454 /// <param name="targetId">The CDP target id of the tab to close.</param>
3455 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
3456 /// <returns>Task that resolves to true if the tab was closed (and the parent window closed, if it became empty), otherwise false.</returns>
3457 public async Task<bool> CloseTab(string targetId, string sessionId = null)
3458 {
3459 bool retVal = false;
3460 if (string.IsNullOrEmpty(targetId))
3461 {
3462 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No targetId provided for CloseTab", this, GPALObjectType.PuppeteerCommunicator);
3463 }
3464 else
3465 {
3466
3467 await SendCommand<object>(DevToolsMethods.TargetCloseTarget, new { targetId }, null).ConfigureAwait(false);
3468 bool removed = RemoveTabFromQueue(targetId);
3469 if (!removed)
3470 {
3471 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Failed to remove tab [{targetId}] from queue", this, GPALObjectType.PuppeteerCommunicator);
3472 }
3473
3474 var windowArray = _windowSessionsQueue.ToArray();
3475 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
3476 {
3477 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"No active window after closing tab [{targetId}]", this, GPALObjectType.PuppeteerCommunicator);
3478 retVal = true;
3479 }
3480 else
3481 {
3482 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
3483 if (tabQueue != null && tabQueue.IsEmpty)
3484 {
3485 string browserContextId = windowArray[_activeWindowIndex].BrowserContextId;
3486 await CloseWindow(browserContextId, sessionId).ConfigureAwait(false);
3487 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Closed window for targetId [{targetId}] (context [{browserContextId}]) as it has no tabs", this, GPALObjectType.PuppeteerCommunicator);
3488 retVal = true;
3489 }
3490 }
3491 }
3492 CheckForLastBrowserWindowOpen("CloseTab()");
3493 return retVal;
3494 }
3500 /// <param name="sessionId">CDP session id; currently unused but reserved for future use.</param>
3501 /// <returns>Task that resolves to true if the window and all its tabs were closed successfully, otherwise false.</returns>
3502 /// <example>
3503 /// <code>
3504 /// await communicator.CloseWindow(browserContextId);
3505 /// </code>
3506 /// </example>
3507 public async Task<bool> CloseWindow(string browserContextId, string sessionId = null)
3508 {
3509 bool retVal = false;
3510 try
3511 {
3512 var windowArray = _windowSessionsQueue.ToArray();
3513 var window = windowArray.FirstOrDefault(w => w.BrowserContextId == browserContextId);
3514 if (window == null)
3515 {
3516 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Window with browserContextId [{browserContextId}] not found", this, GPALObjectType.PuppeteerCommunicator);
3517 }
3518 else
3519 {
3520 // iterate thru all open tabs, close them which will close the window, also cleanup our internal housekeeping
3521 var tabArray = window.TabQueue.ToArray();
3522 foreach (var tab in tabArray)
3523 {
3524 await SendCommand<object>(DevToolsMethods.TargetCloseTarget, new { targetId = tab.Key }, null).ConfigureAwait(false);
3525 RemoveTabFromQueue(tab.Key);
3526 }
3527
3528 if (RemoveWindowFromQueue(browserContextId))
3529 {
3530 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Closed window with browserContextId [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
3531 retVal = true;
3532 }
3533 else
3534 {
3535 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to remove window with browserContextId [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
3536 }
3537 }
3538 }
3539 catch (Exception ex)
3540 {
3541 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to close window with browserContextId [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator, ex);
3542 }
3543
3544 CheckForLastBrowserWindowOpen("CloseWindow()");
3545 return retVal;
3546 }
3547
3548 // Implementation for "delete-storage"
3564 /// <param name="storageType">Storage type</param>
3565 /// <param name="sessionId">CDP session id</param>
3566 /// <param name="domain">Domain/origin; null acts as wildcard</param>
3567 /// <param name="storeName">Optional object store (IndexedDB)</param>
3568 /// <param name="path">Optional path (cookies)</param>
3569 /// <param name="key">Optional key; null acts as wildcard</param>
3570 /// <returns>Number of items successfully deleted</returns>
3571 public async Task<bool> DeleteStorage(
3572 string storageType,
3573 string sessionId,
3574 string domain = null,
3575 string storeName = null,
3576 string path = null,
3577 string key = null)
3578 {
3579 sessionId ??= GetEffectiveSessionId();
3580 int deletedCount = 0;
3581 bool retVal = false;
3582
3583 bool IsMissing(string s) => string.IsNullOrEmpty(s);
3584
3585 try
3586 {
3587 string origin = false == string.IsNullOrEmpty(domain) ? UrlHelper.GetOrigin(domain) : UrlHelper.GetOrigin(BrowserHelper.GetCurrentUrl(Browser.BrowserSettings));
3588
3589 if (IsMissing(domain))
3590 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"DOMAIN IS MISSING. Deleting all [{storageType}] for origin [{origin}].", this, GPALObjectType.PuppeteerCommunicator);
3591
3592 switch (storageType)
3593 {
3594 case "cookie":
3595 {
3596 var cookiesJson = await GetStorage("cookie", sessionId, domain, storeName, path, key).ConfigureAwait(false);
3597 var cookies = JsonConvert.DeserializeObject<List<dynamic>>(cookiesJson) ?? new List<dynamic>();
3598 foreach (dynamic c in cookies)
3599 {
3600 var delParams = new Dictionary<string, object>
3601 {
3602 { "name", (string)c.Key },
3603 { "domain", (string)c.Domain },
3604 //{ "url", $"https://{(string)c.Domain.ToString().TrimStart('.')}{(string)c.Path}" },
3605 { "path", (string)c.Path },
3606 };
3607
3608 // build the partitionKey object exactly as it appears in the cookie
3609 if (c.PartitionKey != null)
3610 {
3611 var pk = new Dictionary<string, object>
3612 {
3613 { "topLevelSite", c.PartitionKey.topLevelSite },
3614 { "hasCrossSiteAncestor", c.PartitionKey.hasCrossSiteAncestor }
3615 };
3616
3617 delParams.Add("partitionKey", pk);
3618 }
3619 retVal = await SendCommand(DevToolsMethods.NetworkDeleteCookies, delParams, sessionId).ConfigureAwait(false);
3620 if (true == retVal)
3621 deletedCount++;
3622 }
3623 break;
3624 }
3625 case "localStorage":
3626 case "sessionStorage":
3627 {
3628 if (IsMissing(domain))
3629 {
3630 string js = $@"(function() {{
3631 try {{
3632 {storageType}.clear();
3633 return true;
3634 }} catch (e) {{
3635 return false;
3636 }}
3637 }})();";
3638
3639 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
3640 {
3641 expression = js,
3642 awaitPromise = false,
3643 contextId = CurrentContextId,
3644 returnByValue = true
3645 }, sessionId).ConfigureAwait(false).GetAwaiter().GetResult()?.ToString().Contains("true");
3646
3647 if (true == retVal)
3648 deletedCount++;
3649
3650 break;
3651 }
3652
3653 if (IsMissing(key))
3654 {
3655 var keysJson = await GetStorage(storageType, sessionId, domain).ConfigureAwait(false);
3656 var keys = JsonConvert.DeserializeObject<List<string>>(keysJson) ?? new List<string>();
3657 foreach (var k in keys)
3658 {
3659 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
3660 {
3661 expression = $"{storageType}.removeItem('{EscapeJsString(k)}');",
3662 awaitPromise = false,
3663 contextId = CurrentContextId,
3664 returnByValue = true
3665 }, sessionId).ConfigureAwait(false).GetAwaiter().GetResult()?.ToString().Contains("true");
3666 if (true == retVal)
3667 deletedCount++;
3668 }
3669 }
3670 else
3671 {
3672 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
3673 {
3674 expression = $"{storageType}.removeItem('{EscapeJsString(key)}');",
3675 awaitPromise = false,
3676 contextId = CurrentContextId,
3677 returnByValue = true
3678 }, sessionId).ConfigureAwait(false).GetAwaiter().GetResult()?.ToString().Contains("true");
3679 if (true == retVal)
3680 deletedCount++;
3681 }
3682 break;
3683 }
3684 case "indexedDb":
3685 {
3686 if (IsMissing(domain))
3687 {
3688 retVal = SendCommand<dynamic>(DevToolsMethods.StorageClearDataForOrigin, new
3689 {
3690 origin = origin,
3691 storageTypes = "indexeddb"
3692 }, sessionId).GetAwaiter().GetResult() != null;
3693
3694 if (retVal)
3695 deletedCount++;
3696
3697 break;
3698 }
3699
3700 if (IsMissing(storeName))
3701 {
3702 // enumerate all DBs and delete
3703 var dbsJson = await GetStorage("indexeddb", sessionId, domain).ConfigureAwait(false);
3704 var dbs = JsonConvert.DeserializeObject<List<string>>(dbsJson) ?? new List<string>();
3705 foreach (var db in dbs)
3706 {
3707 string js = $@"(function(){{ indexedDB.deleteDatabase('{EscapeJsString(db)}'); }})();";
3708 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate,
3709 new {
3710 expression = js,
3711 awaitPromise = true,
3712 contextId = CurrentContextId,
3713 returnByValue = true
3714 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
3715 if (true == retVal)
3716 deletedCount++;
3717 }
3718 }
3719 else if (IsMissing(key))
3720 {
3721 string js = $@"(function(){{ indexedDB.deleteDatabase('{EscapeJsString(storeName)}'); }})();";
3722 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate,
3723 new {
3724 expression = js,
3725 awaitPromise = true,
3726 contextId = CurrentContextId,
3727 returnByValue = true
3728 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
3729 if (true == retVal)
3730 deletedCount++;
3731 }
3732 else
3733 {
3734 string js = $@"(function() {{
3735 return new Promise((resolve, reject) => {{
3736 const dbName = '{EscapeJsString(path)}'; // path = dbName (per your note)
3737 const store = '{EscapeJsString(storeName)}'; // storeName = store (per your note)
3738 const recordKey = '{EscapeJsString(key)}';
3739
3740 const openReq = indexedDB.open(dbName);
3741
3742 openReq.onerror = () => reject(openReq.error);
3743
3744 openReq.onsuccess = () => {{
3745 const db = openReq.result;
3746
3747 const tx = db.transaction(store, 'readwrite');
3748 const objectStore = tx.objectStore(store);
3749
3750 objectStore.delete(recordKey);
3751
3752 tx.oncomplete = () => {{
3753 db.close();
3754 resolve(true); // so your .Contains(""true"") check still works
3755 }};
3756
3757 tx.onerror = () => {{
3758 db.close();
3759 reject(tx.error);
3760 }};
3761 }};
3762 }});
3763 }})();
3764 ";
3765
3766 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate,
3767 new
3768 {
3769 expression = js,
3770 awaitPromise = true,
3771 contextId = CurrentContextId,
3772 returnByValue = true
3773 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
3774
3775 if (retVal)
3776 deletedCount++;
3777 }
3778 break;
3779 }
3780
3781 case "cache":
3782 {
3783 if (IsMissing(domain))
3784 {
3785 string js = @"(async function() {
3786 try {
3787 const keys = await caches.keys();
3788 await Promise.all(keys.map(k => caches.delete(k)));
3789 return true;
3790 } catch (e) {
3791 return false;
3792 }
3793 })();";
3794
3795 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
3796 {
3797 expression = js,
3798 awaitPromise = true,
3799 contextId = CurrentContextId,
3800 returnByValue = true
3801 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
3802
3803 if (true == retVal)
3804 deletedCount++;
3805
3806 break;
3807 }
3808
3809 if (IsMissing(key))
3810 {
3811 var cachesJson = await GetStorage("cache", sessionId, domain).ConfigureAwait(false);
3812 var caches = JsonConvert.DeserializeObject<List<string>>(cachesJson) ?? new List<string>();
3813 foreach (var c in caches)
3814 {
3815 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate,
3816 new {
3817 expression = $"caches.open('{EscapeJsString(path)}').then(cache => cache.delete('{EscapeJsString(c)}',{{ignoreVary: true}}));",
3818 awaitPromise = true,
3819 contextId = CurrentContextId,
3820 returnByValue = true
3821 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
3822 if (true == retVal)
3823 deletedCount++;
3824 }
3825 }
3826 else
3827 {
3828 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate,
3829 new {
3830 expression = $"caches.open('{EscapeJsString(path)}').then(cache => cache.delete('{EscapeJsString(key)}', {{ignoreVary: true}}));",
3831 awaitPromise = true,
3832 contextId = CurrentContextId,
3833 returnByValue = true
3834 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
3835 if (true == retVal)
3836 deletedCount++;
3837 }
3838 break;
3839 }
3840
3841 default:
3843 GPALEventType.ERROR,
3844 $"Unsupported storage type: [{storageType}]",
3845 this,
3846 GPALObjectType.PuppeteerCommunicator);
3847 break;
3849 }
3850 catch (Exception ex)
3851 {
3853 GPALEventType.EXCEPTION,
3854 $"Failed for [{storageType}] d[{domain}] sn[{storeName}] p[{path}] k[{key}]",
3855 this,
3856 GPALObjectType.PuppeteerCommunicator,
3857 ex);
3858 }
3859
3860 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Deleted [{deletedCount}] [{storageType}](s)", this, GPALObjectType.PuppeteerCommunicator);
3861
3862 return 0 < deletedCount;
3863 }
3864 // Small helper to prevent JS injection / quoting issues
3870 /// <returns>The escaped string, or an empty string if <paramref name="value"/> is null or empty.</returns>
3871 private static string EscapeJsString(string value)
3872 {
3873 if (string.IsNullOrEmpty(value)) return "";
3874 return value.Replace("\\", "\\\\")
3875 .Replace("'", "\\'")
3876 .Replace("\r", "\\r")
3877 .Replace("\n", "\\n");
3878 }
3879
3880 // Implementation for "evaluate"
3892 public async Task<dynamic> Evaluate(string xpath, string sessionId = null)
3893 {
3894 sessionId ??= GetEffectiveSessionId();
3895 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
3896 new {
3897 contextId = CurrentContextId,
3898 expression = $"document.evaluate('{xpath}', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue"
3899 }, sessionId).ConfigureAwait(false);
3900 }
3901
3902 // Implementation for "evaluate-all"
3914 public async Task<dynamic> EvaluateAll(string xpath, string sessionId = null)
3915 {
3916 sessionId ??= GetEffectiveSessionId();
3917 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
3918 new {
3919 contextId = CurrentContextId,
3920 expression = $"Array.from(document.evaluate('{xpath}', document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null))"
3921 }, sessionId).ConfigureAwait(false);
3922 }
3923
3930 private string GetKeyCode(char c)
3931 {
3932 char upper = char.ToUpperInvariant(c);
3933
3934 if (upper >= 'A' && upper <= 'Z')
3935 {
3936 return "Key" + upper;
3937 }
3938
3939 if (char.IsDigit(c))
3940 {
3941 return "Digit" + c;
3942 }
3943
3944 switch (c)
3945 {
3946 case '`':
3947 case '~':
3948 return "Backquote";
3949
3950 case '-':
3951 case '_':
3952 return "Minus";
3953
3954 case '=':
3955 case '+':
3956 return "Equal";
3957
3958 case '[':
3959 case '{':
3960 return "BracketLeft";
3961
3962 case ']':
3963 case '}':
3964 return "BracketRight";
3965
3966 case '\\':
3967 case '|':
3968 return "Backslash";
3969
3970 case ';':
3971 case ':':
3972 return "Semicolon";
3973
3974 case '\'':
3975 case '"':
3976 return "Quote";
3977
3978 case ',':
3979 case '<':
3980 return "Comma";
3981
3982 case '.':
3983 case '>':
3984 return "Period";
3985
3986 case '/':
3987 case '?':
3988 return "Slash";
3989
3990 case ' ':
3991 return "Space";
3992
3993 case '\t':
3994 return "Tab";
3995
3996 case '\r':
3997 case '\n':
3998 return "Enter";
3999
4000 // Add more special keys here as needed, e.g.:
4001 // case '\b': return "Backspace";
4002 // case '\x1B': return "Escape";
4003
4004 default:
4005 return c.ToString(); // fallback
4006 }
4007 }
4008
4009 // Implementation for "fillin" helper
4023 public async Task<dynamic> FillIn(string objectId, string text, int delayMs = 0, string sessionId = null)
4024 {
4025 sessionId ??= GetEffectiveSessionId();
4026
4027 // Input.insertText (faster bulk insert)
4028 bool success = await TryInsertText(objectId, text, sessionId).ConfigureAwait(false);
4029 if (success) return true;
4030
4031 // JS direct set + synthetic events
4032 success = await TryJsDirectFill(objectId, text, sessionId).ConfigureAwait(false);
4033 if (success) return true;
4034
4035 // Step 1: Ensure focused
4036 await FocusElement(objectId, sessionId).ConfigureAwait(false);
4037
4038 // Per-char key events (most reliable for validation)
4039 success = await TryTypeWithKeyEvents(objectId, text, delayMs, sessionId).ConfigureAwait(false);
4040 if (success) return true;
4041
4042
4043 // All failed — log or throw
4044 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to fill element [{objectId}] with [{text}] using all methods.");
4045 return false;
4046
4047 }
4048
4058 private async Task<bool> TryTypeWithKeyEvents(string objectId, string text, int delayMs, string sessionId)
4059 {
4060 try
4061 {
4062 await DispatchTextCharByChar(text, 0 < delayMs ? delayMs : 50, sessionId).ConfigureAwait(false);
4063
4064 // After typing, verify
4065 string actualValue = await GetElementValue(objectId, sessionId).ConfigureAwait(false);
4066 if (actualValue == text)
4067 {
4068 // Final blur to trigger any validation/button enable
4069 await BlurElement(objectId, sessionId).ConfigureAwait(false);
4070 return true;
4071 }
4072 }
4073 catch (Exception ex)
4074 {
4075 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to type text, trying next method.", this, GPALObjectType.PuppeteerCommunicator, ex);
4076 }
4077 return false;
4078 }
4079
4088 private async Task<bool> TryInsertText(string objectId, string text, string sessionId)
4089 {
4090 try
4091 {
4092 // Bulk insert
4093 await SendCommand(DevToolsMethods.InputInsertText, new { text }, sessionId).ConfigureAwait(false);
4094
4095 // Verify
4096 string actualValue = await GetElementValue(objectId, sessionId).ConfigureAwait(false);
4097 if (actualValue == text)
4098 {
4099 await BlurElement(objectId, sessionId).ConfigureAwait(false);
4100 return true;
4101 }
4102 }
4103 catch (Exception ex)
4104 {
4105 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to type text, trying next method.", this, GPALObjectType.PuppeteerCommunicator, ex);
4106 }
4107 return false;
4108 }
4109
4119 private async Task<bool> TryJsDirectFill(string objectId, string text, string sessionId)
4120 {
4121 try
4122 {
4123 // Your original/enhanced JS function (set .value + dispatch events)
4124 var js = @"
4125 function(value) {
4126 this.value = value;
4127
4128 // Trigger everything frameworks listen to
4129 const desc = Object.getOwnPropertyDescriptor(this, 'value') ||
4130 Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value') ||
4131 Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
4132
4133 // Bypass any framework value setters that block direct assignment
4134 if (desc && desc.set) {
4135 desc.set.call(this, value);
4136 }
4137
4138 this.dispatchEvent(new Event('input', { bubbles: true }));
4139 this.dispatchEvent(new Event('change', { bubbles: true }));
4140 }";
4141
4142 await SendCommand(DevToolsMethods.RuntimeCallFunctionOn, new
4143 {
4144 objectId,
4145 functionDeclaration = js,
4146 arguments = new[] { new { value = text } },
4147 contextId = CurrentContextId,
4148 awaitPromise = true
4149 }, sessionId).ConfigureAwait(false);
4150
4151 // Verify
4152 string actualValue = await GetElementValue(objectId, sessionId).ConfigureAwait(false);
4153 if (actualValue == text)
4154 {
4155 await BlurElement(objectId, sessionId).ConfigureAwait(false);
4156 return true;
4157 }
4158 }
4159 catch (Exception ex)
4160 {
4161 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to type text, trying next method.", this, GPALObjectType.PuppeteerCommunicator, ex);
4162 }
4163 return false;
4164 }
4165
4166 // Helper: Get current value via JS evaluate
4173 private async Task<string> GetElementValue(string objectId, string sessionId)
4174 {
4175 var result = await SendCommand<dynamic>(DevToolsMethods.RuntimeCallFunctionOn, new
4176 {
4177 objectId,
4178 functionDeclaration = "function() { return this.value; }",
4179 contextId = CurrentContextId,
4180 returnByValue = true
4181 }, sessionId).ConfigureAwait(false);
4182
4183 // Parse the returned value (assuming it's a string)
4184 return result?.result?.value?.ToString() ?? string.Empty;
4185 }
4186
4187 // Helper: Focus
4193 internal async Task FocusElement(string objectId, string sessionId)
4194 {
4195 await SendCommand(DevToolsMethods.RuntimeCallFunctionOn, new
4196 {
4197 objectId,
4198 contextId = CurrentContextId,
4199 functionDeclaration = "function() { this.focus(); }"
4200 }, sessionId).ConfigureAwait(false);
4201 }
4202
4203 // Helper: Blur
4209 private async Task BlurElement(string objectId, string sessionId)
4210 {
4211 await SendCommand(DevToolsMethods.RuntimeCallFunctionOn, new
4212 {
4213 objectId,
4214 contextId = CurrentContextId,
4215 functionDeclaration = "function() { this.blur(); }"
4216 }, sessionId).ConfigureAwait(false);
4217 }
4218
4219 // Implementation for "fill-in-append"
4230
4231 public async Task FillInAppend(string objectId, string text, int delayMs = 0, string sessionId = null)
4232 {
4233 sessionId ??= GetEffectiveSessionId();
4234
4235 var currentResp = await SendCommand<object>(DevToolsMethods.RuntimeCallFunctionOn, new
4236 {
4237 objectId,
4238 functionDeclaration = "function() { return this.value || ''; }",
4239 contextId = CurrentContextId,
4240 returnByValue = true
4241 }, sessionId).ConfigureAwait(false);
4242
4243 string current = currentResp?.result?.value?.ToString() ?? "";
4244 await FillIn(objectId, current + text, delayMs, sessionId).ConfigureAwait(false);
4245 }
4246
4247 // Implementation for "fill-in-insert"
4255 /// <code>
4256 /// await communicator.FillInInsert(inputObjectId, "prefix-");
4257 /// </code>
4258 /// </example>
4259 public async Task FillInInsert(string objectId, string text, int delayMs = 0, string sessionId = null)
4260 {
4261 sessionId ??= GetEffectiveSessionId();
4262
4263 var currentResp = await SendCommand<object>(DevToolsMethods.RuntimeCallFunctionOn, new
4264 {
4265 objectId,
4266 functionDeclaration = "function() { return this.value || ''; }",
4267 contextId = CurrentContextId,
4268 returnByValue = true
4269 }, sessionId).ConfigureAwait(false);
4270
4271 string current = currentResp?.result?.value?.ToString() ?? "";
4272 await FillIn(objectId, text + current, delayMs, sessionId).ConfigureAwait(false);
4273 }
4274
4275 // Implementation for "fill-in-overwrite"
4278
4285 /// </code>
4286 /// </example>
4287 public async Task FillInOverwrite(string objectId, string text, int delayMs = 0, string sessionId = null)
4288 {
4289 await FillIn(objectId, text, delayMs, sessionId).ConfigureAwait(false);
4290 }
4291
4292 // Implementation for "fire-change-event"
4299 public async Task<dynamic> FireChangeEvent(string objectId, string sessionId = null)
4300 {
4301 sessionId ??= GetEffectiveSessionId();
4302 return await SendCommand<object>(DevToolsMethods.RuntimeCallFunctionOn, new
4303 {
4304 objectId,
4305 contextId = CurrentContextId,
4306 functionDeclaration = "function() {{ this.dispatchEvent(new Event('change')); }}"
4307 }, sessionId).ConfigureAwait(false);
4308 }
4309
4310 // Implementation for "focus"
4316 public async Task Focus(int backendNodeId, string sessionId = null)
4317 {
4318 sessionId ??= GetEffectiveSessionId();
4319 var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, sessionId).ConfigureAwait(false);
4320 await SendCommand<object>(DevToolsMethods.DOMFocus, new { nodeId }, sessionId).ConfigureAwait(false);
4321 }
4322
4323 // Implementation for "forward"
4329 public async Task<dynamic> Forward(string sessionId = null)
4330 {
4331 sessionId ??= GetEffectiveSessionId();
4332 dynamic history = await SendCommand<object>(DevToolsMethods.PageGetNavigationHistory, new { }, sessionId).ConfigureAwait(false);
4333 if (history?.currentIndex != null)
4334 {
4335 var entries = (IEnumerable<dynamic>)history.entries;
4336 var nextEntry = entries.ElementAtOrDefault((int)history.currentIndex + 1);
4337 if (nextEntry?.id != null)
4339 var result = await SendCommand(DevToolsMethods.PageNavigateToHistoryEntry, new { entryId = (int)nextEntry.id }, sessionId).ConfigureAwait(false);
4340 return result;
4341 }
4342 }
4343 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No next page in history", this, GPALObjectType.PuppeteerClient);
4344 return null;
4345 }
4346
4347 // Implementation for "fullscreen"
4354 public async Task<bool> FullScreen(string sessionId = null)
4356 if (true == Browser.BrowserSettings.UseHeadless)
4357 return await Maximize().ConfigureAwait(false);
4358 else
4359 {
4360 sessionId ??= GetEffectiveSessionId();
4361
4362 var windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
4363
4364 await SendCommand(DevToolsMethods.BrowserSetWindowBounds, new
4365 {
4366 windowId,
4367 bounds = new { windowState = "fullscreen" } // - this is the CDP way for browser fullscreen
4368 }, sessionId).ConfigureAwait(false);
4369 }
4370
4371 return true;
4372 }
4373
4374 // Implementation for "get-attribute"
4382 public async Task<dynamic> GetAttribute(int backendNodeId, string attribute, string sessionId = null)
4383 {
4384 var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, sessionId).ConfigureAwait(false);
4385 sessionId ??= GetEffectiveSessionId();
4386 return await SendCommand<object>(DevToolsMethods.DOMGetAttributes, new { nodeId, name = attribute }, sessionId).ConfigureAwait(false);
4387 }
4388
4389
4390 // Implementation for "get-bounding-client-rect"
4399 public async Task<Rectangle> GetBoundingClientRect(int backendNodeId, string sessionId = null)
4400 {
4401 sessionId ??= GetEffectiveSessionId();
4402
4403 // If we are inside an iframe (via InFrame()), prefer the iframe's context/session
4404 bool isInFrame = CurrentContextId.HasValue;
4405 string effectiveSessionId = isInFrame && !string.IsNullOrEmpty(CurrentFrameSessionId)
4406 ? CurrentFrameSessionId
4407 : sessionId;
4408
4409 // === Best path for cross-origin iframes ===
4410 if (CurrentContextId.HasValue && !string.IsNullOrEmpty(CurrentFrameSessionId))
4411 {
4412 try
4413 {
4414 // 1. Convert backendNodeId to a JavaScript RemoteObject in the iframe session
4415 var resolveResult = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, new
4416 {
4417 backendNodeId = backendNodeId,
4418 executionContextId = CurrentContextId.Value
4419 }, CurrentFrameSessionId).ConfigureAwait(false);
4420
4421 string objectId = resolveResult?.@object?.objectId;
4422
4423 if (!string.IsNullOrEmpty(objectId))
4424 {
4425 // 2. Call getBoundingClientRect directly on that object
4426 var rectResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeCallFunctionOn, new
4427 {
4428 objectId = objectId,
4429 functionDeclaration = "function() { const r = this.getBoundingClientRect(); return {x: r.left, y: r.top, width: r.width, height: r.height}; }",
4430 contextId = CurrentContextId,
4431 returnByValue = true
4432 }, CurrentFrameSessionId).ConfigureAwait(false);
4433
4434 if (rectResult?.result?.value != null)
4435 {
4436 var r = rectResult.result.value;
4437 if (0 < (int)r.width && 0 < (int)r.height)
4438 // Note: These coordinates are relative to the IFRAME viewport
4439 return new Rectangle((int)r.x, (int)r.y, (int)r.width, (int)r.height);
4440 }
4441 }
4442 }
4443 catch { }
4444 }
4445
4446 // === main page or fallback ===
4447 Point pageOffset = Point.Empty;
4448
4449 try
4450 {
4451 pageOffset = new Point(await WindowPageOffsetX(effectiveSessionId).ConfigureAwait(false),
4452 await WindowPageOffsetY(effectiveSessionId).ConfigureAwait(false));
4453 }
4454 catch (Exception)
4456 // a scroll that cannot be read is taken as none, which leaves the rect where the box model
4457 // put it rather than losing it
4458 }
4459
4460 try
4461 {
4462 var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, effectiveSessionId).ConfigureAwait(false);
4463 dynamic box = await SendCommand<dynamic>(DevToolsMethods.DOMGetBoxModel, new { nodeId }, effectiveSessionId).ConfigureAwait(false);
4464
4465 BoxModelResponse boxModel = ((JObject)box)?.ToObject<BoxModelResponse>();
4466
4467 if (boxModel?.Model != null)
4468 {
4469 // DOM.getBoxModel answers in document coordinates. this method is named for
4470 // getBoundingClientRect and the branch above it answers in viewport coordinates, so the
4471 // scroll of the same session comes off and both paths mean the same thing.
4472 // read outside the rect, because a scroll this cannot get is a rect adjusted by zero
4473 // rather than a rect nobody gets
4474 return new Rectangle(
4475 (int)boxModel.Model.Content[0] - pageOffset.X,
4476 (int)boxModel.Model.Content[1] - pageOffset.Y,
4477 (int)boxModel.Model.Width,
4478 (int)boxModel.Model.Height
4479 );
4480 }
4481 }
4482 catch (Exception ex)
4483 {
4484 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
4485 $"DOM.getBoxModel failed",
4486 this, GPALObjectType.PuppeteerCommunicator, ex);
4487 }
4488
4489 return new Rectangle(-1, -1, -1, -1);
4490 }
4491
4492 // Implementation for "get-content-and-css"
4499 public async Task<dynamic> GetContentAndCss(string elementId, string sessionId = null)
4500 {
4501 sessionId ??= GetEffectiveSessionId();
4502 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
4503 new {
4504 expression = $"let el = document.getElementById('{elementId}'); JSON.stringify({{content: el.innerHTML, css: getComputedStyle(el)}})",
4505 contextId = CurrentContextId,
4506 returnByValue = false
4507 }, sessionId).ConfigureAwait(false);
4508 }
4516 public async Task<Dictionary<string, object>> GetCssAttributes(int backendNodeId, string sessionId = null)
4517 {
4518 sessionId ??= GetEffectiveSessionId();
4519
4520 try
4521 {
4522 var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, sessionId).ConfigureAwait(false);
4523
4524 await SendCommand<object>(DevToolsMethods.DOMEnable, new { }, sessionId).ConfigureAwait(false);
4525 await SendCommand<object>(DevToolsMethods.CSSEnable, new { }, sessionId).ConfigureAwait(false);
4526
4527 var result = await SendCommand<dynamic>(DevToolsMethods.CSSGetComputedStyleForNode, new { nodeId }, sessionId).ConfigureAwait(false);
4528
4529 if (result?.computedStyle == null)
4530 {
4531 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CSS.getComputedStyleForNode returned null for nodeId [{nodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4532 return null;
4533 }
4534
4535 var relevantProps = new HashSet<string>(new[]
4536 {
4537 "display","visibility","opacity","pointer-events","overflow","clip-path",
4538 "position","top","left","right","bottom","z-index",
4539 "width","height","min-width","min-height","max-width","max-height",
4540 "margin","margin-top","margin-right","margin-bottom","margin-left",
4541 "padding","padding-top","padding-right","padding-bottom","padding-left",
4542 "border","border-width","border-style","border-color","box-sizing",
4543 "flex","flex-direction","flex-wrap","flex-grow","flex-shrink","flex-basis",
4544 "justify-content","align-items","align-self","order",
4545 "grid","grid-template","grid-area","grid-column","grid-row",
4546 "transform","transform-origin","transition","animation",
4547 "cursor","user-select","content","filter"
4548 });
4549
4550 var emptyValues = new HashSet<string> { "", "none", "normal", "inherit", "initial", "unset", "auto", "0px", "0", "transparent" };
4551
4552 var computedStyles = new Dictionary<string, object>();
4553 foreach (var style in result.computedStyle)
4554 {
4555 string name = style.name?.ToString();
4556 string value = style.value?.ToString()?.Trim();
4557
4558 if (name != null && relevantProps.Contains(name) && !string.IsNullOrEmpty(value) && !emptyValues.Contains(value))
4559 {
4560 computedStyles[name] = value;
4561 }
4562 }
4563
4564 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Retrieved [{computedStyles.Count}] filtered CSS attributes for nodeId [{nodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4565 return computedStyles;
4566 }
4567 catch (Exception ex)
4568 {
4569 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to get CSS attributes", this, GPALObjectType.PuppeteerCommunicator, ex);
4570 return null;
4571 }
4572 finally
4573 {
4574 await SendCommand<object>(DevToolsMethods.CSSDisable, new { }, sessionId).ConfigureAwait(false);
4575 await SendCommand<object>(DevToolsMethods.DOMDisable, new { }, sessionId).ConfigureAwait(false);
4576 }
4577 }
4578 //public async Task<Dictionary<string, object>> GetCssAttributes(int backendNodeId, string sessionId = null)
4579 //{
4580 // sessionId ??= GetEffectiveSessionId();
4581
4582 // try
4583 // {
4584 // var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, sessionId);
4586 // await SendCommand<object>(DevToolsMethods.DOMEnable, new { }, sessionId);
4587 // await SendCommand<object>(DevToolsMethods.CSSEnable, new { }, sessionId); // NOTE: this command generates a lot of 'unhandled' receivemessage messages of elements added - this must be done tho
4588
4589 // // Send CSS.getComputedStyleForNode
4590 // var result = await SendCommand<dynamic>(DevToolsMethods.CSSGetComputedStyleForNode, new
4591 // {
4592 // nodeId
4593 // }, sessionId);
4594
4595
4596 // if (result?.computedStyle == null)
4597 // {
4598 // GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CSS.getComputedStyleForNode returned null for nodeId [{nodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4599 // return null;
4600 // }
4601
4602 // // Process computedStyle array
4603 // var computedStyles = new Dictionary<string, object>();
4604 // foreach (var style in result.computedStyle)
4605 // computedStyles[style.name?.ToString()] = style.value?.ToString();
4606
4607 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Retrieved {computedStyles.Count} CSS attributes for nodeId [{nodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4608 // return computedStyles;
4609 // }
4610 // catch (Exception ex)
4611 // {
4612 // GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to get CSS attributes for backendNodeId [{backendNodeId}]: {ex.Message}", this, GPALObjectType.PuppeteerCommunicator, ex);
4613 // return null;
4614 // }
4615 // finally
4616 // {
4617 // await SendCommand<object>(DevToolsMethods.CSSDisable, new { }, sessionId);
4618 // await SendCommand<object>(DevToolsMethods.DOMDisable, new { }, sessionId);
4619 // }
4620 //}
4621 // this appears to be identical to the attributes we get from evaluateselector javascript
4627 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
4628 /// <returns>Task that resolves to a dictionary of attribute names and values, or null on failure.</returns>
4629 public async Task<Dictionary<string, object>> GetDomAttributes(int backendNodeId, string sessionId = null)
4630 {
4631 sessionId ??= GetEffectiveSessionId();
4632
4633 try
4634 {
4635 // Send DOM.getAttributes
4636 var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, sessionId).ConfigureAwait(false);
4637 var result = await SendCommand<object>(DevToolsMethods.DOMGetAttributes, new
4638 {
4639 nodeId
4640 }, sessionId).ConfigureAwait(false);
4641
4642 if (result?.attributes == null)
4643 {
4644 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"DOM.getAttributes returned null for nodeId [{nodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4645 return null;
4646 }
4647
4648 // Process attributes array (alternating name/value pairs)
4649 var attributes = new Dictionary<string, object>();
4650 for (int i = 0; i < result.attributes.Count - 1; i += 2)
4651 {
4652 attributes[result.attributes[i]?.ToString()] = result.attributes[i + 1]?.ToString();
4653 }
4654
4655 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Retrieved [{attributes.Count}] DOM attributes for nodeId [{nodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4656 return attributes;
4657 }
4658 catch (Exception ex)
4659 {
4660 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to get DOM attributes for nodeId [{backendNodeId}]", this, GPALObjectType.PuppeteerCommunicator, ex);
4661 return null;
4662 }
4663 }
4671 public async Task<Dictionary<string, object>> GetDomProperties(int backendNodeId, string sessionId = null)
4672 {
4673 sessionId ??= GetEffectiveSessionId();
4674
4675 try
4676 {
4677 // Resolve the node to a JS object so we can run the same curated script the template
4678 // (ElementHelper.GetDomProperties) and OttoMagic use - not an attribute/property dump.
4679 var resolve = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, new { backendNodeId, pierce = true }, sessionId).ConfigureAwait(false);
4680 string objectId = resolve.@object.objectId;
4681
4682 // Same key set and truthiness guards as ElementHelper.GetDomProperties
4683 var jsProps = await SendCommand<dynamic>(DevToolsMethods.RuntimeCallFunctionOn, new
4684 {
4685 objectId,
4686 functionDeclaration = @"
4687 function() {
4688 const props = {};
4689 if ('textContent' in this) props.textContent = this.textContent;
4690 if ('innerHTML' in this) props.innerHTML = this.innerHTML;
4691 if ('innerText' in this) props.innerText = this.innerText;
4692 if ('outerHTML' in this) props.outerHTML = this.outerHTML;
4693 if ('className' in this) props.className = this.className;
4694 if ('disabled' in this) props.disabled = this.disabled;
4695 return props;
4696 }",
4697 contextId = CurrentContextId,
4698 returnByValue = true
4699 }, sessionId).ConfigureAwait(false);
4700
4701 // Release objectId to prevent leak
4702 await SendCommand<dynamic>(DevToolsMethods.RuntimeReleaseObject,
4703 new {
4704 contextId = CurrentContextId,
4705 objectId
4706 }, sessionId).ConfigureAwait(false);
4707
4708 var properties = new Dictionary<string, object>();
4709 if (jsProps?.result?.value != null)
4710 properties = ((Newtonsoft.Json.Linq.JObject)jsProps.result.value).ToObject<Dictionary<string, object>>();
4711
4712 // Normalize null -> "" (consistent with ElementHelper.GetDomProperties)
4713 properties = properties.ToDictionary(kvp => kvp.Key, kvp => kvp.Value ?? "");
4714
4715 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Retrieved [{properties.Count}] DOM properties for backendNodeId [{backendNodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4716 return properties;
4717 }
4718 catch (Exception ex)
4719 {
4720 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to get DOM properties for backendNodeId [{backendNodeId}]", this, GPALObjectType.PuppeteerCommunicator, ex);
4721 return null;
4722 }
4723 }
4724 // pure javascript, message is too large from browser and cdp / websocket hangs
4725 /*
4726 public async Task<dynamic> GetCssAttributes(string selector, string sessionId = null)
4727 {
4728 sessionId ??= GetEffectiveSessionId();
4729
4730 if (string.IsNullOrEmpty(selector))
4731 {
4732 return new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
4733 }
4734
4735 var jsExpression = $@"
4736 (function() {{
4737 try {{
4738 console.log('get css attrs');
4739 // Helper to find the first matching element in a context
4740 function findFirstElement(sel, context = document) {{
4741 // Try CSS selector
4742 try {{
4743 console.log('css');
4744 const element = context.querySelector(sel);
4745 if (element) {{
4746 console.log('found css');
4747 return element;
4748 }}
4749 }} catch (e) {{
4750 console.log('error ' + e);
4751 }}
4752
4753 // Try XPath
4754 try {{
4755 console.log('xpath');
4756 const result = document.evaluate(sel, context, null, 9, null);
4757 if (result.singleNodeValue) {{
4758 console.log('found xpath');
4759 return result.singleNodeValue;
4760 }}
4761 }} catch (e) {{
4762 console.log('error ' + e);
4763 }}
4764
4765 return null;
4766 }}
4767
4768 console.log('find first');
4769 // Search in main document
4770 let element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}');
4771
4772 // Search in shadow DOM if no element found
4773 if (!element) {{
4774 console.log('try shadowdom');
4775 const shadowHosts = document.querySelectorAll('*');
4776 var shadowCount = 0;
4777 for (const host of shadowHosts) {{
4778 if (host.shadowRoot) {{
4779 console.log('searching shadow root' + shadowCount++);
4780 element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}', host.shadowRoot);
4781 if (element) break;
4782 }}
4783 }}
4784 }}
4785
4786 // Search in iframes if no element found
4787 if (!element) {{
4788 console.log('try iframes');
4789 const iframes = document.querySelectorAll('iframe');
4790 var iframeCount = 0;
4791 for (const iframe of iframes) {{
4792 try {{
4793 if (iframe.contentDocument) {{
4794 console.log('searching iframe' + iframeCount++);
4795 element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}', iframe.contentDocument);
4796 if (element) break;
4797 }}
4798 }} catch (e) {{
4799 // Ignore iframe access errors (e.g., cross-origin)
4800 }}
4801 }}
4802 }}
4803
4804 // If no element found, return empty object
4805 if (!element) {{
4806 console.log('no element found');
4807 return {{}};
4808 }}
4809
4810 console.log('get computed style');
4811 // Get computed styles for the element
4812 const styles = window.getComputedStyle(element);
4813 const props = {{}};
4814 for (let i = 0; i < styles.length; i++) {{
4815 const prop = styles[i];
4816 const value = styles.getPropertyValue(prop);
4817 if (value && value !== 'none') {{
4818 props[prop] = value;
4819 }}
4820 }}
4821 console.log('return props ' + props);
4822 return props;
4823 }} catch (e) {{
4824 console.error('Error in GetCssAttributes:', e);
4825 return {{}};
4826 }}
4827 }})()";
4828
4829 var jsProps = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
4830 {
4831 expression = jsExpression,
4832 contextId = CurrentContextId,
4833 returnByValue = true
4834 }, sessionId);
4835
4836 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Got jsprops [{jsProps}]");
4837
4838 return jsProps?.result?.value != null
4839 ? ((Newtonsoft.Json.Linq.JObject)jsProps.result.value).ToObject<Dictionary<string, object>>()
4840 : new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
4841 }
4842 public async Task<dynamic> GetDomAttributes(string selector, string sessionId = null)
4843 {
4844 sessionId ??= GetEffectiveSessionId();
4845
4846 var jsExpression = $@"
4847 (function() {{
4848 try {{
4849 console.log('get dom attrs');
4850 // Helper to find the first matching element in a context
4851 function findFirstElement(sel, context = document) {{
4852 // Try CSS selector
4853 try {{
4854 const element = context.querySelector(sel);
4855 if (element) return element;
4856 }} catch (e) {{
4857 // Invalid CSS selector; try XPath
4858 }}
4859
4860 // Try XPath
4861 try {{
4862 const result = document.evaluate(sel, context, null, 9, null);
4863 if (result.singleNodeValue) return result.singleNodeValue;
4864 }} catch (e) {{
4865 // Invalid XPath; continue to shadow DOM/iframes
4866 }}
4867
4868 return null;
4869 }}
4870
4871 // Search in main document
4872 let element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}');
4873
4874 // Search in shadow DOM if no element found
4875 if (!element) {{
4876 const shadowHosts = document.querySelectorAll('*');
4877 for (const host of shadowHosts) {{
4878 if (host.shadowRoot) {{
4879 element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}', host.shadowRoot);
4880 if (element) break;
4881 }}
4882 }}
4883 }}
4884
4885 // Search in iframes if no element found
4886 if (!element) {{
4887 const iframes = document.querySelectorAll('iframe');
4888 for (const iframe of iframes) {{
4889 try {{
4890 if (iframe.contentDocument) {{
4891 element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}', iframe.contentDocument);
4892 if (element) break;
4893 }}
4894 }} catch (e) {{
4895 // Ignore iframe access errors (e.g., cross-origin)
4896 }}
4897 }}
4898 }}
4899
4900 // If no element found, return empty object
4901 if (!element) return {{}};
4902
4903 // Get DOM attributes for the element
4904 const attributes = element.attributes;
4905 const attrs = {{}};
4906 for (let i = 0; i < attributes.length; i++) {{
4907 const name = attributes[i].name;
4908 const value = attributes[i].value;
4909 if (name && value) {{
4910 attrs[name] = value;
4911 }}
4912 }}
4913 return attrs;
4914 }} catch (e) {{
4915 console.error('Error in GetDomAttributes:', e);
4916 return {{}};
4917 }}
4918 }})()";
4919
4920 var jsProps = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
4921 {
4922 expression = jsExpression,
4923 contextId = CurrentContextId,
4924 returnByValue = true
4925 }, sessionId);
4926
4927 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Got jsprops [{jsProps}]");
4928
4929 return jsProps?.result?.value != null
4930 ? ((Newtonsoft.Json.Linq.JObject)jsProps.result.value).ToObject<Dictionary<string, object>>()
4931 : new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
4932 }
4933 public async Task<dynamic> GetDomProperties(string selector, string sessionId = null)
4934 {
4935 sessionId ??= GetEffectiveSessionId();
4936 var jsExpression = $@"
4937 (function() {{
4938 try {{
4939 //console.log('get dom props');
4940 // Helper to find the first matching element in a context
4941 function findFirstElement(sel, context = document) {{
4942 // Try CSS selector
4943 try {{
4944 //console.log('css');
4945 const element = context.querySelector(sel);
4946 if (element) {{
4947 //console.log('found css');
4948 return element;
4949 }}
4950 }} catch (e) {{
4951 //console.log('error ' + e);
4952 }}
4953
4954 // Try XPath
4955 try {{
4956 //console.log('xpath');
4957 const result = document.evaluate(sel, context, null, 9, null);
4958 if (result.singleNodeValue) {{
4959 return result.singleNodeValue;
4960 }}
4961 }} catch (e) {{
4962 //console.log('error ' + e);
4963 }}
4964
4965 return null;
4966 }}
4967
4968 // Search in main document
4969 let element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}');
4970
4971 // Search in shadow DOM if no element found
4972 if (!element) {{
4973 const shadowHosts = document.querySelectorAll('*');
4974 let hostCount = 0;
4975 for (const host of shadowHosts) {{
4976 //console.log('searching shadow host ' + hostCount);
4977 hostCount++;
4978 if (host.shadowRoot) {{
4979 element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}', host.shadowRoot);
4980 if (element) {{
4981 break;
4982 }}
4983 }}
4984 }}
4985 }}
4986
4987 // Search in iframes if no element found
4988 if (!element) {{
4989 const iframes = document.querySelectorAll('iframe');
4990 let iframeCount = 0;
4991 for (const iframe of iframes) {{
4992 iframeCount++;
4993 try {{
4994 if (iframe.contentDocument) {{
4995 //console.log('searching iframe ' + iframeCount);
4996 element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}', iframe.contentDocument);
4997 if (element) {{
4998 break;
4999 }}
5000 }}
5001 }} catch (e) {{
5002 //console.log('error ' + e);
5003 }}
5004 }}
5005 }}
5006
5007 // If no element found, return empty object
5008 if (!element) {{
5009 return {{}};
5010 }}
5012 // Get DOM properties for the element
5013 //console.log('get dom props 2');
5014 const props = {{}};
5015 if (element.tagName) props.tagName = element.tagName;
5016 if (element.textContent) props.textContent = element.textContent;
5017 if (element.value != null && element.value !== '') props.value = element.value;
5018 if (element.innerHTML) props.innerHTML = element.innerHTML;
5019 if (element.outerHTML) props.outerHTML = element.outerHTML;
5020 if (element.className) props.className = element.className;
5021 if (element.id) props.id = element.id;
5022 //console.log('return props');
5023 return props;
5024 }} catch (e) {{
5025 return {{}};
5026 }}
5027 }})()";
5028
5029 var jsProps = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
5030 {
5031 expression = jsExpression,
5032 contextId = CurrentContextId,
5033 returnByValue = true
5034 }, sessionId);
5035
5036 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Got jsprops [{jsProps}]");
5037
5038 return jsProps?.result?.value != null
5039 ? ((Newtonsoft.Json.Linq.JObject)jsProps.result.value).ToObject<Dictionary<string, object>>()
5040 : new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
5041 }
5042 */
5043
5044 // Implementation for "get-current-url"
5045
5053 /// </code>
5054 /// </example>
5055 public async Task<string> GetCurrentUrl(string sessionId = null)
5056 {
5057 sessionId ??= GetEffectiveSessionId();
5058
5059 // Evaluate 'window.location.toString()' to get the current URL
5060 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5061 new {
5062 expression = "window.location.href",
5063 contextId = CurrentContextId,
5064 returnByValue = true
5065 }, sessionId).ConfigureAwait(false);
5066
5067 // Extract and log the URL
5068 return result?.result?.value;
5069 }
5070
5071 // Implementation for "get-current-window"
5074
5077 public async Task<int> GetCurrentWindow(string sessionId = null)
5078 {
5079 sessionId ??= GetEffectiveSessionId();
5080
5081 var result = await SendCommand<dynamic>(
5082 DevToolsMethods.BrowserGetWindowForTarget,
5083 new { }, // no params needed
5084 sessionId
5085 ).ConfigureAwait(false);
5086
5087 return (int)result.windowId;
5088 }
5089
5093
5097 public async Task<System.Drawing.Rectangle> GetWindowRectangle(string sessionId = null)
5098 {
5099 sessionId ??= GetEffectiveSessionId();
5100
5101 int windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
5102
5103 dynamic result = await SendCommand<dynamic>(
5104 DevToolsMethods.BrowserGetWindowBounds,
5105 new { windowId },
5106 sessionId
5107 ).ConfigureAwait(false);
5108
5109 dynamic bounds = result?.bounds;
5110
5111 // a minimized window reports no position or size of its own, so what comes back is what it had
5112 return new System.Drawing.Rectangle(
5113 (int)(bounds?.left ?? 0),
5114 (int)(bounds?.top ?? 0),
5115 (int)(bounds?.width ?? 0),
5116 (int)(bounds?.height ?? 0));
5117 }
5118
5119 // Implementation for "get-element-attribute-hash"
5126 public async Task<dynamic> GetElementAttributeHash(string elementId, string sessionId = null)
5127 {
5128 sessionId ??= GetEffectiveSessionId();
5129 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5130 new
5131 {
5132 expression = "JSON.stringify(document.getElementById('" + elementId + "').attributes)",
5133 contextId = CurrentContextId,
5134 returnByValue = false
5135 }, sessionId).ConfigureAwait(false);
5136 }
5137
5150 public async Task<string> GetPageSource(string sessionId = null)
5151 {
5152 if (sessionId == null)
5153 {
5154 sessionId = GetEffectiveSessionId();
5155 }
5156
5157 // ── 1. Get root document ─────────────────────────────────────────────
5158 var docResult = await SendCommand(
5159 DevToolsMethods.DOMGetDocument,
5160 new Dictionary<string, object> { { "depth", 0 } },
5161 sessionId).ConfigureAwait(false);
5162
5163 if (docResult == null)
5164 {
5165 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "DOM.getDocument returned null",
5166 this, GPALObjectType.PuppeteerCommunicator);
5167 return string.Empty;
5168 }
5169
5170 // Normalize to JObject
5171 JObject rootDoc = docResult as JObject;
5172 if (rootDoc == null)
5173 {
5174 try
5175 {
5176 string jsonStr = Newtonsoft.Json.JsonConvert.SerializeObject(docResult);
5177 rootDoc = JObject.Parse(jsonStr);
5178 }
5179 catch (Exception ex)
5180 {
5181 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
5182 "Failed to convert DOM.getDocument response to JObject: [" + ex.Message + "]",
5183 this, GPALObjectType.PuppeteerCommunicator);
5184 return string.Empty;
5185 }
5186 }
5187
5188 // Find root (direct or wrapped in "result")
5189 JToken rootToken = rootDoc["root"] ?? rootDoc["result"]?["root"];
5190
5191 if (rootToken == null || rootToken.Type != JTokenType.Object)
5192 {
5193 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
5194 "No valid 'root' object found in DOM.getDocument response",
5195 this, GPALObjectType.PuppeteerCommunicator);
5196 return string.Empty;
5197 }
5198
5199 JObject root = (JObject)rootToken;
5200
5201 if (!long.TryParse(root["nodeId"]?.ToString(), out long rootNodeId))
5202 {
5203 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Invalid or missing nodeId in root",
5204 this, GPALObjectType.PuppeteerCommunicator);
5205 return string.Empty;
5206 }
5207
5208 // ── 2. Get outerHTML ─────────────────────────────────────────────────
5209 var htmlResult = await SendCommand(
5210 DevToolsMethods.DOMGetOuterHTML,
5211 new Dictionary<string, object> { { "nodeId", rootNodeId } },
5212 sessionId).ConfigureAwait(false);
5213
5214 if (htmlResult == null)
5215 {
5216 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "DOM.getOuterHTML returned null",
5217 this, GPALObjectType.PuppeteerCommunicator);
5218 return string.Empty;
5220
5221 // Normalize htmlResult to JObject
5222 JObject htmlDoc = htmlResult as JObject;
5223 if (htmlDoc == null)
5224 {
5225 try
5226 {
5227 string jsonStr = Newtonsoft.Json.JsonConvert.SerializeObject(htmlResult);
5228 htmlDoc = JObject.Parse(jsonStr);
5229 }
5230 catch (Exception ex)
5231 {
5232 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
5233 "Failed to convert DOM.getOuterHTML response to JObject: [" + ex.Message + "]",
5234 this, GPALObjectType.PuppeteerCommunicator);
5235 return string.Empty;
5236 }
5238
5239 // Extract the actual source string
5240 string fullSource = htmlDoc["outerHTML"]?.ToString();
5241
5242 if (string.IsNullOrWhiteSpace(fullSource))
5243 {
5244 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "outerHTML was empty or missing",
5245 this, GPALObjectType.PuppeteerCommunicator);
5246 return string.Empty;
5247 }
5248
5249 // Return the raw page source. Normalization (JS-unescaping and stripping
5250 // Chrome's XML-viewer wrapper when a file is being displayed) is applied
5251 // uniformly for every engine at the choke point in Browser.GetPageSource,
5252 // so this path stays as "ignorant" as the OttoMagic and Selenium paths.
5253 return fullSource;
5254 }
5255
5256 // Implementation for "get-parent-node"
5263 public async Task<dynamic> GetParentNode(string elementId, string sessionId = null)
5264 {
5265 sessionId ??= GetEffectiveSessionId();
5266 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5267 new {
5268 contextId = CurrentContextId,
5269 expression = "document.getElementById('" + elementId + "').parentNode"
5270 }, sessionId).ConfigureAwait(false);
5271 }
5272
5273 // Implementation for "get-ready-status"
5277
5281 public async Task<string> GetReadyStatus(string sessionId, string sessionToken)
5282 {
5283 sessionId ??= GetEffectiveSessionId();
5284 string retVal = null;
5286 if (lastSessionToken != sessionToken)
5287 {
5288 lastErrorMessage.Clear();
5289 supressedMessage = false;
5290 lastSessionToken = sessionToken;
5291 }
5292
5293 JObject result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
5294 {
5295 expression = "document.readyState",
5296 contextId = CurrentContextId,
5297 returnByValue = false
5298 }, sessionId).ConfigureAwait(false);
5299
5300 if (null != result?["result"]?["value"])
5301 retVal = result?["result"]?["value"]?.ToString();
5302 else
5303 retVal = @"""loading"""; //
5304
5305 string currentErrorMessage = $"Document.Ready status [{retVal}]";
5306
5307 if (false == lastErrorMessage.Contains(currentErrorMessage))
5308 {
5309 lastErrorMessage.Add(currentErrorMessage);
5310 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage, null, GPALObjectType.None);
5311 supressedMessage = false;
5312 }
5313 else if (false == supressedMessage)
5314 {
5315 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
5316 supressedMessage = true;
5317 }
5318
5319 return retVal;
5320 }
5321
5322 // Implementation for "get-shadow-root"
5329 public async Task<dynamic> GetShadowRoot(string css, string sessionId = null)
5330 {
5331 sessionId ??= GetEffectiveSessionId();
5332 var nodeId = await QueryByCss(css, sessionId).ConfigureAwait(false);
5333 return await SendCommand<object>(DevToolsMethods.DOMDescribeNode, new { nodeId, pierce = true }, sessionId).ConfigureAwait(false);
5334 }
5335
5336 // Implementation for "get-storage"
5359 public async Task<string> GetStorage(
5360 string storageType,
5361 string sessionId,
5362 string domain = null,
5363 string storeName = null,
5364 string path = null,
5365 string key = null)
5366 {
5367 sessionId ??= GetEffectiveSessionId();
5368 string resultJson = "null";
5369
5370 try
5371 {
5372 switch (storageType)
5373 {
5374 case "cookie":
5375 {
5376 // get all cookies via CDP
5377 dynamic cookiesResp = await SendCommand<dynamic>(DevToolsMethods.StorageGetCookies,
5378 null,
5379 sessionId).ConfigureAwait(false);
5380
5381 if (cookiesResp?.cookies != null)
5382 {
5383 var filtered = new List<dynamic>();
5384 path ??= "/";
5385
5386 foreach (dynamic c in cookiesResp.cookies)
5387 {
5388 if ((false == UrlHelper.CookieDomainMatches(((dynamic)c).domain?.ToString(), domain)) ||
5389 (!string.IsNullOrEmpty(path) && ((dynamic)c).path != path) ||
5390 (!string.IsNullOrEmpty(key) && ((dynamic)c).name != key))
5391 continue;
5392
5393 // NOTE: this gets all cookies, not just a list of names given a wildcard (missing parm)
5394 // add partitionKey conditionally
5395 if (((dynamic)c).partitionKey != null)
5396 {
5397 filtered.Add(new
5398 {
5399 Key = ((dynamic)c).name,
5400 Domain = ((dynamic)c).domain,
5401 Path = ((dynamic)c).path,
5402 Value = ((dynamic)c).value,
5403 Expires = ((dynamic)c).expires,
5404
5405 Secure = ((dynamic)c).secure,
5406 HttpOnly = ((dynamic)c).httpOnly,
5407 SameSite = ((dynamic)c).sameSite,
5408 Session = ((dynamic)c).session,
5409 Priority = ((dynamic)c).priority,
5410 SameParty = ((dynamic)c).sameParty,
5411 SourceScheme = ((dynamic)c).sourceScheme,
5412 SourcePort = ((dynamic)c).sourcePort,
5413
5414 PartitionKey = ((dynamic)c).partitionKey
5415 });
5416 }
5417 else
5418 {
5419 filtered.Add(new
5420 {
5421 Key = ((dynamic)c).name,
5422 Domain = ((dynamic)c).domain,
5423 Path = ((dynamic)c).path,
5424 Value = ((dynamic)c).value,
5425 Expires = ((dynamic)c).expires,
5426
5427 Secure = ((dynamic)c).secure,
5428 HttpOnly = ((dynamic)c).httpOnly,
5429 SameSite = ((dynamic)c).sameSite,
5430 Session = ((dynamic)c).session,
5431 Priority = ((dynamic)c).priority,
5432 SameParty = ((dynamic)c).sameParty,
5433 SourceScheme = ((dynamic)c).sourceScheme,
5434 SourcePort = ((dynamic)c).sourcePort
5435 });
5436 }
5437 }
5438
5439 resultJson = JsonConvert.SerializeObject(filtered);
5440 }
5441 break;
5442 }
5443 case "localStorage":
5444 case "sessionStorage":
5445 {
5446 string js = string.IsNullOrEmpty(key)
5447 ? $@"(function(){{
5448 let obj={{}};
5449 for(let i=0;i<{storageType}.length;i++){{ let k={storageType}.key(i); obj[k]={storageType}.getItem(k); }}
5450 return JSON.stringify(obj);
5451 }})()"
5452 : $"{storageType}.getItem('{EscapeJsString(key)}');";
5453
5454 var evalResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
5455 {
5456 expression = js,
5457 awaitPromise = true,
5458 contextId = CurrentContextId,
5459 returnByValue = true
5460 }, sessionId).ConfigureAwait(false);
5461
5462 resultJson = evalResult?.result?.value?.ToString() ?? "null";
5463 break;
5464 }
5465 case "indexedDb":
5466 {
5467 string js;
5468
5469 if (string.IsNullOrEmpty(path))
5470 {
5471 // No database name > list all IndexedDB databases
5472 js = @"
5473 (async () => {
5474 let dbs = await indexedDB.databases().ConfigureAwait(false);
5475 return JSON.stringify(dbs.map(db => ({ name: db.name, version: db.version })));
5476 })();";
5477 }
5478 else if (string.IsNullOrEmpty(storeName))
5479 {
5480 // Path (database name) given + no object store name > list all object stores in that DB
5481 js = $@"
5482 (async () => {{
5483 let req = indexedDB.open('{EscapeJsString(path)}');
5484 return new Promise((resolve) => {{
5485 req.onsuccess = e => {{
5486 let db = e.target.result;
5487 let stores = Array.from(db.objectStoreNames);
5488 db.close();
5489 resolve(JSON.stringify(stores));
5490 }};
5491 req.onerror = () => resolve(null);
5492 }});
5493 }})();";
5494 }
5495 else if (string.IsNullOrEmpty(key))
5496 {
5497 // Path + storeName given + no key > list all keys in that specific object store
5498 js = $@"
5499 (async () => {{
5500 let req = indexedDB.open('{EscapeJsString(path)}');
5501 return new Promise((resolve) => {{
5502 req.onsuccess = e => {{
5503 let db = e.target.result;
5504 let tx = db.transaction('{EscapeJsString(storeName)}', 'readonly');
5505 let store = tx.objectStore('{EscapeJsString(storeName)}');
5506 let keys = [];
5507 let cursorReq = store.openKeyCursor();
5508 cursorReq.onsuccess = function(event) {{
5509 let cursor = event.target.result;
5510 if (cursor) {{
5511 keys.push(cursor.key);
5512 cursor.continue();
5513 }} else {{
5514 db.close();
5515 resolve(JSON.stringify(keys));
5516 }}
5517 }};
5518 cursorReq.onerror = () => {{ db.close(); resolve(null); }};
5519 }};
5520 req.onerror = () => resolve(null);
5521 }});
5522 }})();";
5523 }
5524 else
5525 {
5526 // All three (path + storeName + key) > get the value for that key
5527 js = $@"
5528 (async () => {{
5529 let req = indexedDB.open('{EscapeJsString(path)}');
5530 return new Promise((resolve) => {{
5531 req.onsuccess = e => {{
5532 let db = e.target.result;
5533 let tx = db.transaction('{EscapeJsString(storeName)}', 'readonly');
5534 let store = tx.objectStore('{EscapeJsString(storeName)}');
5535 let getReq = store.get('{EscapeJsString(key)}');
5536 getReq.onsuccess = () => {{
5537 db.close();
5538 resolve(JSON.stringify(getReq.result));
5539 }};
5540 getReq.onerror = () => {{ db.close(); resolve(null); }};
5541 }};
5542 req.onerror = () => resolve(null);
5543 }});
5544 }})();";
5545 }
5546
5547 var evalDb = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
5548 {
5549 expression = js,
5550 awaitPromise = true,
5551 contextId = CurrentContextId,
5552 returnByValue = true
5553 }, sessionId).ConfigureAwait(false);
5554
5555 resultJson = evalDb?.result?.value?.ToString() ?? "null";
5556 break;
5557 }
5558
5559 case "cache":
5560 {
5561 string js;
5562
5563 if (string.IsNullOrEmpty(storeName))
5564 {
5565 // no key = enumerate alll cache names
5566 // key = return all with the key name
5567 js = string.IsNullOrEmpty(key)
5568 ? @"(async ()=>{let c=await caches.keys(); return JSON.stringify(c).ConfigureAwait(false);})()"
5569 : $@"(async () => {{
5570 const response = await caches.match('{EscapeJsString(key)}').ConfigureAwait(false);
5571 if (response) {{
5572 const data = await response.json().ConfigureAwait(false);
5573 return JSON.stringify(data);
5574 }}
5575 return null;
5576 }})();";
5577 }
5578 else if (string.IsNullOrEmpty(key))
5579 {
5580 // Path (cache name) is defined + no key > list all keys (as URLs) inside that specific cache
5581 js = $@"(async () => {{
5582 const cache = await caches.open('{EscapeJsString(storeName)}').ConfigureAwait(false);
5583 const keys = await cache.keys().ConfigureAwait(false);
5584 const keyUrls = keys.map(k => k.url); // keys are Request objects; we return clean URLs
5585 return JSON.stringify(keyUrls);
5586 }})();";
5587 }
5588 else
5589 {
5590 // Both path and key are defined > open the specific cache and match the key inside it
5591 js = $@"(async () => {{
5592 const cache = await caches.open('{EscapeJsString(storeName)}').ConfigureAwait(false);
5593 const response = await cache.match('{EscapeJsString(key)}').ConfigureAwait(false);
5594 if (response) {{{{
5595 const contentType = response.headers.get('content-type') || '';
5596 let data;
5597 if (contentType.includes('application/json') || contentType.includes('text/json')) {{{{
5598 data = await response.json().ConfigureAwait(false);
5599 }}}} else {{{{
5600 data = await response.text().ConfigureAwait(false);
5601 }}}}
5602 return JSON.stringify(data);
5603 }}}}
5604 return null;
5605 }})();";
5606
5607 }
5608 var evalCache = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
5609 {
5610 expression = js,
5611 awaitPromise = true,
5612 contextId = CurrentContextId,
5613 returnByValue = true
5614 }, sessionId).ConfigureAwait(false);
5615
5616 resultJson = evalCache?.result?.value?.ToString() ?? "null";
5617 break;
5618 }
5619
5620 case "notSet":
5621 GPAL.PublishSimpleEvent(
5622 GPALEventType.WARNING,
5623 $"Nothing to do because StorageType is [{storageType}] d[{domain}] sn[{storeName}] p[{path}] k[{key}]",
5624 this,
5625 GPALObjectType.PuppeteerCommunicator);
5626 break;
5627
5628 default:
5629 GPAL.PublishSimpleEvent(
5630 GPALEventType.ERROR,
5631 $"Unsupported storage type: [{storageType}]",
5632 this,
5633 GPALObjectType.PuppeteerCommunicator);
5634 break;
5635 }
5636 }
5637 catch (Exception ex)
5638 {
5639 GPAL.PublishSimpleEvent(
5640 GPALEventType.EXCEPTION,
5641 $"GetStorage failed for [{storageType}] d[{domain}] sn[{storeName}] p[{path}] k[{key}]",
5642 this,
5643 GPALObjectType.PuppeteerCommunicator,
5644 ex);
5645 }
5646
5647 return resultJson;
5648 }
5649
5652
5655 public async Task<string> GetUserAgent(string sessionId = null)
5656 {
5657 sessionId ??= GetEffectiveSessionId();
5658 JObject result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5659 new
5661 expression = "navigator.userAgent",
5662 contextId = CurrentContextId,
5663 returnByValue = true
5664 }, sessionId).ConfigureAwait(false);
5665 return result?["result"]?["value"]?.ToString();
5666 }
5667
5668 // Implementation for "goto"
5672 /// <param name="url">The URL to navigate to.</param>
5673 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
5674 /// <returns>Task that resolves to true if navigation succeeded, otherwise false.</returns>
5675 /// <example>
5676 /// <code>
5677 /// await communicator.GoTo("https://example.com");
5678 /// </code>
5679 /// </example>
5680 public async Task<bool> GoTo(string url, string sessionId = null)
5681 {
5682 sessionId ??= GetEffectiveSessionId();
5683 return await NavigateToUrl(url, sessionId).ConfigureAwait(false);
5684 }
5685
5686 // Implementation for "goto-tab"
5692 /// <returns>Task that resolves to the targetId of the activated tab, or null if no matching tab was found.</returns>
5693 public async Task<string> GoToTab(object tabIdOrUrlOrIndex, string sessionId = null)
5694 {
5695 string targetId = null;
5696 var windowArray = _windowSessionsQueue.ToArray();
5697
5698 if (tabIdOrUrlOrIndex is int index)
5699 {
5700 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
5701 {
5702 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No active window for GoToTab", this, GPALObjectType.PuppeteerCommunicator);
5703 return null;
5704 }
5705
5706 var tabQueue = windowArray[_activeWindowIndex].TabQueue.ToArray();
5707 if (index < 0 || index >= tabQueue.Length)
5708 {
5709 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Invalid tab index [{index}]", this, GPALObjectType.PuppeteerCommunicator);
5710 return null;
5711 }
5712
5713 // Get targetId from index
5714 targetId = tabQueue[index].Key;
5715 if (string.IsNullOrEmpty(targetId))
5716 {
5717 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No targetId at index [{index}]", this, GPALObjectType.PuppeteerCommunicator);
5718 return null;
5720
5721 // Target.activateTarget is the tab switch itself, not just window ordering - cdp has no way to
5722 // make a tab current without focusing it, and focusing it raises the window. that raise is the
5723 // cost of switching tabs, not something GPAL chose
5724 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId }, null).ConfigureAwait(false);
5725 SetActiveTabIndex(index);
5726 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
5727
5728 return targetId;
5729 }
5730 else if (tabIdOrUrlOrIndex is string str)
5731 {
5732 targetId = await GetTargetTabIdByUrl(str, sessionId).ConfigureAwait(false);
5733 if (string.IsNullOrEmpty(targetId))
5734 {
5735 // Check if it's a direct targetId
5736 foreach (var window in windowArray)
5737 {
5738 var tabArray = window.TabQueue.ToArray();
5739 if (tabArray.Any(kvp => kvp.Key == str))
5740 {
5741 targetId = str;
5742 break;
5743 }
5744 }
5745 }
5746
5747 if (string.IsNullOrEmpty(targetId))
5748 {
5749 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No target found for tabId or URL [{str}]", this, GPALObjectType.PuppeteerCommunicator);
5750 return null;
5751 }
5752
5753 int windowIndex = GetCurrentWindowIndex(targetId);
5754 if (windowIndex < 0)
5755 {
5756 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Tab with targetId [{targetId}] not found in any window", this, GPALObjectType.PuppeteerCommunicator);
5757 return null;
5758 }
5759
5760 SetActiveWindowIndex(windowIndex);
5761 var tabQueue = windowArray[windowIndex].TabQueue.ToArray();
5762 int tabIndex = Array.FindIndex(tabQueue, kvp => kvp.Key == targetId);
5763 if (tabIndex < 0)
5764 {
5765 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Tab with targetId [{targetId}] not found in window [{windowArray[windowIndex].BrowserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
5766 return null;
5767 }
5768
5769 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId }, null).ConfigureAwait(false);
5770 SetActiveTabIndex(tabIndex);
5771 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
5772 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Switched to tab with targetId [{targetId}], windowIndex [{windowIndex}], tabIndex [{tabIndex}]", this, GPALObjectType.PuppeteerCommunicator);
5773 return targetId;
5774 }
5775
5776 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Invalid input for GoToTab [{tabIdOrUrlOrIndex}]", this, GPALObjectType.PuppeteerCommunicator);
5777 return null;
5778 }
5779
5780 // Implementation for "hide-element"
5787 public async Task<dynamic> HideElement(int backendNodeId, string sessionId = null)
5788 {
5789 return await SetAttribute(backendNodeId, "style", "display: none", sessionId).ConfigureAwait(false);
5790 }
5791
5792 // Implementation for "hover"
5796
5799 public async Task Hover(int backendNodeId, string sessionId = null)
5800 {
5801 sessionId ??= GetEffectiveSessionId();
5802 var x = await GetElementRandomCenterX(backendNodeId, sessionId).ConfigureAwait(false);
5803 var y = await GetElementRandomCenterY(backendNodeId, sessionId).ConfigureAwait(false);
5804 await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new { type = "mouseMoved", button = "none", x, y }, sessionId).ConfigureAwait(false);
5805 }
5806
5807 // Implementation for "is-clickable"
5815 /// <code>
5816 /// bool clickable = await communicator.IsClickable("submitButton");
5817 /// </code>
5818 /// </example>
5819 public async Task<bool> IsClickable(string elementId, string sessionId = null)
5820 {
5821 sessionId ??= GetEffectiveSessionId();
5822
5823 List<GPALElement> elems = await EvaluateSelector(elementId, sessionId).ConfigureAwait(false);
5824 List<dynamic> responses = new List<dynamic>();
5825
5826 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5827 new {
5828 contextId = CurrentContextId,
5829 expression = "document.getElementById('" + elementId + "').checkVisibility()"
5830 }, sessionId).ConfigureAwait(false);
5831 return (bool)result.value;
5832 }
5833
5834 // Implementation for "is-displayed"
5846 public async Task<bool> IsDisplayed(string elementId, string sessionId = null)
5847 {
5848 sessionId ??= GetEffectiveSessionId();
5849 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5850 new {
5851 contextId = CurrentContextId,
5852 expression = "document.getElementById('" + elementId + "').offsetParent !== null"
5853 }, sessionId).ConfigureAwait(false);
5854 return (bool)result.value;
5855 }
5856
5857 // Implementation for "is-enabled"
5858
5869 public async Task<bool> IsEnabled(string elementId, string sessionId = null)
5870 {
5871 sessionId ??= GetEffectiveSessionId();
5872 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5873 new {
5874 contextId = CurrentContextId,
5875 expression = "!document.getElementById('" + elementId + "').disabled"
5876 }, sessionId).ConfigureAwait(false);
5877 return (bool)result.value;
5878 }
5879
5880 // Implementation for "is-end-of-page"
5887 public async Task<bool> IsEndOfPage(string sessionId = null)
5888 {
5889 sessionId ??= GetEffectiveSessionId();
5890 try
5891 {
5892 // JavaScript code to check if at end of page
5893 var result = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
5894 {
5895 expression = @"
5896 (function() {
5897 try {
5898 const overflow = getComputedStyle(document.documentElement).overflow;
5899 if (overflow !== 'hidden') {
5900 return (window.innerHeight + window.scrollY) >= document.body.scrollHeight;
5901 }
5902 const scrollable = Array.from(document.querySelectorAll('*')).find(
5903 el => el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
5904 ) || document.body;
5905 return (scrollable.clientHeight + scrollable.scrollTop) > scrollable.scrollHeight;
5906 } catch {
5907 return false;
5908 }
5909 })()",
5910 contextId = CurrentContextId,
5911 returnByValue = true
5912 }, sessionId).ConfigureAwait(false);
5913
5914 // Parse result
5915 return (bool)result?["result"]?["value"];
5916 }
5917 catch (Exception ex)
5919 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Error detecting end of page, returning [true]", this, GPALObjectType.PuppeteerCommunicator, ex);
5920 return true;
5921 }
5922 }
5923
5924 // Implementation for "is-visible-in-viewport"
5942 public async Task<bool> ElementFromPoint(GPALElement element, int x, int y, string sessionId = null)
5943 {
5944 sessionId ??= GetEffectiveSessionId();
5945
5946 // the node we already have, not a selector re-resolved inside the page. a css lookup here can find
5947 // a different element or none at all, and it runs in whatever context happens to be current, which is
5948 // two ways to answer this question about the wrong element
5949 var resolved = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, new
5950 {
5951 backendNodeId = element.ElementBackendNodeId
5952 }, sessionId).ConfigureAwait(false);
5953
5954 string objectId = resolved?.@object?.objectId;
5955
5956 if (true == string.IsNullOrEmpty(objectId))
5957 return false;
5958
5959 // "this" is the element, so the comparison cannot drift. a descendant counts, since clicking the text
5960 // inside a button is clicking the button.
5961 // it answers with what is there rather than just yes or no - "" means this element, anything else
5962 // names what is covering it, which is the only thing worth knowing when the answer is no
5963 var result = await SendCommand<dynamic>(DevToolsMethods.RuntimeCallFunctionOn, new
5964 {
5965 objectId = objectId,
5966 functionDeclaration = @"function (x, y) {
5967 const hit = document.elementFromPoint(x, y);
5968 if (hit === this || this.contains(hit)) { return ''; }
5969 if (!hit) { return 'nothing'; }
5970 return hit.tagName
5971 + (hit.id ? '#' + hit.id : '')
5972 + (hit.className ? '.' + String(hit.className).trim().split(/\s+/).join('.') : '');
5973 }",
5974 arguments = new object[] { new { value = x }, new { value = y } },
5975 returnByValue = true
5976 }, sessionId).ConfigureAwait(false);
5977
5978 string covering = result?.result?.value as string;
5979
5980 if (false == string.IsNullOrEmpty(covering))
5981 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{covering}] is what is at viewport [{x}, {y}], not [{element.TagName}]", null, GPALObjectType.Browser);
5982
5983 return true == string.IsNullOrEmpty(covering) && null != result?.result?.value;
5984 }
5985
5986 public async Task<bool> IsVisibleInViewport(dynamic elemsOrBackendNodeId, string sessionId = null)
5987 {
5988 sessionId ??= GetEffectiveSessionId();
5989 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
5990 {
5991 expression = @"({
5992 height: window.innerHeight || document.documentElement.clientHeight,
5993 width: window.innerWidth || document.documentElement.clientWidth
5994 })",
5995 contextId = CurrentContextId,
5996 returnByValue = true
5997 }, sessionId).ConfigureAwait(false);
5998
5999 int viewportHeight = 0;
6000 int viewportWidth = 0;
6001 bool isInViewport = true;
6002 if (result?.result?.value != null)
6003 {
6004 var dict = ((Newtonsoft.Json.Linq.JObject)result.result.value).ToObject<Dictionary<string, int>>();
6005 viewportHeight = dict["height"];
6006 viewportWidth = dict["width"];
6007 }
6008
6009 if (elemsOrBackendNodeId is List<GPALElement>)
6010 foreach (GPALElement element in elemsOrBackendNodeId)
6011 {
6012 Rectangle rect = await GetBoundingClientRect(element.ElementBackendNodeId, sessionId).ConfigureAwait(false);
6013 if (-1 == rect.X) // NOTE: CAVEAT: if we cannot compute the box model, the element is prolly not interactable/false positive, so fail-forward
6014 isInViewport = true;
6015 else
6016 isInViewport &= rect.Top >= 0 &&
6017 rect.Left >= 0 &&
6018 rect.Bottom <= viewportHeight &&
6019 rect.Right <= viewportWidth;
6020 }
6021 else
6022 {
6023 Rectangle rect = await GetBoundingClientRect(elemsOrBackendNodeId, sessionId).ConfigureAwait(false);
6024 if (-1 == rect.X) // NOTE: CAVEAT: if we cannot compute the box model, the element is prolly not interactable/false positive, so fail-forward
6025 isInViewport = true;
6026 else
6027 isInViewport &= rect.Top >= 0 &&
6028 rect.Left >= 0 &&
6029 rect.Bottom <= viewportHeight &&
6030 rect.Right <= viewportWidth;
6031 }
6032 return isInViewport;
6033 }
6034
6035 // Implementation for "left-click"
6045 public async Task ClickElement(GPALElement element, string sessionId, List<dynamic> responses,
6046 ClickType clickType = ClickType.LeftClick,
6047 int modifiers = 0)
6048 {
6049 bool cdpSuccess = false;
6050 bool forceJavscript = false;
6051 bool jsSuccess = false;
6052 bool isInFrame = CurrentContextId.HasValue;
6053 string effectiveSessionId = isInFrame && !string.IsNullOrEmpty(CurrentFrameSessionId)
6054 ? CurrentFrameSessionId
6055 : sessionId;
6056
6057 try
6058 {
6059 // NOTE: for headless which is not finding a box model on an A tag
6060 // Get fresh box model
6061 // Cheap reflow trigger via JS (very reliable)
6062 if (true == Browser.BrowserSettings.UseHeadless)
6063 {
6064 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
6065 {
6066 expression = "document.body.offsetHeight;", // reads force layout
6067 contextId = CurrentContextId,
6068 returnByValue = true
6069 }, sessionId).ConfigureAwait(false);
6070
6071 await Task.Delay(80).ConfigureAwait(false); // extra breathing room
6072 }
6073
6074 var boxModel = await SendCommand<object>(DevToolsMethods.DOMGetBoxModel, new
6075 {
6076 objectId = element.ElementHandle
6077 }, effectiveSessionId).ConfigureAwait(false);
6078
6079 if (null != boxModel)
6080 {
6081 var contentQuad = boxModel?.model?.content;
6082 if (contentQuad == null || contentQuad.Count < 8)
6083 {
6084 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{element.TagName}][{clickType}] failed: Unable to get coordinates (boxmodel)");
6085 }
6086 else
6087 {
6088 float x = (float)contentQuad[0];
6089 float y = (float)contentQuad[1];
6090 float width = (float)(contentQuad[2] - contentQuad[0]);
6091 float height = (float)(contentQuad[7] - contentQuad[1]);
6092
6093 var random = new Random();
6094 var margin = 0.05f;
6095 var clickAreaWidth = width * (1f - 2 * margin);
6096 var clickAreaHeight = height * (1f - 2 * margin);
6097
6098 x = x + width * margin + (float)random.NextDouble() * clickAreaWidth;
6099 y = y + height * margin + (float)random.NextDouble() * clickAreaHeight;
6100
6101 if (0 < x && 0 < y)
6102 {
6103 // Force center for <a> tags (your existing behavior)
6104 if ("A".Equals(element.TagName, StringComparison.OrdinalIgnoreCase))
6105 {
6106 x = (float)contentQuad[0] + width / 2f;
6107 y = (float)contentQuad[1] + height / 2f;
6108 }
6109
6110 // Map ClickType -> CDP values
6111 string button = clickType switch
6112 {
6113 ClickType.LeftClick => "left",
6114 ClickType.MiddleClick => "middle",
6115 ClickType.RightClick => "right",
6116 ClickType.LeftDoubleClick => "left",
6117 _ => "left"
6118 };
6119
6120 int clickCount = clickType == ClickType.LeftDoubleClick ? 2 : 1;
6121
6122 // PRESS
6123 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6124 {
6125 type = "mousePressed",
6126 button,
6127 clickCount,
6128 x,
6129 y,
6130 modifiers,
6131 timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
6132 }, effectiveSessionId).ConfigureAwait(false));
6133
6134 await Task.Delay(random.Next(60, 140)).ConfigureAwait(false); // human-like
6135
6136 // RELEASE
6137 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6138 {
6139 type = "mouseReleased",
6140 button,
6141 clickCount,
6142 x = x + random.Next(-2, 3),
6143 y = y + random.Next(-2, 3),
6144 modifiers,
6145 timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
6146 }, effectiveSessionId).ConfigureAwait(false));
6147 cdpSuccess = true;
6148 }
6149 else
6150 {
6151 // the box model is viewport relative, so a negative coordinate means the element sits
6152 // outside the viewport and a dispatched mouse event there would land on nothing
6153 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CDP [{clickType}] has no on-screen point for [{element.TagName}][{element.Css}] at [{x},{y}]. Falling back to JavaScript.", this, GPALObjectType.PuppeteerClient);
6154 forceJavscript = true;
6155 }
6156 }
6157 }
6158 else
6159 {
6160 float x = (float)element.BoundingRect.X;
6161 float y = (float)element.BoundingRect.Y;
6162 float width = (float)element.BoundingRect.Width;
6163 float height = (float)element.BoundingRect.Height;
6164
6165 var random = new Random();
6166 var margin = 0.05f;
6167 var clickAreaWidth = width * (1f - 2 * margin);
6168 var clickAreaHeight = height * (1f - 2 * margin);
6169
6170 x = x + width * margin + (float)random.NextDouble() * clickAreaWidth;
6171 y = y + height * margin + (float)random.NextDouble() * clickAreaHeight;
6172
6173 if (0 < x && 0 < y)
6174 {
6175 // Force center for <a> tags (your existing behavior)
6176 if ("A".Equals(element.TagName, StringComparison.OrdinalIgnoreCase))
6177 {
6178 x = (float)element.BoundingRect.X + width / 2f;
6179 y = (float)element.BoundingRect.Y + height / 2f;
6180 }
6181
6182 // Map ClickType -> CDP values
6183 string button = clickType switch
6184 {
6185 ClickType.LeftClick => "left",
6186 ClickType.MiddleClick => "middle",
6187 ClickType.RightClick => "right",
6188 ClickType.LeftDoubleClick => "left",
6189 _ => "left"
6190 };
6191
6192 int clickCount = clickType == ClickType.LeftDoubleClick ? 2 : 1;
6193
6194 // PRESS
6195 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6196 {
6197 type = "mousePressed",
6198 button,
6199 clickCount,
6200 x,
6201 y,
6202 modifiers,
6203 timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
6204 }, effectiveSessionId).ConfigureAwait(false));
6205
6206 await Task.Delay(random.Next(60, 140)).ConfigureAwait(false); // human-like
6207
6208 // RELEASE
6209 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6210 {
6211 type = "mouseReleased",
6212 button,
6213 clickCount,
6214 x = x + random.Next(-2, 3),
6215 y = y + random.Next(-2, 3),
6216 modifiers,
6217 timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
6218 }, effectiveSessionId).ConfigureAwait(false));
6219 cdpSuccess = true;
6220 }
6221 else
6222 {
6223 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CDP [{clickType}] failed. Falling back to JavaScript.", this, GPALObjectType.PuppeteerClient);
6224 forceJavscript = true;
6225 }
6226 }
6227 }
6228 catch (Exception ex)
6229 {
6230 if (!GPAL.NoFallbackRecoveryActions)
6231 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"CDP [{clickType}] failed.{(false == GPAL.NoFallbackRecoveryActions ? " Falling back to JavaScript." : " Javascript fallback disallowed.")}", this, GPALObjectType.PuppeteerClient, ex);
6232 }
6233
6234 // Optional JS fallback (keep your existing one, or remove if CDP never fails anymore)
6235 if ((false == cdpSuccess && false == GPAL.NoFallbackRecoveryActions) || true == forceJavscript)
6236 {
6237 string selector = !string.IsNullOrEmpty(element.Css) ? element.Css : element.Xpath;
6238
6239 if (string.IsNullOrEmpty(selector))
6240 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Fallback JavaScript failed: No valid XPath or CSS selector for [{element.TagName}]", this, GPALObjectType.PuppeteerClient);
6241
6242 var clickScript = $@"
6243 (() => {{
6244 try {{
6245 let el = null;
6246 const sel = `{selector.Replace("'", "\\'")}`;
6247
6248 // XPath first -> CSS fallback
6249 const xp = document.evaluate(sel, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
6250 el = xp.singleNodeValue || document.querySelector(sel);
6251 if (!el) return false;
6252
6253 // getBoundingClientRect is already viewport relative, which is what clientX and
6254 // clientY are defined as. adding the scroll offset turns them into document
6255 // coordinates and any handler that hit tests the event reads the wrong point
6256 const rect = el.getBoundingClientRect();
6257 const cx = rect.left + rect.width / 2;
6258 const cy = rect.top + rect.height / 2;
6259
6260 const opts = {{ bubbles: true, cancelable: true, clientX: cx, clientY: cy, view: window }};
6261
6262 // === LEFT CLICK (or double) ===
6263 if ({(clickType == ClickType.LeftClick || clickType == ClickType.LeftDoubleClick).ToString().ToLower()}) {{
6264 const count = {(clickType == ClickType.LeftDoubleClick ? 2 : 1)};
6265 for (let i = 0; i < count; i++) {{
6266 el.dispatchEvent(new MouseEvent('mousedown', {{...opts, button: 0, buttons: 1}}));
6267 el.dispatchEvent(new MouseEvent('mouseup', {{...opts, button: 0, buttons: 0}}));
6268 el.dispatchEvent(new MouseEvent('click', {{...opts, button: 0, detail: i+1}}));
6269 if (i === 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 80); // ~80ms between clicks
6270 }}
6271 return true;
6272 }}
6273
6274 // === MIDDLE CLICK (eBay pop-under) ===
6275 if ({(clickType == ClickType.MiddleClick).ToString().ToLower()}) {{
6276 el.dispatchEvent(new MouseEvent('mousedown', {{...opts, button: 1, buttons: 4}}));
6277 el.dispatchEvent(new MouseEvent('mouseup', {{...opts, button: 1, buttons: 0}}));
6278 el.dispatchEvent(new MouseEvent('click', {{...opts, button: 1}}));
6279 return true;
6280 }}
6281
6282 // === RIGHT CLICK (context menu) ===
6283 if ({(clickType == ClickType.RightClick).ToString().ToLower()}) {{
6284 el.dispatchEvent(new MouseEvent('mousedown', {{...opts, button: 2, buttons: 2}}));
6285 el.dispatchEvent(new MouseEvent('mouseup', {{...opts, button: 2, buttons: 0}}));
6286 el.dispatchEvent(new MouseEvent('contextmenu', {{...opts, button: 2}}));
6287 return true;
6288 }}
6289
6290 return false;
6291 }} catch(e) {{
6292 return false;
6293 }}
6294 }})();
6295 ";
6296
6297 if (false == string.IsNullOrEmpty(selector))
6298 {
6299 dynamic jsResult = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
6300 {
6301 expression = clickScript,
6302 objectGroup = "node",
6303 contextId = CurrentContextId,
6304 returnByValue = true
6305 }, sessionId).ConfigureAwait(false);
6306
6307 responses.Add(jsResult);
6308
6309 // the script returns false when it cannot find the element or the click type has no branch
6310 if (jsResult?.result?.value is bool clicked && clicked)
6311 jsSuccess = true;
6312 }
6313 }
6314
6315 // name the path that actually dispatched. a click that reached neither is the one that leaves the page
6316 // unchanged and scrapes the same results twice, so it cannot read the same as a click that landed
6317 string clickPath = true == cdpSuccess ? "CDP" : true == jsSuccess ? "JavaScript" : "nothing";
6318
6319 GPAL.PublishSimpleEvent(true == cdpSuccess || true == jsSuccess ? GPALEventType.INFO : GPALEventType.WARNING,
6320 $"Puppeteer [{clickType}] [{element.TagName}][{Browser.CurrentUOW.CurrentSelector.Name}] w/modifiers [{((PuppeteerClient)PuppeteerClient).GetModifiersString(modifiers)}] dispatched by [{clickPath}]",
6321 this, GPALObjectType.PuppeteerCommunicator);
6322 }
6333 public async Task DragAndDrop(GPALElement element, string sessionId, List<dynamic> responses, int deltaX, int deltaY, int offsetX = 0, int offsetY = 0)
6334 {
6335 var startX = (float)element.BoundingRect.X + (float)element.BoundingRect.Width / 2 + offsetX;
6336 var startY = (float)element.BoundingRect.Y + (float)element.BoundingRect.Height / 2 + offsetY;
6337 var endX = startX + deltaX;
6338 var endY = startY + deltaY;
6339
6340 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6341 {
6342 type = "mousePressed",
6343 button = "left",
6344 clickCount = 1,
6345 x = startX,
6346 y = startY,
6347 modifiers = 0,
6348 timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
6349 }, sessionId).ConfigureAwait(false));
6350
6351 const int STEPS = 10;
6352 for (int i = 1; i <= STEPS; i++)
6353 {
6354 var x = startX + (endX - startX) * i / STEPS;
6355 var y = startY + (endY - startY) * i / STEPS;
6356
6357 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6358 {
6359 type = "mouseMoved",
6360 button = "left",
6361 buttons = 1,
6362 x,
6363 y,
6364 modifiers = 0,
6365 timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
6366 }, sessionId).ConfigureAwait(false));
6367
6368 await Task.Delay(15).ConfigureAwait(false);
6369 }
6370
6371 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6372 {
6373 type = "mouseReleased",
6374 button = "left",
6375 clickCount = 1,
6376 x = endX,
6377 y = endY,
6378 modifiers = 0,
6379 timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
6380 }, sessionId).ConfigureAwait(false));
6381 }
6395 public async Task LeftClick(string elementId, string sessionId = null)
6396 {
6397 sessionId ??= GetEffectiveSessionId();
6398 List<GPALElement> elems = await EvaluateSelector(elementId, sessionId).ConfigureAwait(false);
6399 List<dynamic> responses = new List<dynamic>();
6400
6401 foreach (GPALElement elem in elems)
6402 {
6403 await ScrollIntoView(elem, sessionId).ConfigureAwait(false);
6404 await ClickElement(elem, sessionId, responses, ClickType.LeftClick).ConfigureAwait(false);
6405 }
6406 }
6407
6419 public async Task SetDownloadBehavior(string downloadPath, string sessionId = null)
6420 {
6421 sessionId ??= GetEffectiveSessionId();
6422
6423 await SendCommand<object>(DevToolsMethods.BrowserSetDownloadBehavior, new { behavior = "allow", downloadPath = downloadPath }, sessionId).ConfigureAwait(false);
6424 }
6425
6431 /// </summary>
6432 /// <param name="sessionId">CDP session ID; falls back to the current effective session if null</param>
6433 /// <returns>Task representing the asynchronous call</returns>
6434 public async Task RestoreDownloadBehavior(string sessionId = null)
6435 {
6436 sessionId ??= GetEffectiveSessionId();
6437
6438 await SendCommand<object>(DevToolsMethods.BrowserSetDownloadBehavior, new { behavior = "default" }, sessionId).ConfigureAwait(false);
6439 }
6440
6441 // Implementation for "left-click-download"
6442 // NOTE: HEADLESS ONLY
6443
6452 /// <param name="sessionId">CDP session ID; falls back to the current effective session if null.</param>
6453 /// <param name="responses">List collecting the raw CDP responses from each click.</param>
6454 /// <returns>Task representing the asynchronous click-and-download operation.</returns>
6455 public async Task LeftClickAndDownload(List<GPALElement> elems, string downloadPath, int modifiers, string sessionId, List<dynamic> responses)
6456 {
6457 sessionId ??= GetEffectiveSessionId();
6458
6459 string saveDirectory = Path.GetDirectoryName(downloadPath);
6460
6461 // NOTE: we can set the download directory, but not the filename, so we still require the downloadwatcher
6462 await SetDownloadBehavior(saveDirectory, sessionId).ConfigureAwait(false);
6463
6464 foreach (GPALElement elem in elems)
6465 {
6466 await ScrollIntoView(elem, sessionId).ConfigureAwait(false);
6467 await ClickElement(elem, sessionId, responses, ClickType.LeftClick, modifiers).ConfigureAwait(false);
6468 }
6469 }
6470 // Implementation for "left-click-and-upload"
6471 // NOTE: HEADLESS ONLY
6472 // NOTE: CAV#EAT: in order to call leftclickandupload repeatedly, we must track the files ourselves and clear the list...
6473 private static readonly Dictionary<string, List<string>> _attachedFilesPerSession = new Dictionary<string, List<string>>();
6474
6475 /// <summary>
6476 /// Attaches one or more files to file input element(s) using CDP.
6477 /// Supports single file path (string) or multiple paths (List&lt;string&gt; / IEnumerable&lt;string&gt;).
6478 /// Accumulates files across calls by tracking attached paths per session/input and re-attaching the full list each time.
6479 /// Uses DOM.setFileInputFiles to set files directly (headless-friendly, no dialog).
6480 /// Dispatches change event after attachment to trigger page/widget processing.
6481 /// </summary>
6482 /// <param name="elems">List of GPALElement instances representing the file input(s). Usually one, but supports multiple.</param>
6483 /// <param name="uploadFiles">
6484 /// File path(s) to attach.
6485 /// - string: single file path (e.g. @"C:\temp\report.pdf")
6486 /// - IEnumerable&lt;string&gt; or List&lt;string&gt;: multiple file paths (e.g. new[] { @"C:\file1.pdf", @"C:\file2.jpg" })
6487 /// </param>
6488 /// <param name="modifiers">Keyboard modifiers (not used in current implementation)</param>
6489 /// <param name="sessionId">CDP session ID (optional; falls back to current)</param>
6490 /// <param name="responses">Optional list to collect status/info (e.g. for logging or fluent chaining)</param>
6491 /// <returns>Task (async operation)</returns>
6492 /// <example>
6493 /// Single file:
6494 /// <code>
6495 /// await LeftClickAndUpload(elems, @"C:\temp\doc.pdf", 0, sessionId, responses);
6496 /// </code>
6497 ///
6498 /// Multiple files (additive across calls):
6499 /// <code>
6500 /// var files = new List&lt;string&gt; { @"C:\doc1.pdf" };
6501 /// await LeftClickAndUpload(elems, files, 0, sessionId, responses);
6502 ///
6503 /// files.Add(@"C:\photo.jpg");
6504 /// await LeftClickAndUpload(elems, files, 0, sessionId, responses); // now attaches both
6505 /// </code>
6506 ///
6507 /// Mixed:
6508 /// <code>
6509 /// await LeftClickAndUpload(elems, @"C:\invoice.docx", 0, sessionId, responses); // single
6510 /// await LeftClickAndUpload(elems, new[] { @"C:\old1.pdf", @"C:\old2.pdf" }, 0, sessionId, responses); // full list
6511 /// </code>
6512 /// </example>
6513 public async Task LeftClickAndUpload(
6514 List<GPALElement> elems,
6515 object uploadFiles,
6516 int modifiers,
6517 string sessionId,
6518 List<dynamic> responses)
6519 {
6520 sessionId ??= GetEffectiveSessionId();
6521
6522 // Normalize uploadFiles to List<string>
6523 var newPaths = new List<string>();
6524
6525 if (uploadFiles is string singlePath)
6526 {
6527 if ("" != singlePath?.Trim())
6528 {
6529 newPaths.Add(Path.GetFullPath(singlePath));
6530 }
6531 }
6532 else if (uploadFiles is GPALFile pathEnumerable)
6533 {
6534 foreach (var p in pathEnumerable.Filenames)
6535 {
6536 if ("" != p?.Trim())
6537 {
6538 newPaths.Add(Path.GetFullPath(p));
6539 }
6540 }
6541 }
6542 else
6543 {
6544 // Invalid type – silent skip (fluent style)
6545 return;
6547
6548 if (0 == newPaths.Count)
6549 {
6550 return; // nothing to attach
6551 }
6552
6553 foreach (GPALElement fileInput in elems)
6554 {
6555 string nodeKey = $"{sessionId}:{fileInput.ElementBackendNodeId}";
6556
6557 // Get browser-reported file count via attribute
6558 string fileCountStr = fileInput.GetAttribute("length");
6559 int browserFileCount = 0;
6560
6561 if ("" != fileCountStr)
6562 {
6563 Int32.TryParse(fileCountStr, out browserFileCount);
6564 }
6565
6566 // Get or create tracked list
6567 if (false == _attachedFilesPerSession.TryGetValue(nodeKey, out var currentFiles))
6568 {
6569 currentFiles = new List<string>();
6570 _attachedFilesPerSession[nodeKey] = currentFiles;
6571 }
6572
6573 // Staleness check: browser count != tracked count > reset
6574 if (browserFileCount != currentFiles.Count)
6575 {
6576 currentFiles.Clear();
6577 _attachedFilesPerSession[nodeKey] = currentFiles;
6578 }
6579
6580 // Add new paths
6581 currentFiles.AddRange(newPaths);
6582
6583 var setFilesParams = new
6584 {
6585 nodeId = fileInput.ElementNodeId,
6586 files = currentFiles.ToArray()
6587 };
6588
6589 await SendCommand<object>(DevToolsMethods.DOMSetFileInputFiles, setFilesParams, sessionId).ConfigureAwait(false);
6590 await FireChangeEvent(fileInput.ElementHandle, sessionId).ConfigureAwait(false);
6591
6592 responses?.Add(new
6593 {
6594 status = "upload_attached",
6595 pathsAdded = newPaths,
6596 totalFilesTracked = currentFiles.Count,
6597 browserReportedCount = browserFileCount
6598 });
6599 }
6600 }
6601
6602 // Implementation for "left-double-click"
6611 public async Task LeftDoubleClick(string elementId, string sessionId = null)
6612 {
6613 sessionId ??= GetEffectiveSessionId();
6614 List<GPALElement> elems = await EvaluateSelector(elementId, sessionId).ConfigureAwait(false);
6615 List<dynamic> responses = new List<dynamic>();
6616
6617 foreach (GPALElement elem in elems)
6618 {
6619 await ScrollIntoView(elem, sessionId).ConfigureAwait(false);
6620 await ClickElement(elem, sessionId, responses, ClickType.LeftDoubleClick).ConfigureAwait(false);
6621 }
6622 }
6623
6624
6625 // Implementation for "maximize"
6629 /// </summary>
6630 /// <param name="sessionId">CDP session ID; falls back to the current effective session if null.</param>
6631 /// <returns>Task that resolves to true once the window bounds have been updated.</returns>
6632 public async Task<bool> Maximize(string sessionId = null)
6633 {
6634 sessionId ??= GetEffectiveSessionId();
6635
6636 var windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
6637
6638 // window state, not bounds - chromium reads a bounds set as a normal window at that size
6639 await SendCommand(DevToolsMethods.BrowserSetWindowBounds, new
6640 {
6641 windowId,
6642 bounds = new { windowState = "maximized" }
6643 }, sessionId).ConfigureAwait(false);
6644
6645 return true;
6646 }
6647
6648 // Implementation for "minimize"
6650 /// Minimizes the browser window.
6651 /// Implementation for the "minimize" workflow action.
6652 /// </summary>
6653 /// <param name="sessionId">CDP session ID; falls back to the current effective session if null.</param>
6654 /// <returns>Task that resolves to true once the window state has been set to minimized.</returns>
6655 public async Task<bool> Minimize(string sessionId = null)
6656 {
6657 sessionId ??= GetEffectiveSessionId();
6658
6659 var windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
6660 await SendCommand(DevToolsMethods.BrowserSetWindowBounds, new
6661 {
6662 windowId = windowId,
6663 bounds = new { windowState = "minimized" }
6664 }, sessionId).ConfigureAwait(false);
6665
6666 return true;
6667 }
6677 public async Task<bool> MoveTo(List<GPALElement> elems, string sessionId, List<dynamic> responses)
6678 {
6679 bool retVal = true;
6680
6681 foreach (GPALElement element in elems)
6682 {
6683 try
6684 {
6685 await ScrollIntoView(element, sessionId).ConfigureAwait(false);
6686
6687 // Randomize move within 90% of the element's border
6688 var random = new Random();
6689 var margin = 0.05f; // 5% margin on each side (90% clickable area)
6690 var clickAreaWidth = (float)element.BoundingRect.Width * (1f - 2 * margin);
6691 var clickAreaHeight = (float)element.BoundingRect.Height * (1f - 2 * margin);
6692 var x = (float)element.BoundingRect.X + (float)element.BoundingRect.Width * margin + (float)random.NextDouble() * clickAreaWidth;
6693 var y = (float)element.BoundingRect.Y + (float)element.BoundingRect.Height * margin + (float)random.NextDouble() * clickAreaHeight;
6694
6695 if (true == "A".Equals(element.TagName)) // click in dead center, don't miss it, altho width could be a bit more tolerant
6696 {
6697 x = (float)element.BoundingRect.X + (float)element.BoundingRect.Width / 2;
6698 y = (float)element.BoundingRect.Y + (float)element.BoundingRect.Height / 2;
6699 }
6700
6701 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6702 {
6703 type = "mouseMoved",
6704 x,
6705 y,
6706 modifiers = 0,
6707 timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
6708 }, sessionId).ConfigureAwait(false));
6709 }
6710 catch (Exception ex)
6711 {
6712 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Error moving to element [{element.ElementHandle}][{element.Css}]", this, GPALObjectType.Puppeteer, ex);
6713 retVal = false;
6714 }
6715 }
6716 return retVal;
6717 }
6718 // Implementation for "move-to"
6726 public async Task MoveTo(int backendNodeId, string sessionId = null)
6727 {
6728 sessionId ??= GetEffectiveSessionId();
6729 var x = await GetElementRandomCenterX(backendNodeId, sessionId).ConfigureAwait(false);
6730 var y = await GetElementRandomCenterY(backendNodeId, sessionId).ConfigureAwait(false);
6731 await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new { type = "mouseMoved", x, y }, sessionId).ConfigureAwait(false);
6732 }
6733
6734 // Implementation for "click-point"
6747 public async Task ClickElement(int x, int y, string sessionId, List<dynamic> responses,
6748 ClickType clickType = ClickType.LeftClick,
6749 int modifiers = 0)
6750 {
6751 sessionId ??= GetEffectiveSessionId();
6752
6753 string button = clickType switch
6754 {
6755 ClickType.LeftClick => "left",
6756 ClickType.MiddleClick => "middle",
6757 ClickType.RightClick => "right",
6758 ClickType.LeftDoubleClick => "left",
6759 _ => "left"
6760 };
6761
6762 int clickCount = ClickType.LeftDoubleClick == clickType ? 2 : 1;
6763 var random = new Random();
6764
6765 // land the pointer first so :hover resolves before the press, same as a real mouse would
6766 await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6767 {
6768 type = "mouseMoved",
6769 button = "none",
6770 x,
6771 y
6772 }, sessionId).ConfigureAwait(false);
6773
6774 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6775 {
6776 type = "mousePressed",
6777 button,
6778 clickCount,
6779 x,
6780 y,
6781 modifiers,
6782 timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
6783 }, sessionId).ConfigureAwait(false));
6784
6785 await Task.Delay(random.Next(60, 140)).ConfigureAwait(false); // human-like
6786
6787 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6788 {
6789 type = "mouseReleased",
6790 button,
6791 clickCount,
6792 x,
6793 y,
6794 modifiers,
6795 timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
6796 }, sessionId).ConfigureAwait(false));
6797 }
6798
6799 // Implementation for "move-to-point"
6803
6809 public async Task MoveTo(int x, int y, string sessionId = null)
6810 {
6811 sessionId ??= GetEffectiveSessionId();
6812 await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new { type = "mouseMoved", button = "none", x, y }, sessionId).ConfigureAwait(false);
6813 }
6814
6815 // Implementation for "new-tab"
6830 public async Task<string> NewTab(GPALUrl url = null, string sessionId = null)
6831 {
6832 if (null == url)
6833 url = new GPALUrl();
6834
6835 sessionId ??= GetEffectiveSessionId();
6836 url.ForUrl(MagicHelper.GetFullUrl(url.Url, Browser, out Browser._areRobotsAllowed));
6837
6838 if (false == Browser.AreRobotsAllowed && true == Browser.BrowserSettings.ObeyRobotsTxt)
6840 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Visiting [{url.Url}] is disallowed by robots.txt and your request to honor it. Not going to URL. Workflow will fail.", this, GPALObjectType.PuppeteerCommunicator);
6841 url.ForUrl("https://google.com");
6842 }
6843
6844 var browserContextId = GetActiveWindowId();
6845 if (string.IsNullOrEmpty(browserContextId))
6846 {
6847 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
6848 "NewTab: No active window found to attach tab",
6849 this, GPALObjectType.PuppeteerCommunicator);
6850 return null;
6851 }
6852
6853 // Create new target in the current active window (browserContextId)
6854 var result = await SendCommand<object>(DevToolsMethods.TargetCreateTarget,
6855 new { url = url.Url, browserContextId, background = true }, null).ConfigureAwait(false);
6856
6857 string targetId = result.targetId.ToString();
6858
6859 // Handle attachment and queuing
6860 await HandleNewTarget(targetId, browserContextId).ConfigureAwait(false);
6861
6862 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
6863 $"Opened new tab TargetId [{targetId}] in Window [{browserContextId}]",
6864 this, GPALObjectType.PuppeteerCommunicator);
6865
6866 return targetId;
6867 }
6868
6869 // helper for next/previoustab
6870 // Implementation for getCurrentTabIndex
6878 internal int GetCurrentTabIndex(string currentTargetId)
6879 {
6880 var windowArray = _windowSessionsQueue.ToArray();
6881 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
6882 {
6883 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No active window for NextTab", this, GPALObjectType.PuppeteerCommunicator);
6884 return 0;
6885 }
6886
6887 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
6888 var queueArray = tabQueue.ToArray();
6889 if (queueArray.Length == 0)
6890 {
6891 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No tabs for NextTab", this, GPALObjectType.PuppeteerCommunicator);
6892 return 0;
6893 }
6894
6895 var currentIndex = Array.FindIndex(queueArray, kvp => kvp.Key == currentTargetId);
6896 if (currentIndex == -1)
6897 currentIndex = windowArray[_activeWindowIndex].ActiveTabIndex;
6898
6899 return currentIndex;
6900 }
6901 // Implementation for "next-tab"
6904
6909 public async Task<string> NextTab(string currentTargetId)
6910 {
6911 var windowArray = _windowSessionsQueue.ToArray();
6912 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
6913 {
6914 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No active window for NextTab", this, GPALObjectType.PuppeteerCommunicator);
6915 return null;
6916 }
6917
6918 var session = windowArray[_activeWindowIndex];
6919 var tabQueue = session.TabQueue;
6920 var browserContextId = session.BrowserContextId; // Use stored BrowserContextId
6921
6922 // Poll all targets via CDP to discover unknown pages
6923 var targetsResponse = await SendCommand<dynamic>(
6924 DevToolsMethods.TargetGetTargets,
6925 new { },
6926 null // Browser-level command, no sessionId
6927 ).ConfigureAwait(false);
6928
6929 if (targetsResponse?.targetInfos == null)
6930 {
6931 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Failed to get targets list in NextTab sync", this, GPALObjectType.PuppeteerCommunicator);
6932 // Proceed anyway with existing queue
6933 }
6934 else
6935 {
6936 var knownTargetIds = new HashSet<string>(tabQueue.Select(kv => kv.Key));
6937
6938 foreach (var targetInfo in targetsResponse.targetInfos)
6939 {
6940 string targetId = targetInfo.targetId?.ToString();
6941 string type = targetInfo.type?.ToString();
6942 string contextId = targetInfo.browserContextId?.ToString();
6943
6944 if (string.IsNullOrEmpty(targetId) || type != "page" || contextId != browserContextId)
6945 continue;
6946
6947 if (!knownTargetIds.Contains(targetId))
6948 {
6949 // Add with doNotGetSendSemaphore=true to avoid reentrancy/locking issues
6950 await AddTabToQueue(targetId, null, false, true).ConfigureAwait(false);
6951 knownTargetIds.Add(targetId);
6952 }
6953 }
6954 }
6955
6956 var queueArray = tabQueue.ToArray();
6957 if (queueArray.Length == 0)
6958 {
6959 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No tabs for NextTab", this, GPALObjectType.PuppeteerCommunicator);
6960 return null;
6961 }
6962
6963 // find where we are by target id, the way PreviousTab already does. The stored ActiveTabIndex is a
6964 // position, and a position points at the wrong tab as soon as a tab is added or removed. It stays as
6965 // the fallback for the case where the id is not in the queue at all
6966 var currentIndex = Array.FindIndex(queueArray, kvp => kvp.Key == currentTargetId);
6967 if (currentIndex == -1)
6968 {
6969 currentIndex = session.ActiveTabIndex;
6970 }
6971
6972 var nextIndex = (currentIndex + 1) % queueArray.Length;
6973 var nextTargetId = await GoToTab(nextIndex).ConfigureAwait(false);
6974
6975 session.ActiveTabIndex = nextIndex;
6976
6977 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
6978 $"Switched to index [{nextIndex}], ID [{nextTargetId}], Active window tab queue size [{queueArray.Length}]",
6979 this, GPALObjectType.PuppeteerCommunicator);
6981 return nextTargetId;
6982 }
6983 /*
6984 public async Task<string> NextTab(string currentTargetId)
6985 {
6986 var windowArray = _windowSessionsQueue.ToArray();
6987 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
6988 {
6989 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No active window for NextTab", this, GPALObjectType.PuppeteerCommunicator);
6990 return null;
6991 }
6992
6993 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
6994 var queueArray = tabQueue.ToArray();
6995 if (queueArray.Length == 0)
6996 {
6997 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No tabs for NextTab", this, GPALObjectType.PuppeteerCommunicator);
6998 return null;
6999 }
7000
7001 var currentIndex = windowArray[_activeWindowIndex].ActiveTabIndex;
7002
7003 var nextIndex = (currentIndex + 1) % queueArray.Length;
7004 var nextTargetId = await GoToTab(nextIndex);
7005
7006 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Switched to index [{nextIndex}], ID [{nextTargetId}], Active window tab queue size [{queueArray.Length}]", this, GPALObjectType.PuppeteerCommunicator);
7007
7008 return nextTargetId;
7010 */
7011 // Implementation for "next-window"
7017
7019 public async Task<string> NextWindow(string currentTargetId)
7020 {
7021 var windowArray = _windowSessionsQueue.ToArray();
7022 if (windowArray.Length == 0)
7023 {
7024 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No windows for NextWindow", this, GPALObjectType.PuppeteerCommunicator);
7025 return null;
7026 }
7027
7028 var currentWindowIndex = -1;
7029 for (int i = 0; i < windowArray.Length; i++)
7030 {
7031 var tabArray = windowArray[i].TabQueue.ToArray();
7032 if (Array.Exists(tabArray, kvp => kvp.Key == currentTargetId))
7033 {
7034 currentWindowIndex = i;
7035 break;
7036 }
7037 }
7038 if (currentWindowIndex == -1)
7039 {
7040 currentWindowIndex = _activeWindowIndex;
7041 }
7042
7043 var nextWindowIndex = (currentWindowIndex + 1) % windowArray.Length;
7044 SetActiveWindowIndex(nextWindowIndex);
7045
7046 var nextWindowContextId = windowArray[nextWindowIndex].BrowserContextId;
7047 var nextTabQueue = windowArray[nextWindowIndex].TabQueue;
7048 var nextTabArray = nextTabQueue.ToArray();
7049 if (nextTabArray.Length == 0)
7050 {
7051 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "No tabs in next window for NextWindow", this, GPALObjectType.PuppeteerCommunicator);
7052 return null;
7053 }
7054
7055 var nextTargetId = nextTabArray[windowArray[nextWindowIndex].ActiveTabIndex].Key;
7056 var nextSessionId = nextTabArray[windowArray[nextWindowIndex].ActiveTabIndex].Value;
7057
7058 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId = nextTargetId }, null).ConfigureAwait(false);
7059
7060 if (await SendCommand<object>(DevToolsMethods.PageEnable, new { }, nextSessionId).ConfigureAwait(false) == null)
7061 {
7062 var newSession = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new { targetId = nextTargetId, flatten = true }, null).ConfigureAwait(false);
7063 if (newSession?.sessionId != null)
7064 {
7065 nextSessionId = (string)newSession.sessionId;
7066 RemoveTabFromQueue(nextTargetId);
7067 await AddTabToQueue(nextTargetId, nextSessionId).ConfigureAwait(false);
7068 }
7069 else
7071 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to re-attach NextWindow tab ID [{nextTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
7072 return null;
7073 }
7074 await SendCommand<object>(DevToolsMethods.PageDisable, new { }, nextSessionId).ConfigureAwait(false);
7075 }
7076 else
7077 {
7078 await SendCommand<object>(DevToolsMethods.PageDisable, new { }, nextSessionId).ConfigureAwait(false);
7079 }
7080
7081 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"NextWindow: Switched to window index [{nextWindowIndex}], Context [{nextWindowContextId}], Tab ID [{nextTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
7082 return nextTargetId;
7083 }
7084 // Implementation for "normal"
7085 /// <summary>
7086 /// Restores the browser window to its normal (non-maximized, non-minimized) state.
7087 /// Implementation for the "normal" workflow action.
7088 /// </summary>
7089 /// <param name="sessionId">CDP session ID; falls back to the current effective session if null.</param>
7090 /// <returns>Task that resolves to true once the window state has been set to normal.</returns>
7091 public async Task<bool> Normal(string sessionId = null)
7092 {
7093 sessionId ??= GetEffectiveSessionId();
7094
7095 var windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
7096 await SendCommand(DevToolsMethods.BrowserSetWindowBounds, new
7097 {
7098 windowId = windowId,
7099 bounds = new { windowState = "normal" }
7100 }, sessionId).ConfigureAwait(false);
7101
7102 return true;
7103 }
7104
7105 // Implementation for "open-window"
7114 public async Task<string> OpenWindow(string url = "https://google.com", string sessionId = null)
7115 {
7116 sessionId ??= GetEffectiveSessionId();
7117
7118 // Create the new target in its own browser context
7119 var context = await SendCommand<object>(DevToolsMethods.TargetCreateBrowserContext, new { }, sessionId).ConfigureAwait(false);
7120 string browserContextId = context.browserContextId.ToString();
7121
7122 var result = await SendCommand<object>(DevToolsMethods.TargetCreateTarget,
7123 new { url, browserContextId }, null).ConfigureAwait(false);
7124
7125 string targetId = result.targetId.ToString();
7126
7127 // Activate and attach
7128 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId }, null).ConfigureAwait(false);
7129 var session = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget,
7130 new { targetId, flatten = true }, null).ConfigureAwait(false);
7131
7132 string newSessionId = session.sessionId.ToString();
7133
7134 // Build tab queue for this window
7135 var tabQueue = new ConcurrentQueue<KeyValuePair<string, string>>();
7136 tabQueue.Enqueue(new KeyValuePair<string, string>(targetId, newSessionId));
7137
7138 // Add new window with its tab queue into _windowSessionsQueue
7139 _windowSessionsQueue.Enqueue(new WindowSession(browserContextId, tabQueue, 0));
7140
7141 // Update active window index to the newest one
7142 SetActiveWindowIndex(_windowSessionsQueue.Count - 1);
7143
7144 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
7145
7146 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
7147 $"Opened new window ContextId [{browserContextId}], TargetId [{targetId}], SessionId [{newSessionId}]",
7148 this, GPALObjectType.PuppeteerCommunicator);
7149
7150 return targetId;
7151 }
7152 // Implementation for "override-referrer"
7160 public async Task OverrideReferrer(string url, string sessionId = null)
7161 {
7162 sessionId ??= GetEffectiveSessionId();
7163 await SendCommand<object>(DevToolsMethods.NetworkSetExtraHTTPHeaders, new { headers = new { Referer = url } }, sessionId).ConfigureAwait(false);
7164 }
7165
7166 // Implementation for "set-useragent"
7174 public async Task<dynamic> SetUserAgent(string userAgent, string sessionId = null)
7175 {
7176 sessionId ??= GetEffectiveSessionId();
7177 return await SendCommand<object>(DevToolsMethods.NetworkSetUserAgentOverride, new { userAgent }, sessionId).ConfigureAwait(false);
7178 }
7179
7180 // Implementation for "page-down"
7181 // PageDown: Scrolls down by one or more pages
7189 public async Task PageDown(int pagesToScroll = 1, string sessionId = null)
7190 {
7191 sessionId ??= GetEffectiveSessionId();
7192 for (int i = 0; i < pagesToScroll; i++)
7193 {
7194 await ScrollPageAsync("down", pagesToScroll, sessionId).ConfigureAwait(false);
7195 }
7196 }
7197
7198 // PageEnd: Scrolls to the bottom of the page
7199 /// <summary>
7200 /// Scrolls to the very bottom of the page.
7201 /// Implementation for the "page-end" workflow action.
7202 /// </summary>
7203 /// <param name="sessionId">CDP session ID; falls back to the current effective session if null.</param>
7204 /// <returns>Task representing the asynchronous scroll operation.</returns>
7205 public async Task PageEnd(string sessionId = null)
7206 {
7207 sessionId ??= GetEffectiveSessionId();
7208 await ScrollToPositionAsync("end", sessionId).ConfigureAwait(false);
7209 }
7210
7211 // PageTop: Scrolls to the top of the page
7218 public async Task PageTop(string sessionId = null)
7219 {
7220 sessionId ??= GetEffectiveSessionId();
7221 await ScrollToPositionAsync("top", sessionId).ConfigureAwait(false);
7222 }
7223
7224 // PageUp: Scrolls up by one or more pages
7232 public async Task PageUp(int pagesToScroll = 1, string sessionId = null)
7233 {
7234 sessionId ??= GetEffectiveSessionId();
7235 for (int i = 0; i < pagesToScroll; i++)
7236 {
7237 await ScrollPageAsync("up", pagesToScroll, sessionId).ConfigureAwait(false);
7238 }
7239 }
7240
7241 // Implementation for "press-modifier-key"
7250 public async Task PressModifierKey(int modifierKeys, string sessionId = null)
7251 {
7252 sessionId ??= GetEffectiveSessionId();
7253 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, new { type = "keyDown", modifiers = modifierKeys }, sessionId).ConfigureAwait(false);
7254 }
7255
7256 // Implementation for "previous-tab"
7264
7265 public async Task<string> PreviousTab(string currentTargetId)
7266 {
7267 var windowArray = _windowSessionsQueue.ToArray();
7268 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
7269 {
7270 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No active window for PreviousTab", this, GPALObjectType.PuppeteerCommunicator);
7271 return null;
7272 }
7273
7274 var session = windowArray[_activeWindowIndex];
7275 var tabQueue = session.TabQueue;
7276 var browserContextId = session.BrowserContextId; // Use stored BrowserContextId
7277
7278 // Poll all targets via CDP to discover unknown pages
7279 var targetsResponse = await SendCommand<dynamic>(
7280 DevToolsMethods.TargetGetTargets,
7281 new { },
7282 null // Browser-level command, no sessionId
7283 ).ConfigureAwait(false);
7284
7285 if (targetsResponse?.targetInfos == null)
7286 {
7287 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Failed to get targets list in NextTab sync", this, GPALObjectType.PuppeteerCommunicator);
7288 // Proceed anyway with existing queue
7289 }
7290 else
7291 {
7292 var knownTargetIds = new HashSet<string>(tabQueue.Select(kv => kv.Key));
7293
7294 foreach (var targetInfo in targetsResponse.targetInfos)
7295 {
7296 string targetId = targetInfo.targetId?.ToString();
7297 string type = targetInfo.type?.ToString();
7298 string contextId = targetInfo.browserContextId?.ToString();
7299
7300 if (string.IsNullOrEmpty(targetId) || type != "page" || contextId != browserContextId)
7301 continue;
7303 if (!knownTargetIds.Contains(targetId))
7304 {
7305 // Add with doNotGetSendSemaphore=true to avoid reentrancy/locking issues
7306 await AddTabToQueue(targetId, null, false, true).ConfigureAwait(false);
7307 knownTargetIds.Add(targetId);
7308 }
7309 }
7310 }
7311
7312 var queueArray = tabQueue.ToArray();
7313 if (queueArray.Length == 0)
7314 {
7315 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No tabs for PreviousTab", this, GPALObjectType.PuppeteerCommunicator);
7316 return null;
7317 }
7319 var currentIndex = Array.FindIndex(queueArray, kvp => kvp.Key == currentTargetId);
7320 if (currentIndex == -1)
7321 {
7322 currentIndex = windowArray[_activeWindowIndex].ActiveTabIndex;
7323 }
7324
7325 var prevIndex = (currentIndex - 1 + queueArray.Length) % queueArray.Length;
7326 var prevTargetId = queueArray[prevIndex].Key;
7327 var prevSessionId = queueArray[prevIndex].Value;
7328
7329 // Update ActiveTabIndex in WindowSession
7330 var windowList = _windowSessionsQueue.ToList();
7331 windowList[_activeWindowIndex] = new WindowSession(
7332 windowList[_activeWindowIndex].BrowserContextId,
7333 tabQueue,
7334 prevIndex
7335 );
7336 _windowSessionsQueue = new ConcurrentQueue<WindowSession>();
7337 foreach (var w in windowList)
7338 {
7339 _windowSessionsQueue.Enqueue(w);
7340 }
7341
7342 ((PuppeteerClient)PuppeteerClient)._currentTargetId = prevTargetId;
7343
7344 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId = prevTargetId }, null).ConfigureAwait(false);
7345
7346 if (await SendCommand<object>(DevToolsMethods.PageEnable, new { }, prevSessionId).ConfigureAwait(false) == null)
7347 {
7348 var newSession = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new { targetId = prevTargetId, flatten = true }, null).ConfigureAwait(false);
7349 if (newSession?.sessionId != null)
7350 {
7351 prevSessionId = (string)newSession.sessionId;
7352 RemoveTabFromQueue(prevTargetId);
7353 await AddTabToQueue(prevTargetId, prevSessionId).ConfigureAwait(false);
7354 }
7355 else
7356 {
7357 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to re-attach PreviousTab ID [{prevTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
7358 return null;
7359 }
7360 await SendCommand<object>(DevToolsMethods.PageDisable, new { }, prevSessionId).ConfigureAwait(false);
7361 }
7362 else
7363 {
7364 await SendCommand<object>(DevToolsMethods.PageDisable, new { }, prevSessionId).ConfigureAwait(false);
7365 }
7366
7367 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"PreviousTab: Switched to index [{prevIndex}], ID [{prevTargetId}], Active window tab queue size [{queueArray.Length}]", this, GPALObjectType.PuppeteerCommunicator);
7368 return prevTargetId;
7369 }
7370
7371 // Implementation for "previous-window"
7374 /// activating that window's currently active tab and re-attaching the CDP session if needed.
7375 /// Implementation for the "previous-window" workflow action.
7376 /// </summary>
7377 /// <param name="currentTargetId">Target ID of the currently active tab, used to determine which window is currently active.</param>
7378 /// <returns>Task that resolves to the target ID of the active tab in the previous window, or null if there are no windows or no tabs in the previous window.</returns>
7379 public async Task<string> PreviousWindow(string currentTargetId)
7380 {
7381 var windowArray = _windowSessionsQueue.ToArray();
7382 if (windowArray.Length == 0)
7383 {
7384 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No windows for PreviousWindow", this, GPALObjectType.PuppeteerCommunicator);
7385 return null;
7386 }
7387
7388 var currentWindowIndex = -1;
7389 for (int i = 0; i < windowArray.Length; i++)
7390 {
7391 var tabArray = windowArray[i].TabQueue.ToArray();
7392 if (Array.Exists(tabArray, kvp => kvp.Key == currentTargetId))
7393 {
7394 currentWindowIndex = i;
7395 break;
7396 }
7397 }
7398 if (currentWindowIndex == -1)
7399 {
7400 currentWindowIndex = _activeWindowIndex;
7402
7403 var prevWindowIndex = (currentWindowIndex - 1 + windowArray.Length) % windowArray.Length;
7404 SetActiveWindowIndex(prevWindowIndex);
7405
7406 var prevWindowContextId = windowArray[prevWindowIndex].BrowserContextId;
7407 var prevTabQueue = windowArray[prevWindowIndex].TabQueue;
7408 var prevTabArray = prevTabQueue.ToArray();
7409 if (prevTabArray.Length == 0)
7410 {
7411 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"No tabs in previous window for PreviousWindow (context [{prevWindowContextId}])", this, GPALObjectType.PuppeteerCommunicator);
7412 return null;
7413 }
7414
7415 var prevTargetId = prevTabArray[windowArray[prevWindowIndex].ActiveTabIndex].Key;
7416 var prevSessionId = prevTabArray[windowArray[prevWindowIndex].ActiveTabIndex].Value;
7417
7418 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId = prevTargetId }, null).ConfigureAwait(false);
7419
7420 if (await SendCommand<object>(DevToolsMethods.PageEnable, new { }, prevSessionId).ConfigureAwait(false) == null)
7421 {
7422 var newSession = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new { targetId = prevTargetId, flatten = true }, null).ConfigureAwait(false);
7423 if (newSession?.sessionId != null)
7425 prevSessionId = (string)newSession.sessionId;
7426 RemoveTabFromQueue(prevTargetId);
7427 await AddTabToQueue(prevTargetId, prevSessionId).ConfigureAwait(false);
7428 }
7429 else
7430 {
7431 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to re-attach PreviousWindow tab ID [{prevTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
7432 return null;
7433 }
7434 await SendCommand<object>(DevToolsMethods.PageDisable, new { }, prevSessionId).ConfigureAwait(false);
7435 }
7436 else
7437 {
7438 await SendCommand<object>(DevToolsMethods.PageDisable, new { }, prevSessionId).ConfigureAwait(false);
7439 }
7440
7441 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"PreviousWindow: Switched to window index [{prevWindowIndex}], Context [{prevWindowContextId}], Tab ID [{prevTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
7442 return prevTargetId;
7443 } // Implementation for "query-selector"
7450
7451 public async Task<dynamic> QuerySelector(string css, string sessionId = null)
7452 {
7453 sessionId ??= GetEffectiveSessionId();
7454 return await QueryByCss(css, sessionId).ConfigureAwait(false);
7455 }
7456
7464 internal async Task<long> DocumentMark(string sessionId = null)
7465 {
7466 sessionId ??= GetEffectiveSessionId();
7467
7468 var doc = await SendCommand<object>(DevToolsMethods.DOMGetDocument, new { depth = 0 }, sessionId).ConfigureAwait(false);
7469 long mark = null == doc || null == doc.root ? 0 : (long)doc.root.backendNodeId;
7470
7471 return mark;
7472 }
7473
7474 // Implementation for "query-selectors"
7482 public async Task<dynamic> QuerySelectors(string css, string sessionId = null)
7483 {
7484 sessionId ??= GetEffectiveSessionId();
7485
7486 var doc = await SendCommand<object>(DevToolsMethods.DOMGetDocument, new { depth = 0 }, sessionId).ConfigureAwait(false);
7487
7488 return await SendCommand<object>(DevToolsMethods.DOMQuerySelectorAll, new { nodeId = (int)doc.root.nodeId, selector = css }, sessionId).ConfigureAwait(false);
7489 }
7490
7491 // Implementation for "refresh"
7498 public async Task Refresh(string sessionId = null)
7499 {
7500 sessionId ??= GetEffectiveSessionId();
7501 await SendCommand<object>(DevToolsMethods.PageReload, new { }, sessionId).ConfigureAwait(false);
7502 }
7503
7504 // Implementation for "release-modifier-key"
7513 public async Task ReleaseModifierKey(int modifierKeys, string sessionId = null)
7514 {
7515 sessionId ??= GetEffectiveSessionId();
7516 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, new { type = "keyUp", modifiers = modifierKeys }, sessionId).ConfigureAwait(false);
7517 }
7518
7519 // Implementation for "restore"
7526 public async Task<bool> Restore(string sessionId = null)
7527 {
7528 sessionId ??= GetEffectiveSessionId();
7529
7530 var windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
7531 await SendCommand(DevToolsMethods.BrowserSetWindowBounds, new
7532 {
7533 windowId = windowId,
7534 bounds = new { windowState = "normal" }
7535 }, sessionId).ConfigureAwait(false);
7536
7537 return true;
7538 }
7539
7540 // Implementation for "middle-click"
7554 public async Task MiddleClick(string elementId, string sessionId = null)
7555 {
7556 sessionId ??= GetEffectiveSessionId();
7557 List<GPALElement> elems = await EvaluateSelector(elementId, sessionId).ConfigureAwait(false);
7558 List<dynamic> responses = new List<dynamic>();
7559
7560 foreach (GPALElement elem in elems)
7561 {
7562 await ScrollIntoView(elem, sessionId).ConfigureAwait(false);
7563 await ClickElement(elem, sessionId, responses, ClickType.MiddleClick).ConfigureAwait(false);
7564 }
7565 }
7566
7567 // Implementation for "right-click"
7581 public async Task RightClick(string elementId, string sessionId = null)
7582 {
7583 sessionId ??= GetEffectiveSessionId();
7584 List<GPALElement> elems = await EvaluateSelector(elementId, sessionId).ConfigureAwait(false);
7585 List<dynamic> responses = new List<dynamic>();
7586
7587 foreach (GPALElement elem in elems)
7588 {
7589 await ScrollIntoView(elem, sessionId).ConfigureAwait(false);
7590 await ClickElement(elem, sessionId, responses, ClickType.RightClick).ConfigureAwait(false);
7591 }
7592 }
7593
7594 // Implementation for "right-click-download"
7604 public async Task RightClickAndDownload(string elementId, string savePath, string sessionId = null)
7605 {
7606 await SendCommand<object>(DevToolsMethods.PageSetDownloadBehavior, new { behavior = "allow", downloadPath = savePath }, sessionId).ConfigureAwait(false);
7607 await RightClick(elementId, sessionId).ConfigureAwait(false);
7608 }
7609
7610 // Implementation for "scroll-element"
7621 public async Task ScrollElement(string elementId, int hPixels, int vPixels, string sessionId = null)
7622 {
7623 sessionId ??= GetEffectiveSessionId();
7624 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
7625 new {
7626 contextId = CurrentContextId,
7627 expression = "document.getElementById('" + elementId + "').scrollBy(" + hPixels + ", " + vPixels + ")"
7628 }, sessionId).ConfigureAwait(false);
7629 }
7630
7631 // Implementation for "scroll-into-view"
7648 public async Task<bool> ScrollIntoView(GPALElement element, string sessionId = null)
7649 {
7650 int backendNodeId = element.ElementBackendNodeId;
7651
7652 sessionId ??= GetEffectiveSessionId();
7653
7654 try
7655 {
7656 if ("GPALElement".Equals(element.TagName))
7657 return true; // it's already in view if it's a matched image
7658
7659 // force layout engine to run before we interact
7660 if (true == Browser.BrowserSettings.UseHeadless)
7661 {
7662 // force layout
7663 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
7664 new {
7665 contextId = CurrentContextId,
7666 expression = @"
7667 document.body.style.zoom = '100%'; // minor reflow trigger
7668 window.dispatchEvent(new Event('resize'));
7669 document.documentElement.scrollTop += 1; document.documentElement.scrollTop -= 1;"
7670 }, sessionId).ConfigureAwait(false);
7671 }
7672
7673 // keep the items from bouncing around if they are already in view
7674 await SendCommand(DevToolsMethods.RuntimeEvaluate, new
7675 {
7676 expression = @"
7677 if (!document.querySelector('style[data-gpal-overscroll]')) {
7678 const style = document.createElement('style');
7679 style.setAttribute('data-gpal-overscroll', 'true');
7680 style.textContent = `
7681 html, body {
7682 overscroll-behavior: none !important;
7683 overscroll-behavior-x: none !important;
7684 overscroll-behavior-y: none !important;
7685 }
7686 `;
7687 (document.head || document.documentElement).appendChild(style);
7688 }
7689 ",
7690 returnByValue = true
7691 }, sessionId).ConfigureAwait(false);
7692
7693 // Try native CDP scroll first (quick & usually sufficient when element is renderable)
7694 var scrollResult = await SendCommand<object>(
7695 DevToolsMethods.DOMScrollIntoViewIfNeeded,
7696 new { backendNodeId },
7697 sessionId
7698 ).ConfigureAwait(false);
7699
7700 if (true == Browser.BrowserSettings.UseHeadless)
7701 await Task.Delay(250).ConfigureAwait(false); // Give layout/paint time to settle
7702
7703 if (null == scrollResult)
7704 {
7705 // Escape properly for JS string literal (handles quotes, backslashes)
7706 var escapedSelector = element.Css.Replace(@"\", @"\\").Replace("'", @"\'");
7707
7708 var callResult = await SendCommand<dynamic>(
7709 DevToolsMethods.RuntimeEvaluate,
7710 new
7711 {
7712 expression = $@"
7713 (function() {{
7714 const el = document.querySelector(""{escapedSelector}"");
7715 if (!el) {{
7716 console.warn('Element not found by selector: {escapedSelector}');
7717 return false;
7718 }}
7719
7720 el.scrollIntoView({{
7721 behavior: 'instant',
7722 block: 'center',
7723 inline: 'center'
7724 }});
7725
7726 // Force reflow/layout
7727 void el.getBoundingClientRect();
7728 void el.offsetHeight;
7729
7730 return new Promise(r => requestAnimationFrame(() => r(true)));
7731 }})()",
7732 awaitPromise = true,
7733 contextId = CurrentContextId,
7734 returnByValue = true
7735 },
7736 sessionId
7737 ).ConfigureAwait(false);
7738
7739 //var success = callResult?.result?.value as bool? ?? false;
7740
7741 //if (!success)
7742 //{
7743 // GPAL.PublishSimpleEvent(GPALEventType.WARNING,
7744 // $"JS scroll fallback failedfor element [{element.Css}]",
7745 // this, GPALObjectType.PuppeteerCommunicator);
7746 // // still return true if you want fail-forward, or false if stricter
7747 //}
7748
7749 // Assume recovery succeeded after JS — we don't re-check box model
7750 // we don't care about the return value, what else are we going to do, we have nothing to do
7751 }
7752
7753 return true;
7754 }
7755 catch (Exception ex)
7756 {
7757 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
7758 $"ScrollIntoView failed",
7759 this, GPALObjectType.PuppeteerCommunicator, ex);
7760 return false;
7761 }
7762 }
7763 /*
7764 public async Task<dynamic> ScrollIntoView(int backendNodeId, string sessionId = null)
7765 {
7766 sessionId ??= GetEffectiveSessionId();
7767
7768 try
7769 {
7770 // === STEP 0: Let Chromium handle ALL scroll containers ===
7771 await SendCommand<object>(
7772 DevToolsMethods.DOMScrollIntoViewIfNeeded,
7773 new { backendNodeId },
7774 sessionId
7775 );
7776
7777 await Task.Delay(75); // allow paint tree to settle
7778
7779 // === STEP 1: Get fresh box model AFTER scrolling ===
7780 var boxResult = await SendCommand<object>(
7781 DevToolsMethods.DOMGetBoxModel,
7782 new { backendNodeId },
7783 sessionId
7784 );
7785
7786 if (boxResult?.model?.content == null)
7787 {
7788 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
7789 $"No box model for node [{backendNodeId}]",
7790 this, GPALObjectType.PuppeteerCommunicator);
7791 return new { success = false };
7792 }
7793
7794 var content = boxResult.model.content;
7795
7796 double elX = content[0];
7797 double elY = content[1];
7798 double elW = content[2] - content[0];
7799 double elH = content[7] - content[1];
7800
7801 // === STEP 2: Get viewport ===
7802 var vpResult = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
7803 {
7804 expression = "({ height: window.innerHeight, width: window.innerWidth })",
7805 returnByValue = true
7806 }, sessionId);
7807
7808 int vpH = vpResult?.result?.value?.height ?? 0;
7809 int vpW = vpResult?.result?.value?.width ?? 0;
7810
7811 if (vpH == 0 || vpW == 0)
7812 return new { success = false };
7813
7814 // === STEP 3: Clamp to viewport (now actually meaningful) ===
7815 double visLeft = Math.Max(elX, 0);
7816 double visTop = Math.Max(elY, 0);
7817 double visRight = Math.Min(elX + elW, vpW);
7818 double visBottom = Math.Min(elY + elH, vpH);
7819
7820 if (visLeft >= visRight || visTop >= visBottom)
7821 {
7822 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
7823 $"Element [{backendNodeId}] not visible after CDP scroll",
7824 this, GPALObjectType.PuppeteerCommunicator);
7825 return new { success = false };
7826 }
7827
7828 int clickX = (int)((visLeft + visRight) / 2);
7829 int clickY = (int)((visTop + visBottom) / 2);
7831 return new
7832 {
7833 success = true,
7834 point = new { x = clickX, y = clickY }
7835 };
7836 }
7837 catch (Exception ex)
7838 {
7839 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
7840 $"ScrollIntoView failed for node [{backendNodeId}]",
7841 this, GPALObjectType.PuppeteerCommunicator, ex);
7842 return new { success = false };
7843 }
7844 }
7845 */
7846 // Implementation for "scroll-window"
7856 public async Task ScrollWindow(int hPixels, int vPixels, string sessionId = null)
7857 {
7858 sessionId ??= GetEffectiveSessionId();
7859 await ScrollWindowByPixelsAsync(hPixels, vPixels, sessionId).ConfigureAwait(false);
7860 await Task.Delay(150).ConfigureAwait(false); // NOTE: for headless
7861 }
7862
7863 // Implementation for "send-key"
7875 public async Task<dynamic> SendKey(string key, string code, int vkCode, string sessionId = null)
7876 {
7877 sessionId ??= GetEffectiveSessionId();
7878
7879 try
7880 {
7881 // For forms to submit on Enter, Chrome requires the text property to be "\r".
7882 // Otherwise, printable characters use their literal string value.
7883 string textPayload = (code == "Enter") ? "\r" : (key.Length == 1 ? key : null);
7884
7885 // 1. Dispatch Key Down
7886 var keyDownParams = new
7887 {
7888 type = "rawKeyDown",
7889 key = key,
7890 code = code,
7891 windowsVirtualKeyCode = vkCode,
7892 nativeVirtualKeyCode = vkCode
7893 };
7894 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, keyDownParams, sessionId).ConfigureAwait(false);
7895
7896 // 2. Dispatch Char (Only if the key sequence produces an explicit input character)
7897 if (textPayload != null)
7898 {
7899 var charParams = new
7900 {
7901 type = "char",
7902 text = textPayload,
7903 unmodifiedText = textPayload,
7904 key = key,
7905 code = code,
7906 windowsVirtualKeyCode = vkCode,
7907 nativeVirtualKeyCode = vkCode
7909 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, charParams, sessionId).ConfigureAwait(false);
7910 }
7911
7912 // 3. Dispatch Key Up
7913 var keyUpParams = new
7914 {
7915 type = "keyUp",
7916 key = key,
7917 code = code,
7918 windowsVirtualKeyCode = vkCode,
7919 nativeVirtualKeyCode = vkCode
7920 };
7921 await SendCommand<dynamic>(DevToolsMethods.InputDispatchKeyEvent, keyUpParams, sessionId).ConfigureAwait(false);
7922 }
7923 catch (Exception ex)
7925 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"SendKey execution failed for Code [{code}]", this, GPALObjectType.PuppeteerCommunicator, ex);
7926 return false;
7927 }
7928 return true;
7929 }
7930
7934
7937 private int GetVirtualKeyCode(string key)
7938 {
7939 if (string.IsNullOrEmpty(key))
7940 return 0;
7941
7942 string k = key.ToLowerInvariant();
7943
7944 switch (k)
7945 {
7946 case "enter":
7947 case "return":
7948 return 0x0D;
7949
7950 case "tab":
7951 return 0x09;
7952
7953 case "backspace":
7954 return 0x08;
7955
7956 case "delete":
7957 case "del":
7958 return 0x2E;
7959
7960 case "escape":
7961 case "esc":
7962 return 0x1B;
7963
7964 case "space":
7965 return 0x20;
7966
7967 case "arrowup":
7968 case "up":
7969 return 0x26;
7970
7971 case "arrowdown":
7972 case "down":
7973 return 0x28;
7974
7975 case "arrowleft":
7976 case "left":
7977 return 0x25;
7978
7979 case "arrowright":
7980 case "right":
7981 return 0x27;
7982
7983 case "home":
7984 return 0x24;
7985
7986 case "end":
7987 return 0x23;
7988
7989 case "pageup":
7990 return 0x21;
7991
7992 case "pagedown":
7993 return 0x22;
7994
7995 case "insert":
7996 return 0x2D;
7997
7998 case "f1": return 0x70;
7999 case "f2": return 0x71;
8000 case "f3": return 0x72;
8001 case "f4": return 0x73;
8002 case "f5": return 0x74;
8003 case "f6": return 0x75;
8004 case "f7": return 0x76;
8005 case "f8": return 0x77;
8006 case "f9": return 0x78;
8007 case "f10": return 0x79;
8008 case "f11": return 0x7A;
8009 case "f12": return 0x7B;
8010
8011 default:
8012 return 0; // letters, numbers, symbols etc.
8013 }
8014 }
8015
8016 // Implementation for "send-string"
8017 // NOTE: same as fillin
8029 public async Task SendString(string text, int delayMs = 0, string sessionId = null)
8030 {
8031 sessionId ??= GetEffectiveSessionId();
8032 if (0 == delayMs)
8033 await SendCommand<object>(DevToolsMethods.InputInsertText, new { text }, sessionId).ConfigureAwait(false);
8034 else
8035 await DispatchTextCharByChar(text, delayMs, sessionId).ConfigureAwait(false);
8036 }
8037
8047 private async Task DispatchTextCharByChar(string text, int delayMs, string sessionId)
8048 {
8049 bool first = true;
8050 foreach (char c in text)
8051 {
8052 if (false == first && 0 < delayMs)
8053 await Task.Delay(HardwareHelper.GetTypingDelay(delayMs)).ConfigureAwait(false);
8054
8055 string key = c.ToString();
8056 int vkCode = (int)c; // Basic; improve with real VK map for specials/shifts
8057
8058 await SendCommand(DevToolsMethods.InputDispatchKeyEvent, new
8059 {
8060 type = "keyDown",
8061 text = key, // for printable chars
8062 unmodifiedtext = key,
8063 key = key,
8064 code = GetKeyCode(c), // e.g. "KeyA" for 'a'/'A'
8065 windowsVirtualKeyCode = vkCode,
8066 nativeVirtualKeyCode = vkCode
8067 }, sessionId).ConfigureAwait(false);
8068
8069 await SendCommand(DevToolsMethods.InputDispatchKeyEvent, new
8070 {
8071 type = "keyUp",
8072 key = key,
8073 code = GetKeyCode(c),
8074 windowsVirtualKeyCode = vkCode
8075 }, sessionId).ConfigureAwait(false);
8076
8077 first = false;
8078 }
8079 }
8080
8081 // Implementation for "set-attribute"
8091 public async Task<dynamic> SetAttribute(int backendNodeId, string attribute, string value, string sessionId = null)
8092 {
8093 sessionId ??= GetEffectiveSessionId();
8094 var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, sessionId).ConfigureAwait(false);
8095 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Set attribute [{attribute}] to [{value}] on backendNodId [{backendNodeId}]", this, GPALObjectType.PuppeteerCommunicator);
8096 return await SendCommand<object>(DevToolsMethods.DOMSetAttributeValue, new { nodeId, name = attribute, value }, sessionId).ConfigureAwait(false);
8097 }
8098
8099 // Implementation for "set-download-filename"
8107 public async Task SetDownloadFilename(string downloadPath, string sessionId = null)
8108 {
8109 sessionId ??= GetEffectiveSessionId();
8110 await SendCommand<object>(DevToolsMethods.PageSetDownloadBehavior, new { behavior = "allow", downloadPath }, sessionId).ConfigureAwait(false);
8111 }
8112
8113 // Implementation for "scroll-element"
8123 public async Task<dynamic> SetRange(string elementId, string rangeValue, string sessionId = null)
8124 {
8125 sessionId ??= GetEffectiveSessionId();
8126 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8127 new
8128 {
8129 contextId = CurrentContextId,
8130 expression = $@"var s = document.querySelector('{elementId}'); s.value = {rangeValue}; s.dispatchEvent(new Event('input', {{bubbles: true}})); s.dispatchEvent(new Event('change', {{bubbles: true}}));"
8131 }, sessionId).ConfigureAwait(false);
8132 }
8133
8134 // Implementation for "top-browser"
8141 public async Task TopBrowser(string sessionId = null)
8142 {
8143 // Page.bringToFront is top-level only, so this must never go to an iframe session - once we are inside
8144 // a cross-origin frame the effective session is that frame's and CDP rejects the command outright.
8145 // sessionId is deliberately ignored: any session that is not the top-level page target is wrong here
8146 await SendCommand<object>(DevToolsMethods.PageBringToFront, new { }, GetCurrentSessionId()).ConfigureAwait(false);
8147 }
8148
8150
8161 public async Task<bool> SetStorage(
8162 string storageType,
8163 string sessionId,
8164 string domain,
8165 string key,
8166 string data,
8167 string storeName = null,
8168 string path = null)
8169 {
8170 bool retVal = false;
8171 sessionId ??= GetEffectiveSessionId();
8172
8173 try
8174 {
8175 switch (storageType)
8176 {
8177 case "cookie":
8178 {
8179 IDictionary<string, object> cookieToSet;
8180
8181 path ??= "/";
8182 domain ??= ""; // or handle wildcard logic as before
8183
8184 // 1. Get current cookies via CDP
8185 dynamic cookiesResp = await GetStorage(storageType, sessionId, domain, storeName, path, key).ConfigureAwait(false);
8186
8187 // 2. Find the existing cookie (exact match on name + domain + path)
8188 dynamic existing = null;
8189
8190 if (cookiesResp != null)
8191 {
8192 var cookies = JsonConvert.DeserializeObject<List<dynamic>>(cookiesResp) ?? new List<dynamic>();
8193 foreach (dynamic c in cookies)
8195 string cDomain = ((dynamic)c).Domain ?? "";
8196 string cPath = ((dynamic)c).Path ?? "/";
8197 string cName = ((dynamic)c).Key ?? "";
8198
8199 if (cName == key &&
8200 UrlHelper.CookieDomainMatches(cDomain, domain) &&
8201 cPath == path)
8202 {
8203 existing = c;
8204
8205 cookieToSet = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
8206
8207 cookieToSet["name"] = key;
8208 cookieToSet["value"] = data;
8209 cookieToSet["domain"] = ((dynamic)existing)?.Domain?.ToString();
8210 cookieToSet["path"] = path;
8212 cookieToSet["expires"] = ((dynamic)existing)?.Expires != null
8213 ? (double)existing.Expires
8214 : DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (180L * 24 * 60 * 60); // ~6 months
8215
8216 cookieToSet["secure"] = ((dynamic)existing)?.Secure ?? true;
8217 cookieToSet["httpOnly"] = ((dynamic)existing)?.HttpOnly ?? true;
8218 cookieToSet["sameSite"] = ((dynamic)existing)?.SameSite?.ToString() ?? "None";
8219
8220 cookieToSet["session"] = ((dynamic)existing)?.Session ?? false;
8221 cookieToSet["priority"] = ((dynamic)existing)?.Priority?.ToString() ?? "Medium";
8222
8223 cookieToSet["sourceScheme"] = ((dynamic)existing)?.SourceScheme?.ToString() ?? "Secure";
8224 cookieToSet["sourcePort"] = ((dynamic)existing)?.SourcePort ?? 443;
8225
8226 // Only add partitionKey if it actually exists and is not null
8227 if (((dynamic)existing)?.PartitionKey != null)
8228 {
8229 cookieToSet["partitionKey"] = existing.PartitionKey; // keep the original object
8231
8232 // 4. Set the cookie
8233 await SendCommand<dynamic>(DevToolsMethods.StorageSetCookies,
8234 new { cookies = new[] { cookieToSet } },
8235 sessionId).ConfigureAwait(false);
8236 }
8237 }
8238 }
8239 else
8240 {
8241 // 3. Build the cookie to set (merge existing + supplied values)
8242 cookieToSet = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
8243
8244 cookieToSet["name"] = key;
8245 cookieToSet["value"] = data;
8246 cookieToSet["domain"] = ((dynamic)existing)?.Domain?.ToString();
8247 cookieToSet["path"] = path;
8248
8249 cookieToSet["expires"] = ((dynamic)existing)?.Expires != null
8250 ? (double)existing.Expires
8251 : DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (180L * 24 * 60 * 60); // ~6 months
8252
8253 cookieToSet["secure"] = ((dynamic)existing)?.Secure ?? true;
8254 cookieToSet["httpOnly"] = ((dynamic)existing)?.HttpOnly ?? true;
8255 cookieToSet["sameSite"] = ((dynamic)existing)?.SameSite?.ToString() ?? "None";
8256
8257 cookieToSet["session"] = ((dynamic)existing)?.Session ?? false;
8258 cookieToSet["priority"] = ((dynamic)existing)?.Priority?.ToString() ?? "Medium";
8259
8260 cookieToSet["sourceScheme"] = ((dynamic)existing)?.SourceScheme?.ToString() ?? "Secure";
8261 cookieToSet["sourcePort"] = ((dynamic)existing)?.SourcePort ?? 443;
8262
8263 // Only add partitionKey if it actually exists and is not null
8264 if (((dynamic)existing)?.PartitionKey != null)
8265 {
8266 cookieToSet["partitionKey"] = existing.PartitionKey; // keep the original object
8267 }
8268
8269 // 4. Set the cookie
8270 await SendCommand<dynamic>(DevToolsMethods.StorageSetCookies,
8271 new { cookies = new[] { cookieToSet } },
8272 sessionId).ConfigureAwait(false);
8273 }
8274
8275 // Optional: return the cookie we just set (or the old list)
8276 //resultJson = JsonConvert.SerializeObject(new[] { cookieToSet });
8277 retVal = true;
8278 break;
8279 }
8280
8281 case "localStorage":
8282 case "sessionStorage":
8283 string jsStorage = $@"{storageType}.setItem('{EscapeJsString(key)}','{EscapeJsString(data)}');";
8284 await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate,
8285 new {
8286 expression = jsStorage,
8287 awaitPromise = false,
8288 contextId = CurrentContextId,
8289 returnByValue = true
8290 }, sessionId).ConfigureAwait(false);
8291 retVal = true;
8292 break;
8293
8294 case "indexedDb":
8295 string js = $@"
8296 var callback = arguments[arguments.length - 1];
8297
8298 (function() {{
8299 const dbName = '{EscapeJsString(path)}';
8300 const storeName = '{EscapeJsString(storeName)}';
8301 const inputKey = '{EscapeJsString(key)}';
8302 let rawValue = '{EscapeJsString(data)}';
8303
8304 function resolve(val) {{
8305 callback(val);
8306 }}
8307
8308 function fail(msg, err, db) {{
8309 console.error(msg, err?.name || err || '');
8310 try {{ db?.close(); }} catch {{ }}
8311 resolve(false);
8312 }}
8313
8314 function parseValue(v) {{
8315 try {{
8316 return JSON.parse(v);
8317 }} catch {{
8318 return v;
8319 }}
8320 }}
8321
8322 function isValidKey(k) {{
8323 if (k === undefined) return false;
8324 if (typeof k === 'string' || typeof k === 'number') return true;
8325 if (k instanceof Date) return true;
8326 if (Array.isArray(k)) return true;
8327 return false;
8328 }}
8329
8330 function ensureInlineKey(value, keyPath, key, autoIncrement) {{
8331 if (keyPath === null) return {{ value, usedKey: key }};
8332
8333 if (typeof value !== 'object' || value === null) {{
8334 if (keyPath && key !== undefined) {{
8335 const wrapped = {{}};
8336
8337 if (typeof keyPath === 'string') {{
8338 wrapped[keyPath] = key;
8339 }} else if (Array.isArray(keyPath)) {{
8340 if (!Array.isArray(key)) {{
8341 throw new Error('Compound key requires array key');
8342 }}
8343 for (let i = 0; i < keyPath.length; i++) {{
8344 wrapped[keyPath[i]] = key[i];
8345 }}
8346 }}
8347
8348 wrapped.value = value;
8349 return {{ value: wrapped, usedKey: undefined }};
8350 }}
8351
8352 if (autoIncrement) {{
8353 return {{ value, usedKey: undefined }};
8354 }}
8355
8356 throw new Error('Inline keyPath requires object value');
8357 }}
8358
8359 if (typeof keyPath === 'string') {{
8360 if (!(keyPath in value)) {{
8361 if (key !== undefined) {{
8362 value[keyPath] = key;
8363 }} else if (!autoIncrement) {{
8364 throw new Error('Missing required inline keyPath property');
8365 }}
8366 }}
8367 }} else if (Array.isArray(keyPath)) {{
8368 for (let i = 0; i < keyPath.length; i++) {{
8369 const kp = keyPath[i];
8370 if (!(kp in value)) {{
8371 if (Array.isArray(key) && key[i] !== undefined) {{
8372 value[kp] = key[i];
8373 }} else if (!autoIncrement) {{
8374 throw new Error('Missing compound keyPath property: ' + kp);
8375 }}
8376 }}
8377 }}
8378 }}
8379
8380 return {{ value, usedKey: undefined }};
8381 }}
8382
8383 const openReq = indexedDB.open(dbName);
8384
8385 openReq.onerror = () => {{
8386 fail('Failed to open database', openReq.error);
8387 }};
8388
8389 openReq.onsuccess = () => {{
8390 const db = openReq.result;
8391
8392 let tx;
8393 try {{
8394 tx = db.transaction(storeName, 'readwrite');
8395 }} catch (e) {{
8396 fail('Transaction creation failed', e, db);
8397 return;
8398 }}
8399
8400 const store = tx.objectStore(storeName);
8401
8402 let value = parseValue(rawValue);
8403 let key = inputKey;
8404
8405 if (key === '') key = undefined;
8406
8407 if (key !== undefined && !isValidKey(key)) {{
8408 fail('Invalid key type', key, db);
8409 return;
8410 }}
8411
8412 let finalValue, finalKey;
8413
8414 try {{
8415 const result = ensureInlineKey(
8416 value,
8417 store.keyPath,
8418 key,
8419 store.autoIncrement
8420 );
8422 finalValue = result.value;
8423 finalKey = result.usedKey;
8424 }} catch (e) {{
8425 fail('Key handling failed', e, db);
8426 return;
8427 }}
8428
8429 let putReq;
8430
8431 try {{
8432 if (store.keyPath === null) {{
8433 if (finalKey === undefined && !store.autoIncrement) {{
8434 fail('Missing key for out-of-line store', null, db);
8435 return;
8436 }}
8437 putReq = store.put(finalValue, finalKey);
8438 }} else {{
8439 putReq = store.put(finalValue);
8440 }}
8441 }} catch (e) {{
8442 fail('Put setup error', e, db);
8443 return;
8444 }}
8445
8446 // wait for transaction complete (not just put)
8447 tx.oncomplete = () => {{
8448 try {{ db.close(); }} catch {{ }}
8449 resolve(true);
8450 }};
8451
8452 tx.onerror = (event) => {{
8453 fail('Transaction error', event.target.error, db);
8454 }};
8455
8456 tx.onabort = (event) => {{
8457 fail('Transaction aborted', event.target.error, db);
8458 }};
8459 }};
8460 }})();
8461 ";
8462
8463 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
8464 {
8465 expression = js,
8466 awaitPromise = true,
8467 contextId = CurrentContextId,
8468 returnByValue = true
8469 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
8470
8471 break;
8472
8473 case "cache":
8474 string cacheName = EscapeJsString(storeName);
8475 string requestUrl = EscapeJsString(key); // Key = the cache key (URL)
8476
8477 js = $@"
8478 (async () => {{
8479 try {{
8480 const cache = await caches.open('{cacheName}').ConfigureAwait(false);
8481
8482 const request = new Request('{requestUrl}', {{
8483 method: 'GET'
8484 }});
8485
8486 const response = new Response('{data}', {{
8487 status: 200,
8488 statusText: 'OK',
8489 headers: {{
8490 'Content-Type': 'text/plain;charset=utf-8',
8491 'Cache-Control': 'max-age=31536000' // or make this configurable
8492 }}
8493 }});
8494
8495 await cache.put(request, response).ConfigureAwait(false);
8496 return {{ success: true, cacheName: '{cacheName}', key: '{requestUrl}' }};
8497 }} catch (err) {{
8498 console.error('Cache set failed:', err);
8499 return {{ success: false, error: err.message }};
8500 }}
8501 }})();
8502 ";
8503
8504 var evalResult = await SendCommand<dynamic>(
8505 DevToolsMethods.RuntimeEvaluate,
8506 new
8507 {
8508 expression = js,
8509 awaitPromise = true,
8510 contextId = CurrentContextId,
8511 returnByValue = true
8512 },
8513 sessionId).ConfigureAwait(false);
8514
8515 // Check result
8516 retVal = evalResult?.result?.value?.success == true;
8517 break;
8518
8519 default:
8521 GPALEventType.ERROR,
8522 $"Unsupported storage type: [{storageType}]",
8523 this,
8524 GPALObjectType.PuppeteerCommunicator);
8525 break;
8526 }
8527 }
8528 catch (Exception ex)
8529 {
8531 GPALEventType.EXCEPTION,
8532 $"SetStorage failed for [{storageType}] domain=[{domain}] storeName=[{storeName}] path=[{path}] key=[{key}]",
8533 this,
8534 GPALObjectType.PuppeteerCommunicator,
8535 ex);
8536 }
8537
8538 return retVal;
8539 }
8540
8541 // Implementation for "set-value-from-element"
8552 public async Task<dynamic> SetValueFromElement(string srcCss, string destCss, string sessionId = null)
8553 {
8554 sessionId ??= GetEffectiveSessionId();
8555 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8556 new
8557 {
8558 contextId = CurrentContextId,
8559 expression = $@"var s = document.querySelector('{srcCss}'); var d = document.querySelector('{destCss}'); d.value = s.value; d.dispatchEvent(new Event('input', {{bubbles: true}})); d.dispatchEvent(new Event('change', {{bubbles: true}}));"
8560 }, sessionId).ConfigureAwait(false);
8561 }
8562
8563 // Implementation for "stealth-override-referrer"
8571 public async Task StealthOverrideReferrer(string sessionId = null)
8572 {
8573 sessionId ??= GetEffectiveSessionId();
8574 await SendCommand<object>(DevToolsMethods.NetworkSetExtraHTTPHeaders, new { headers = new { Referer = "https://www.google.com" } }, sessionId).ConfigureAwait(false);
8575 }
8576
8577 // Implementation for "submit-form"
8584 public async Task<dynamic> SubmitForm(string objectId, string sessionId = null)
8585 {
8586 sessionId ??= GetEffectiveSessionId();
8587 return await SendCommand<object>(DevToolsMethods.RuntimeCallFunctionOn, new
8588 {
8589 objectId,
8590 contextId = CurrentContextId,
8591 functionDeclaration = "function() {{ this.submit(); }}"
8592 }, sessionId).ConfigureAwait(false);
8593 }
8594
8595 // Implementation for "switch-to-default-content"
8601 public Task SwitchToDefaultContent(string sessionId = null)
8602 {
8603 // dropping the frame state is the whole of the work, and none of it goes over the wire. window
8604 // ordering is not frame context and belongs to TopBrowser, so nothing is sent from here
8605 CurrentContextId = null;
8606 CurrentFrameId = null;
8607 CurrentFrameSessionId = null;
8608 CurrentRootObjectId = null;
8609
8610 return Task.CompletedTask;
8611 }
8612
8620 public async Task<string> GoToWindow(object tabIdOrUrl, string sessionId = null)
8621 {
8622 string targetId = null;
8623
8624 if (tabIdOrUrl is int tabId)
8625 {
8626 var queueArray = CurrentSessions.ToArray();
8627 if (tabId >= 0 && tabId < queueArray.Length)
8628 {
8629 targetId = queueArray[tabId].Key;
8630 // Update active tab index
8631 SetActiveTabIndex(tabId);
8632 }
8633 }
8634 else if (tabIdOrUrl is string url && !string.IsNullOrWhiteSpace(url))
8635 {
8636 targetId = await GetTargetTabIdByUrl(url, sessionId).ConfigureAwait(false);
8637 if (!string.IsNullOrEmpty(targetId))
8638 {
8639 // Find and set active window and tab
8640 int windowIndex = GetCurrentWindowIndex(targetId);
8641 if (windowIndex >= 0)
8642 {
8643 SetActiveWindowIndex(windowIndex);
8644 var windowArray = _windowSessionsQueue.ToArray();
8645 var tabQueue = windowArray[windowIndex].TabQueue.ToArray();
8646 int tabIndex = Array.FindIndex(tabQueue, kvp => kvp.Key == targetId);
8647 if (tabIndex >= 0)
8648 {
8649 SetActiveTabIndex(tabIndex);
8650 }
8651 }
8652 }
8653 }
8654
8655 if (string.IsNullOrEmpty(targetId))
8656 {
8657 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No target found for tabIdOrUrl [{tabIdOrUrl}]", this, GPALObjectType.PuppeteerCommunicator);
8658 return null;
8659 }
8660
8661 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId }, null).ConfigureAwait(false);
8662 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
8663 return targetId;
8664 }
8665 // Implementation for "window-inner-height"
8671 public async Task<int> WindowInnerHeight(string sessionId = null)
8672 {
8673 sessionId ??= GetEffectiveSessionId();
8674 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8675 new {
8676 expression = "window.innerHeight",
8677 contextId = CurrentContextId,
8678 returnByValue = true
8679 }, sessionId).ConfigureAwait(false);
8680 return (int)result.value;
8681 }
8682
8683 // Implementation for "window-inner-width"
8686 /// </summary>
8687 /// <param name="sessionId">Target session ID, or null to use the effective session.</param>
8688 /// <returns>A Task that resolves to the viewport width in pixels.</returns>
8689 public async Task<int> WindowInnerWidth(string sessionId = null)
8690 {
8691 sessionId ??= GetEffectiveSessionId();
8692 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8693 new {
8694 expression = "window.innerWidth",
8695 contextId = CurrentContextId,
8696 returnByValue = true
8697 }, sessionId).ConfigureAwait(false);
8698 return (int)result.value;
8699 }
8700
8701 // Implementation for "window-outer-height"
8707 public async Task<int> WindowOuterHeight(string sessionId = null)
8708 {
8709 sessionId ??= GetEffectiveSessionId();
8710 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8711 new {
8712 expression = "window.outerHeight",
8713 contextId = CurrentContextId,
8714 returnByValue = true
8715 }, sessionId).ConfigureAwait(false);
8716 return (int)result.value;
8717 }
8718
8719 // Implementation for "window-outer-width"
8725 public async Task<int> WindowOuterWidth(string sessionId = null)
8726 {
8727 sessionId ??= GetEffectiveSessionId();
8728 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8729 new {
8730 expression = "window.outerWidth",
8731 contextId = CurrentContextId,
8732 returnByValue = true
8733 }, sessionId).ConfigureAwait(false);
8734 return (int)result.value;
8735 }
8736
8737 // Implementation for "fetch"
8751 public async Task<string> Fetch(string url, string method = null, string body = null, string contentType = null, string[] headers = null, bool asBytes = false, string sessionId = null)
8752 {
8753 sessionId ??= GetEffectiveSessionId();
8754
8755 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8756 new
8757 {
8758 expression = $@"
8759 (async () => {{
8760 {BrowserHelper.FetchSetupScript(url, method, body, contentType, headers, asBytes)}
8761
8762 try {{
8763 var response = await fetch(target.toString(), options);
8764 return await gpalEnvelope(response);
8765 }} catch (error) {{
8766 return null;
8767 }}
8768 }})()",
8769 contextId = CurrentContextId,
8770 returnByValue = true,
8771 awaitPromise = true
8772 }, sessionId).ConfigureAwait(false);
8773
8774 // the response comes back deserialized by Newtonsoft, so it is indexed as a JObject rather than
8775 // reached through dynamic member access, which does not resolve to the string
8776 return (result as Newtonsoft.Json.Linq.JObject)["result"]["value"]?.ToString();
8777 }
8778
8779 // Implementation for "window-page-xoffset"
8785 public async Task<int> WindowPageOffsetX(string sessionId = null)
8786 {
8787 sessionId ??= GetEffectiveSessionId();
8788 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8789 new {
8790 expression = "window.pageXOffset",
8791 contextId = CurrentContextId,
8792 returnByValue = true
8793 }, sessionId).ConfigureAwait(false);
8794 return (int)result.value;
8795 }
8796
8797 // Implementation for "window-page-yoffset"
8803 public async Task<int> WindowPageOffsetY(string sessionId = null)
8804 {
8805 sessionId ??= GetEffectiveSessionId();
8806 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8807 new {
8808 expression = "window.pageYOffset",
8809 contextId = CurrentContextId,
8810 returnByValue = true
8811 }, sessionId).ConfigureAwait(false);
8812 return (int)result.value;
8813 }
8814
8815 // Implementation for "window-screen-left"
8821 public async Task<int> WindowScreenLeft(string sessionId = null)
8822 {
8823 sessionId ??= GetEffectiveSessionId();
8824 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8825 new {
8826 expression = "window.screenLeft",
8827 contextId = CurrentContextId,
8828 returnByValue = true
8829 }, sessionId).ConfigureAwait(false);
8830 return (int)result.value;
8831 }
8832
8833 // Implementation for "window-screen-top"
8839 public async Task<int> WindowScreenTop(string sessionId = null)
8840 {
8841 sessionId ??= GetEffectiveSessionId();
8842 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8843 new {
8844 expression = "window.screenTop",
8845 contextId = CurrentContextId,
8846 returnByValue = true
8847 }, sessionId).ConfigureAwait(false);
8848 return (int)result.value;
8849 }
8850
8851 // Helper for getting nodeId by elementId
8859 private async Task<int> GetNodeIdFromBackendNodeId(int backendNodeId, string sessionId = null)
8860 {
8861 sessionId ??= GetEffectiveSessionId();
8862
8863 // Ensure we have a DOM loaded
8864 await SendCommand<dynamic>(DevToolsMethods.DOMGetDocument, new { depth = 1 }, sessionId).ConfigureAwait(false);
8865
8866 // Push the backend node ID to get a current, valid node ID
8867 var result = await SendCommand<dynamic>(
8868 DevToolsMethods.DOMPushNodesByBackendIdsToFrontend,
8869 new { backendNodeIds = new[] { backendNodeId } },
8870 sessionId
8871 ).ConfigureAwait(false);
8872
8873 // The result has nodeIds as an array of integers
8874 if (result?.nodeIds is Newtonsoft.Json.Linq.JArray nodeIdsArray && nodeIdsArray.Count > 0)
8875 {
8876 return nodeIdsArray[0].Value<int>();
8877 }
8878
8879 return 0;
8880 }
8884 public async Task<string> GetObjectIdFromBackendNodeId(int backendNodeId, string sessionId = null, long? contextId = null)
8885 {
8886 sessionId ??= GetEffectiveSessionId();
8887
8888 // Use iframe session if we're currently in an iframe
8889 string effectiveSessionId = !string.IsNullOrEmpty(CurrentFrameSessionId)
8890 ? CurrentFrameSessionId
8891 : sessionId;
8892
8893 // Use provided contextId or fall back to current iframe context
8894 long? effectiveContextId = contextId ?? CurrentContextId;
8895
8896 try
8897 {
8898 // Step 1: Resolve backendNodeId > nodeId (in the correct session)
8899 var resolveNodeParams = new Dictionary<string, object>
8900 {
8901 ["backendNodeId"] = backendNodeId
8902 };
8903
8904 // Add contextId only if we have one (important for iframes)
8905 if (effectiveContextId.HasValue)
8906 {
8907 resolveNodeParams["executionContextId"] = effectiveContextId.Value;
8908 }
8909
8910 var resolveResult = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, resolveNodeParams, effectiveSessionId).ConfigureAwait(false);
8911
8912 string objectId = resolveResult?.@object?.objectId?.ToString();
8913
8914 if (!string.IsNullOrEmpty(objectId))
8915 return objectId;
8916
8917 // Fallback: Try without executionContextId
8918 var fallbackResult = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, new
8919 {
8920 backendNodeId = backendNodeId
8921 }, effectiveSessionId).ConfigureAwait(false);
8922
8923 return fallbackResult?.@object?.objectId?.ToString() ?? string.Empty;
8924 }
8925 catch (Exception ex)
8926 {
8927 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
8928 $"GetObjectIdFromBackendNodeId failed for backendNodeId [{backendNodeId}]",
8929 this, GPALObjectType.PuppeteerCommunicator, ex);
8930
8931 return string.Empty;
8932 }
8933 }
8934
8935 // Helper for getting element X coordinate
8943 private async Task<int> GetElementRandomCenterX(int backendNodeId, string sessionId = null)
8944 {
8945 sessionId ??= GetEffectiveSessionId();
8946 var rect = await GetBoundingClientRect(backendNodeId, sessionId).ConfigureAwait(false);
8947 return (int)rect.X + rect.Width / new Random().Next(2, 3);
8948 }
8949
8950 // Helper for getting element Y coordinate
8958 private async Task<int> GetElementRandomCenterY(int backendNodeId, string sessionId = null)
8959 {
8960 sessionId ??= GetEffectiveSessionId();
8961 var rect = await GetBoundingClientRect(backendNodeId, sessionId).ConfigureAwait(false);
8962 return (int)rect.Y + rect.Width / new Random().Next(2, 3);
8963 }
8964 // get the json protocol to dynamically build a list of extraction variable for sendcommand to return useful data
8970 internal string GetProtocolJSON()
8971 {
8972 using var http = new System.Net.Http.HttpClient();
8973 var json = http.GetStringAsync($"{_puppeteerUrl}/json/protocol").Result;
8974 return Newtonsoft.Json.Linq.JObject.Parse(json).ToString();
8975 }
8978 /// </summary>
8979 /// <returns>The browser context ID of the active window, or null if there is no active window.</returns>
8980 internal string GetActiveWindowId()
8981 {
8982 var windows = _windowSessionsQueue.ToArray();
8983 if (_activeWindowIndex < 0 || _activeWindowIndex >= windows.Length)
8984 return null;
8985 return windows[_activeWindowIndex].BrowserContextId;
8986 }
8987 // Get active window's tab queue (embedded in windows queue)
8992 private ConcurrentQueue<KeyValuePair<string, string>> GetActiveWindowTabQueue()
8993 {
8994 var windowArray = _windowSessionsQueue.ToArray();
8995 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length) return null;
8996 return windowArray[_activeWindowIndex].TabQueue;
8997 }
8998
9004 public string GetEffectiveSessionId()
9005 {
9006 return !string.IsNullOrEmpty(CurrentFrameSessionId)
9007 ? CurrentFrameSessionId
9008 : GetCurrentSessionId();
9009 }
9010
9011 // Get current sessionId (active window -> active tab)
9024 internal void ClearCurrentSessionId()
9025 {
9026 var windowArray = _windowSessionsQueue.ToArray();
9027 if (0 == windowArray.Length || _activeWindowIndex >= windowArray.Length)
9028 return;
9029
9030 var window = windowArray[_activeWindowIndex];
9031 var tabArray = window.TabQueue.ToArray();
9032 if (0 == tabArray.Length || window.ActiveTabIndex >= tabArray.Length)
9033 return;
9034
9035 string targetId = tabArray[window.ActiveTabIndex].Key;
9036 var newQueue = new ConcurrentQueue<KeyValuePair<string, string>>();
9037
9038 foreach (var kvp in tabArray)
9039 newQueue.Enqueue(kvp.Key == targetId ? new KeyValuePair<string, string>(kvp.Key, null) : kvp);
9040
9041 window.TabQueue = newQueue;
9042 }
9043 public string GetCurrentSessionId()
9044 {
9045 var windowArray = _windowSessionsQueue.ToArray();
9046 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length) return null;
9047
9048 var window = windowArray[_activeWindowIndex];
9049 var tabQueue = window.TabQueue;
9050 var tabArray = tabQueue.ToArray();
9051 var activeTabIndex = window.ActiveTabIndex;
9052 if (tabArray.Length == 0 || activeTabIndex >= tabArray.Length) return null;
9053
9054 var targetId = tabArray[activeTabIndex].Key;
9055 var sessionId = tabArray[activeTabIndex].Value;
9056
9057 if (sessionId == null)
9058 {
9059 var attachResult = SendCommand<dynamic>(DevToolsMethods.TargetAttachToTarget, new
9060 {
9061 targetId,
9062 flatten = true
9063 }, null).GetAwaiter().GetResult();
9064
9065 string newSessionId = attachResult.sessionId;
9066
9067 // rebuild queue with updated value
9068 var newQueue = new ConcurrentQueue<KeyValuePair<string, string>>();
9069 foreach (var kvp in tabArray)
9070 newQueue.Enqueue(kvp.Key == targetId
9071 ? new KeyValuePair<string, string>(kvp.Key, newSessionId)
9072 : kvp);
9073
9074 window.TabQueue = newQueue;
9075 sessionId = newSessionId;
9076 }
9077
9078 return sessionId;
9079 }
9080
9081 // Get current targetId (active window -> active tab)
9086 public string GetCurrentTargetId()
9087 {
9088 var windowArray = _windowSessionsQueue.ToArray();
9089 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length) return null;
9090 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
9091 var tabArray = tabQueue.ToArray();
9092 var activeTabIndex = windowArray[_activeWindowIndex].ActiveTabIndex;
9093 if (tabArray.Length == 0 || activeTabIndex >= tabArray.Length) return null;
9094 return tabArray[activeTabIndex].Key;
9095 }
9096 // Get current window targetId (active window)
9101 public string GetCurrentWindowTargetId()
9102 {
9103 var windowArray = _windowSessionsQueue.ToArray();
9104
9105 return windowArray[_activeWindowIndex].BrowserContextId;
9106 }
9107 // Add tab to active window's embedded tab queue, set as active tab
9117 internal async Task AddTabToQueue(string targetId, string sessionId, bool isActive = true, bool doNotGetSendSemaphore = false)
9118 {
9119 var windowArray = _windowSessionsQueue.ToArray();
9120 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
9121 {
9122 // TODO: this needs to be pushed onto a queue, then the next time send
9123 var targets = await SendCommand<object>(DevToolsMethods.TargetGetTargets, new { }, null, 3, doNotGetSendSemaphore).ConfigureAwait(false);
9124 var target = ((IEnumerable<dynamic>)targets.targetInfos)
9125 .FirstOrDefault(t => t.targetId == targetId && t.type == "page");
9126
9127 if (target == null)
9128 {
9129 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Target ID [{targetId}] not found in CDP", this, GPALObjectType.PuppeteerCommunicator);
9130 return;
9131 }
9132
9133 string browserContextId = target.browserContextId?.ToString() ?? "";
9134
9135 var newTabQueue = new ConcurrentQueue<KeyValuePair<string, string>>();
9136 newTabQueue.Enqueue(new KeyValuePair<string, string>(targetId, sessionId));
9137 _windowSessionsQueue.Enqueue(new WindowSession(browserContextId, newTabQueue, 0));
9138 _activeWindowIndex = 0;
9139 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
9140 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Created window with browserContextId [{browserContextId}], added tab ID [{targetId}], Active window [0], tab [0]", this, GPALObjectType.PuppeteerCommunicator);
9141 return;
9142 }
9143
9144 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
9145 tabQueue.Enqueue(new KeyValuePair<string, string>(targetId, sessionId));
9146
9147 // Update ActiveTabIndex in the WindowSession
9148 var windowList = _windowSessionsQueue.ToList();
9149 windowList[_activeWindowIndex] = new WindowSession(
9150 windowList[_activeWindowIndex].BrowserContextId,
9151 tabQueue,
9152 true == isActive ? tabQueue.Count - 1 : windowList[_activeWindowIndex].ActiveTabIndex // we do not know if the new tab is active, just that it is being added
9153 );
9154 _windowSessionsQueue = new ConcurrentQueue<WindowSession>();
9155 foreach (var w in windowList)
9156 {
9157 _windowSessionsQueue.Enqueue(w);
9158 }
9159 var newTabIdx = tabQueue.Count - 1;
9160
9161 if (true == isActive)
9162 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
9163
9164 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Added tab to active window: ID [{targetId}], Active tab index [{newTabIdx}], Tab queue size [{tabQueue.Count}]", this, GPALObjectType.PuppeteerCommunicator);
9165 } // Set active tab index in active window's embedded tab queue
9171 internal void SetActiveTabIndex(int index)
9172 {
9173 var windowArray = _windowSessionsQueue.ToArray();
9174 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
9175 {
9176 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No active window for SetActiveTabIndex", this, GPALObjectType.PuppeteerCommunicator);
9177 return;
9178 }
9179 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
9180 var tabArray = tabQueue.ToArray();
9181 index = index % tabArray.Length;
9182 if (index < 0) index += tabArray.Length;
9183
9184 // Update ActiveTabIndex in the WindowSession
9185 var windowList = _windowSessionsQueue.ToList();
9186 windowList[_activeWindowIndex] = new WindowSession(
9187 windowList[_activeWindowIndex].BrowserContextId,
9188 windowList[_activeWindowIndex].TabQueue,
9189 index
9190 );
9191 _windowSessionsQueue = new ConcurrentQueue<WindowSession>();
9192 foreach (var w in windowList)
9193 {
9194 _windowSessionsQueue.Enqueue(w);
9195 }
9196
9197 ((PuppeteerClient)PuppeteerClient)._currentTargetId = tabArray[index].Key;
9198 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Set active tab index [{index}] in active window, ID [{tabArray[index].Key}]", this, GPALObjectType.PuppeteerCommunicator);
9199 }
9200 // Remove tab from active window's embedded tab queue by targetId
9207 internal bool RemoveTabFromQueue(string targetId)
9208 {
9209 var windowArray = _windowSessionsQueue.ToArray();
9210 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
9211 {
9212 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No active window for RemoveTabFromQueue [{targetId}]", this, GPALObjectType.PuppeteerCommunicator);
9213 return false;
9214 }
9215
9216 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
9217 var queueList = tabQueue.ToList();
9218 var indexToRemove = queueList.FindIndex(kvp => kvp.Key == targetId);
9219 if (indexToRemove == -1)
9220 {
9221 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Tab ID [{targetId}] not found in active window queue", this, GPALObjectType.PuppeteerCommunicator);
9222 return false;
9223 }
9224
9225 queueList.RemoveAt(indexToRemove);
9226 var newTabQueue = new ConcurrentQueue<KeyValuePair<string, string>>();
9227 foreach (var kvp in queueList)
9228 {
9229 newTabQueue.Enqueue(kvp);
9230 }
9231
9232 // Update ActiveTabIndex in WindowSession
9233 var newActiveTabIndex = windowArray[_activeWindowIndex].ActiveTabIndex;
9234 if (newTabQueue.IsEmpty)
9235 {
9236 newActiveTabIndex = 0;
9237 }
9238 else if (newActiveTabIndex > indexToRemove)
9239 {
9240 newActiveTabIndex--;
9241 }
9242 else if (newActiveTabIndex == indexToRemove)
9243 {
9244 newActiveTabIndex = Math.Max(0, newActiveTabIndex - 1);
9245 }
9246
9247 var windowList = _windowSessionsQueue.ToList();
9248 windowList[_activeWindowIndex] = new WindowSession(
9249 windowList[_activeWindowIndex].BrowserContextId,
9250 newTabQueue,
9251 newActiveTabIndex
9252 );
9253 _windowSessionsQueue = new ConcurrentQueue<WindowSession>();
9254 foreach (var w in windowList)
9255 {
9256 _windowSessionsQueue.Enqueue(w);
9257 }
9258
9259 ((PuppeteerClient)PuppeteerClient)._currentTargetId = newTabQueue.IsEmpty ? null : newTabQueue.ToArray()[newActiveTabIndex].Key;
9260 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Removed tab ID [{targetId}] from active window, Active tab index now [{newActiveTabIndex}], Tab queue size [{newTabQueue.Count}]", this, GPALObjectType.PuppeteerCommunicator);
9261 return true;
9262 }
9263
9269 public int GetCurrentWindowIndex(string targetId)
9270 {
9271 var windows = _windowSessionsQueue.ToArray(); // assuming you track sessions per window
9272 for (int i = 0; i < windows.Length; i++)
9273 {
9274 foreach (var tab in windows[i].TabQueue)
9275 if (tab.Key == targetId)
9276 return i;
9277 }
9278 return -1;
9279 }
9280
9283 /// to the active tab of the newly active window.
9284 /// </summary>
9285 /// <param name="index">The desired window index, wrapped (modulo) into the valid range of the window queue.</param>
9286 internal void SetActiveWindowIndex(int index)
9287 {
9288 var windowArray = _windowSessionsQueue.ToArray();
9289 if (windowArray.Length == 0)
9290 {
9291 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No windows for SetActiveWindowIndex", this, GPALObjectType.PuppeteerCommunicator);
9292 return;
9293 }
9294 index = index % windowArray.Length;
9295 if (index < 0) index += windowArray.Length;
9296 _activeWindowIndex = index;
9297
9298 ((PuppeteerClient)PuppeteerClient)._currentTargetId = GetCurrentTargetId();
9299 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Set active window index [{_activeWindowIndex}], Active tab index [{windowArray[_activeWindowIndex].ActiveTabIndex}], Target ID [{((PuppeteerClient)PuppeteerClient)._currentTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
9300 }
9301 // Remove window from queue by browserContextId or targetId
9309 internal bool RemoveWindowFromQueue(string browserContextId = null, string targetId = null)
9310 {
9311 var windowArray = _windowSessionsQueue.ToArray();
9312 var indexToRemove = -1;
9313
9314 if (browserContextId != null)
9315 {
9316 indexToRemove = Array.FindIndex(windowArray, w => w.BrowserContextId == browserContextId);
9317 }
9318 else if (targetId != null)
9319 {
9320 for (int i = 0; i < windowArray.Length; i++)
9321 {
9322 var tabArray = windowArray[i].TabQueue.ToArray();
9323 if (Array.Exists(tabArray, kvp => kvp.Key == targetId))
9324 {
9325 indexToRemove = i;
9326 browserContextId = windowArray[i].BrowserContextId;
9327 break;
9328 }
9329 }
9330 }
9331
9332 if (indexToRemove == -1)
9333 {
9334 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Window/Context ID [{browserContextId ?? targetId}] not found", this, GPALObjectType.PuppeteerCommunicator);
9335 return false;
9336 }
9337
9338 var queueList = _windowSessionsQueue.ToList();
9339 queueList.RemoveAt(indexToRemove);
9340
9341 _windowSessionsQueue = new ConcurrentQueue<WindowSession>();
9342 foreach (var w in queueList)
9343 {
9344 _windowSessionsQueue.Enqueue(w);
9345 }
9346
9347 if (queueList.Count == 0)
9348 {
9349 _activeWindowIndex = 0;
9350 ((PuppeteerClient)PuppeteerClient)._currentTargetId = null;
9351 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"No windows remain after removing window with browserContextId [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
9352 }
9353 else
9354 {
9355 if (_activeWindowIndex > indexToRemove)
9356 {
9357 _activeWindowIndex--;
9358 }
9359 else if (_activeWindowIndex == indexToRemove)
9360 {
9361 _activeWindowIndex = Math.Max(0, _activeWindowIndex - 1);
9362 }
9363 ((PuppeteerClient)PuppeteerClient)._currentTargetId = GetCurrentTargetId();
9364 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Removed window with browserContextId [{browserContextId}], Active window index now [{_activeWindowIndex}], Active tab index [{windowArray[_activeWindowIndex].ActiveTabIndex}], New active target [{((PuppeteerClient)PuppeteerClient)._currentTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
9365 }
9366
9367 return true;
9368 }
9378 public async Task<bool> ScrollPageAsync(string direction, int pagesToScroll, string s)
9379 {
9380 double initialScrollY = 0;
9381 bool retVal = false;
9382
9383 try
9384 {
9385 // Validate direction
9386 if (direction != "up" && direction != "down")
9387 {
9388 return retVal;
9389 }
9390
9391 // Step 1: Check overflow property
9392 var overflowResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9393 {
9394 expression = @"getComputedStyle(document.documentElement).overflow",
9395 contextId = CurrentContextId,
9396 returnByValue = true
9397 }, s).ConfigureAwait(false);
9398 string overflow = overflowResult?["result"]?["value"].ToString();
9399
9400 // Step 2: Try window.scrollBy if overflow is not 'hidden' - which means we have scroll bars
9401 if (overflow != "hidden")
9402 {
9403 for (int count = 0; count < pagesToScroll; count++)
9404 {
9405 if (true == (retVal = await scrollPage().ConfigureAwait(false)))
9406 continue;
9407 else
9408 break;
9409 }
9410 }
9411 if (false == retVal)
9412 for (int count = 0; count < pagesToScroll; count++)
9413 {
9414
9415 if (true == (retVal = await scrollSinglePageApp().ConfigureAwait(false)))
9416 continue;
9417 else
9418 break;
9419 }
9420 }
9421 catch (Exception ex)
9422 {
9423 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to page [{direction}] for [{pagesToScroll}] pages.", this, GPALObjectType.PuppeteerClient, ex);
9424 }
9425
9426 async Task<bool> scrollPage()
9427 {
9428 // Get initial scroll position
9429 var initialScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9430 {
9431 expression = @"window.scrollY",
9432 contextId = CurrentContextId,
9433 returnByValue = true
9434 }, s).ConfigureAwait(false);
9435 initialScrollY = Convert.ToDouble(initialScrollResult?["result"]?["value"]);
9436
9437 // Try window.scrollBy
9438 string scrollDirection = direction == "down" ? "window.innerHeight" : "-window.innerHeight";
9439 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
9440 {
9441 expression = $"window.scrollBy({{ left: 0, top: {scrollDirection}, behavior: 'instant' }})",
9442 contextId = CurrentContextId,
9443 returnByValue = true
9444 }, s).ConfigureAwait(false);
9445
9446 // Verify scroll
9447 var afterScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9448 {
9449 expression = @"window.scrollY",
9450 contextId = CurrentContextId,
9451 returnByValue = true
9452
9453 }, s).ConfigureAwait(false);
9454 double afterScrollY = Convert.ToDouble(afterScrollResult?["result"]?["value"]);
9455
9456 if (afterScrollY != initialScrollY)
9457 {
9458 return true; // Scroll worked
9459 }
9460 return false;
9461 }
9462
9463 async Task<bool> scrollSinglePageApp()
9464 {
9465 // Step 3: Try scrolling the scrollable element
9466 var elementScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9467 {
9468 expression = $@"(function() {{
9469 const isDown = '{direction}' === 'down';
9470 const scrollAmount = isDown ? window.innerHeight : -window.innerHeight;
9471 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9472 el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
9473 ) || document.body;
9474 const initialScrollTop = scrollable.scrollTop;
9475 scrollable.scrollBy({{ left: 0, top: scrollAmount, behavior: 'instant' }});
9476 return {{ initialScrollTop, scrollTop: scrollable.scrollTop, element: scrollable.className || 'body' }};
9477 }})()",
9478 contextId = CurrentContextId,
9479 returnByValue = true
9480 }, s).ConfigureAwait(false);
9481
9482 double initialScrollTop = Convert.ToDouble(elementScrollResult?["result"]?["value"]["initialScrollTop"]);
9483 double elementScrollTop = Convert.ToDouble(elementScrollResult?["result"]?["value"]["scrollTop"]);
9484
9485 if (elementScrollTop != initialScrollTop)
9486 {
9487 return true; // Scroll worked
9488 }
9489
9490 return await scrollByKeyPress().ConfigureAwait(false);
9491
9492 async Task<bool> scrollByKeyPress()
9493 {
9494 // Step 4: Fallback to Page Up/Down key press
9495 // Focus scrollable element
9496 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
9497 {
9498 expression = @"(function() {
9499 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9500 el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
9501 ) || document.body;
9502 scrollable.focus();
9503 return scrollable.className || 'body';
9504 })()",
9505 contextId = CurrentContextId,
9506 returnByValue = true
9507 }, s).ConfigureAwait(false);
9508
9509 // Send keyDown
9510 int keyCode = direction == "down" ? 34 : 33;
9511 string keyName = direction == "down" ? "PageDown" : "PageUp";
9512 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, new
9513 {
9514 type = "keyDown",
9515 windowsVirtualKeyCode = keyCode,
9516 key = keyName,
9517 code = keyName
9518 }, s).ConfigureAwait(false);
9519
9520 // Send keyUp
9521 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, new
9522 {
9523 type = "keyUp",
9524 windowsVirtualKeyCode = keyCode,
9525 key = keyName,
9526 code = keyName
9527 }, s).ConfigureAwait(false);
9528
9529 // Step 5: Verify scroll
9530 var keyScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9531 {
9532 expression = @"(function() {
9533 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9534 el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
9535 ) || document.body;
9536 return { scrollTop: scrollable.scrollTop, element: scrollable.className || 'body' };
9537 })()",
9538 contextId = CurrentContextId,
9539 returnByValue = true
9540 }, s).ConfigureAwait(false);
9541
9542 double keyScrollTop = Convert.ToDouble(keyScrollResult?["result"]?["value"]["scrollTop"]);
9543 return keyScrollTop != initialScrollTop || keyScrollTop > 0; // Return true if scrolled
9544 }
9545 }
9546
9547 return retVal;
9548 }
9556 public async Task<bool> ScrollToPositionAsync(string position, string s)
9557 {
9558 try
9559 {
9560 // Validate position
9561 if (position != "top" && position != "end")
9562 {
9563 return false;
9564 }
9565
9566 // Step 1: Check overflow property
9567 var overflowResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9568 {
9569 expression = $@"(function() {{
9570 return getComputedStyle(document.documentElement).overflow;
9571 }})()",
9572 contextId = CurrentContextId,
9573 returnByValue = true
9574 }, s).ConfigureAwait(false);
9575 string overflow = overflowResult?["result"]?["value"].ToString();
9576
9577 // Step 2: Try window.scrollTo if overflow is not 'hidden'
9578 double initialScrollY = 0;
9579 if (overflow != "hidden")
9580 {
9581 // Get initial scroll position
9582 var initialScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9583 {
9584 expression = @"window.scrollY",
9585 contextId = CurrentContextId,
9586 returnByValue = true
9587 }, s).ConfigureAwait(false);
9588 initialScrollY = Convert.ToDouble(initialScrollResult?["result"]?["value"]);
9589
9590 // Try window.scrollTo
9591 string scrollTarget = position == "end" ? "document.body.scrollHeight" : "0";
9592 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
9593 {
9594 expression = $"window.scrollTo(0, {scrollTarget})",
9595 contextId = CurrentContextId,
9596 returnByValue = true
9597 }, s).ConfigureAwait(false);
9598
9599 // Verify scroll
9600 var afterScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9601 {
9602 contextId = CurrentContextId,
9603 expression = @"window.scrollY"
9604 }, s).ConfigureAwait(false);
9605 double afterScrollY = Convert.ToDouble(afterScrollResult?["result"]?["value"]);
9606
9607 if (afterScrollY != initialScrollY)
9608 {
9609 return true; // Scroll worked
9610 }
9611 }
9612
9613 // Step 3: Try scrolling the scrollable element
9614 var elementScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9615 {
9616 expression = $@"(function() {{
9617 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9618 el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
9619 ) || document.body;
9620 const initialScrollTop = scrollable.scrollTop;
9621 const scrollTarget = {(position == "end" ? "scrollable.scrollHeight" : "0")};
9622 scrollable.scrollTo({{left: 0, top: scrollTarget, behavior: 'instant'}});
9623 return {{ initialScrollTop, scrollTop: scrollable.scrollTop, element: scrollable.className || 'body' }};
9624 }})()",
9625 contextId = CurrentContextId,
9626 returnByValue = true
9627 }, s).ConfigureAwait(false);
9628
9629 double initialScrollTop = Convert.ToDouble(elementScrollResult?["result"]?["value"]["initialScrollTop"]);
9630 double elementScrollTop = Convert.ToDouble(elementScrollResult?["result"]?["value"]["scrollTop"]);
9631
9632 if (elementScrollTop != initialScrollTop)
9633 {
9634 return true; // Scroll worked
9635 }
9636
9637 // Step 4: Fallback to Home/End key press
9638 // Focus scrollable element
9639 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
9640 {
9641 expression = @"(function() {
9642 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9643 el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
9644 ) || document.body;
9645 scrollable.focus();
9646 return scrollable.className || 'body';
9647 })()",
9648 contextId = CurrentContextId,
9649 returnByValue = true
9650 }, s).ConfigureAwait(false);
9651
9652 // Send keyDown
9653 int keyCode = position == "end" ? 35 : 36;
9654 string keyName = position == "end" ? "End" : "Home";
9655 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, new
9656 {
9657 type = "keyDown",
9658 windowsVirtualKeyCode = keyCode,
9659 key = keyName,
9660 code = keyName
9661 }, s).ConfigureAwait(false);
9662
9663 // Send keyUp
9664 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, new
9665 {
9666 type = "keyUp",
9667 windowsVirtualKeyCode = keyCode,
9668 key = keyName,
9669 code = keyName
9670 }, s).ConfigureAwait(false);
9671
9672 // Step 5: Verify scroll
9673 var keyScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9674 {
9675 expression = @"(function() {
9676 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9677 el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
9678 ) || document.body;
9679 return { scrollTop: scrollable.scrollTop, element: scrollable.className || 'body' };
9680 })()",
9681 contextId = CurrentContextId,
9682 returnByValue = true
9683 }, s).ConfigureAwait(false);
9685 double keyScrollTop = Convert.ToDouble(keyScrollResult?["result"]?["value"]["scrollTop"]);
9686 return keyScrollTop != initialScrollTop || keyScrollTop > 0; // Return true if scrolled
9687 }
9688 catch
9689 {
9690 return false;
9691 }
9692 }
9702 public async Task<bool> ScrollWindowByPixelsAsync(int hPixels, int vPixels, string sessionId)
9703 {
9704 try
9705 {
9706 // Check for main window scrollability
9707 var overflowResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9708 {
9709 expression = $@"(function() {{
9710 return getComputedStyle(document.documentElement).overflow;
9711 }})()",
9712 contextId = CurrentContextId,
9713 returnByValue = true
9714 }, sessionId).ConfigureAwait(false);
9715 string overflow = overflowResult?.result?.value?.ToString();
9716
9717 // Try window.scrollBy if overflow is not 'hidden'
9718 if (overflow != "hidden")
9719 {
9720 // Get initial scroll position
9721 var initialScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9722 {
9723 expression = @"[window.scrollX, window.scrollY]",
9724 contextId = CurrentContextId,
9725 returnByValue = true
9726 }, sessionId).ConfigureAwait(false);
9727 var initialScrollX = initialScrollResult?.result?.value?.First;
9728 var initialScrollY = initialScrollResult?.result?.value?.Last;
9729
9730 // Try window.scrollBy
9731 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
9732 {
9733 expression = $"window.scrollBy({hPixels}, {vPixels})",
9734 contextId = CurrentContextId,
9735 returnByValue = true
9736 }, sessionId).ConfigureAwait(false);
9737
9738 // Verify scroll
9739 var afterScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9740 {
9741 expression = @"[window.scrollX, window.scrollY]",
9742 contextId = CurrentContextId,
9743 returnByValue = true
9744 }, sessionId).ConfigureAwait(false);
9745 var afterScrollX = afterScrollResult?.result?.value?.First;
9746 var afterScrollY = afterScrollResult?.result?.value?.Last;
9747
9748 if (afterScrollX != initialScrollX || afterScrollY != initialScrollY)
9749 {
9750 return true; // Scroll worked
9751 }
9752 }
9753
9754 // Try scrolling the scrollable element
9755 var elementScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9756 {
9757 expression = $@"(function() {{
9758 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9759 (el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden') ||
9760 (el.scrollWidth > el.clientWidth && getComputedStyle(el).overflowX !== 'hidden')
9761 ) || document.body;
9762 const initialScrollLeft = scrollable.scrollLeft;
9763 const initialScrollTop = scrollable.scrollTop;
9764 scrollable.scrollBy({hPixels}, {vPixels});
9765 return {{ initialScrollLeft, initialScrollTop, scrollLeft: scrollable.scrollLeft, scrollTop: scrollable.scrollTop, element: scrollable.className || 'body' }};
9766 }})()",
9767 contextId = CurrentContextId,
9768 returnByValue = true
9769 }, sessionId).ConfigureAwait(false);
9770
9771 double initialScrollLeft = Convert.ToDouble(elementScrollResult?.result?.value?.initialScrollLeft ?? 0);
9772 double initialScrollTop = Convert.ToDouble(elementScrollResult?.result?.value?.initialScrollTop ?? 0);
9773 double elementScrollLeft = Convert.ToDouble(elementScrollResult?.result?.value?.scrollLeft ?? 0);
9774 double elementScrollTop = Convert.ToDouble(elementScrollResult?.result?.value?.scrollTop ?? 0);
9775
9776 if (elementScrollLeft != initialScrollLeft || elementScrollTop != initialScrollTop)
9777 {
9778 return true; // Scroll worked
9779 }
9780
9781 // Fallback to Input.synthesizeScrollGesture (touch-based scrolling)
9782 // This is a more robust alternative to keyboard keys for relative pixel scrolling
9783 await SendCommand<object>(DevToolsMethods.InputSynthesizeScrollGesture, new
9784 {
9785 x = 100, // Arbitrary start position
9786 y = 100, // Arbitrary start position
9787 xDistance = -hPixels, // Note: xDistance is positive for scrolling left
9788 yDistance = -vPixels, // Note: yDistance is positive for scrolling up
9789 speed = 800 // Optional speed control
9790 }, sessionId).ConfigureAwait(false);
9791
9792 // Verify scroll again after the gesture
9793 var finalScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9794 {
9795 expression = @"(function() {
9796 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9797 (el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden') ||
9798 (el.scrollWidth > el.clientWidth && getComputedStyle(el).overflowX !== 'hidden')
9799 ) || document.body;
9800 return { scrollLeft: scrollable.scrollLeft, scrollTop: scrollable.scrollTop };
9801 })()",
9802 contextId = CurrentContextId,
9803 returnByValue = true
9804 }, sessionId).ConfigureAwait(false);
9805
9806 double finalScrollLeft = Convert.ToDouble(finalScrollResult?.result?.value?.scrollLeft ?? 0);
9807 double finalScrollTop = Convert.ToDouble(finalScrollResult?.result?.value?.scrollTop ?? 0);
9808
9809 // This verification is simpler; just check that a change occurred.
9810 return (finalScrollLeft != initialScrollLeft || finalScrollTop != initialScrollTop);
9811 }
9812 catch
9813 {
9814 return false;
9815 }
9816 }
9825 public async Task<bool> SelectByValue(string ElementHandle, string value, string sessionId = null)
9826 {
9827 var result = await SendCommand<dynamic>(
9828 DevToolsMethods.RuntimeCallFunctionOn,
9829 new
9830 {
9831 objectId = ElementHandle,
9832 contextId = CurrentContextId,
9833 functionDeclaration = @"
9834 function(value) {
9835 const oldValue = this.value;
9836 this.value = value;
9837 const changed = this.value !== oldValue;
9838 this.dispatchEvent(new Event('input', { bubbles: true }));
9839 this.dispatchEvent(new Event('change', { bubbles: true }));
9840 return changed;
9841 }",
9842 arguments = new[] { new { value = value } }
9843 },
9844 sessionId ?? GetEffectiveSessionId()).ConfigureAwait(false);
9845
9846 return result?.result?.value == true;
9847 }
9856 public async Task<bool> SelectByIndex(string ElementHandle, int index, string sessionId = null)
9857 {
9858 var result = await SendCommand<dynamic>(
9859 DevToolsMethods.RuntimeCallFunctionOn,
9860 new
9861 {
9862 objectId = ElementHandle,
9863 contextId = CurrentContextId,
9864 functionDeclaration = @"
9865 function(idx) {
9866 if (idx < 0 || idx >= this.options.length) {
9867 return false;
9868 }
9869 const oldIndex = this.selectedIndex;
9870 this.selectedIndex = idx;
9871 const changed = this.selectedIndex !== oldIndex;
9872 this.dispatchEvent(new Event('input', { bubbles: true }));
9873 this.dispatchEvent(new Event('change', { bubbles: true }));
9874 return changed;
9875 }",
9876 arguments = new[] { new { value = index } }
9877 },
9878 sessionId ?? GetEffectiveSessionId()).ConfigureAwait(false);
9879
9880 return result?.result?.value == true;
9881 }
9882 #region HELPERS
9889 internal async Task<bool> SetWindowSize(Rectangle windowSize, string sessionId = null)
9890 {
9891 sessionId ??= GetEffectiveSessionId();
9892
9893 var screen = System.Windows.Forms.Screen.PrimaryScreen.Bounds;
9894
9895 var windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
9896
9897 await SendCommand(DevToolsMethods.BrowserSetWindowBounds, new
9898 {
9899 windowId,
9900 bounds = new
9901 {
9902 left = windowSize.X,
9903 top = windowSize.Y,
9904 width = windowSize.Width,
9905 height = windowSize.Height
9906 }
9907 }, sessionId).ConfigureAwait(false);
9908
9909 return true;
9910 }
9911 #endregion HELPERS
9912 }
9913 internal static class DevToolsCommandBuilder
9914 {
9915 // Complete list of CDP DOM methods that require a nodeId parameter
9916 // Based on Chrome DevTools Protocol (version 1.3, stable as of Chrome 126+)
9917 internal static readonly HashSet<string> MethodsRequiringNodeId = new HashSet<string>
9918 {
9919 // DOM domain methods that require nodeId
9920 "DOM.copyTo",
9921 "DOM.describeNode",
9922 "DOM.focus",
9923 "DOM.getAttributes",
9924 "DOM.getBoxModel",
9925 "DOM.getContentQuads",
9926 "DOM.getFlattenedDocument",
9927 "DOM.getNodeForLocation",
9928 "DOM.getOuterHTML",
9929 "DOM.getRelayoutBoundary",
9930 "DOM.moveTo",
9931 "DOM.querySelector",
9932 "DOM.querySelectorAll",
9933 "DOM.removeAttribute",
9934 "DOM.removeNode",
9935 "DOM.requestNode",
9936 "DOM.setAttributeValue",
9937 "DOM.setAttributesAsText",
9938 "DOM.setNodeName",
9939 "DOM.setNodeValue",
9940 "DOM.setOuterHTML",
9941 "DOM.collectClassNames",
9942 "DOM.getNodeStackTraces",
9943 "DOM.scrollIntoViewIfNeeded",
9944 // Accessibility domain methods that require nodeId
9945 "Accessibility.getPartialAXTree",
9946 "Accessibility.getFullAXTree",
9947 // CSS domain methods that require nodeId
9948 "CSS.getComputedStyleForNode",
9949 "CSS.getInlineStylesForNode",
9950 "CSS.getMatchedStylesForNode",
9951 "CSS.getBackgroundColors",
9952 // Add any experimental or newer methods if needed (none currently require nodeId in stable CDP)
9953 };
9954
9955 public static string BuildCommand<TParameters>(
9956 DevToolsMethods method,
9957 TParameters parameters,
9958 long commandId,
9959 string sessionId = null,
9960 int docRootId = 1)
9961 {
9962 string methodString = GetMethodString(method);
9963 object finalParameters = parameters;
9964
9965 // Check if the method requires a nodeId and parameters are provided
9966 if (MethodsRequiringNodeId.Contains(methodString) && parameters != null)
9967 {
9968 // Convert parameters to dictionary for manipulation
9969 IDictionary<string, object> paramDict;
9970 if (parameters is IDictionary<string, object> dict)
9971 {
9972 paramDict = dict;
9973 }
9974 else
9975 {
9976 // Handle anonymous objects
9977 paramDict = parameters.GetType()
9978 .GetProperties()
9979 .ToDictionary(p => p.Name, p => p.GetValue(parameters));
9980 }
9981
9982 finalParameters = paramDict;
9983 }
9984
9985 string paramsString = "";
9986 if (finalParameters != null)
9987 {
9988 IDictionary<string, object> paramDict;
9989 if (finalParameters is IDictionary<string, object> dict)
9990 {
9991 paramDict = dict;
9992 }
9993 else
9994 {
9995 paramDict = finalParameters.GetType()
9996 .GetProperties()
9997 .ToDictionary(p => p.Name, p => p.GetValue(finalParameters));
9998 }
9999
10000 var filteredDict = paramDict.Where(kvp => kvp.Value != null)
10001 .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
10002 if (filteredDict.Count > 0)
10003 {
10004 paramsString = $", \"params\": {JsonConvert.SerializeObject(filteredDict)}";
10005 }
10006 }
10007
10008 string sessionString = sessionId != null ? $"\"sessionId\": \"{sessionId}\"," : "";
10009 string command = $"{{\"id\": {commandId}, {sessionString} \"method\": \"{methodString}\"{paramsString}}}";
10010
10011 // NOTE: this can be quite excessive
10012 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"Generated command: [{command}]");
10013
10014 return command;
10015 }
10016
10023 internal static string GetMethodString(DevToolsMethods method)
10024 {
10025 var attr = method.GetAttributeOfType<CommandAttribute>();
10026 return attr?.CommandString ?? method.ToString().Replace('_', '.');
10027 }
10028 }
10029
10030 public static class EnumExtensions
10031 {
10032 public static TAttribute GetAttributeOfType<TAttribute>(this Enum enumVal) where TAttribute : Attribute
10033 {
10034 var type = enumVal.GetType();
10035 var memInfo = type.GetMember(enumVal.ToString());
10036 var attributes = memInfo[0].GetCustomAttributes(typeof(TAttribute), false);
10037 return (attributes.Length > 0) ? (TAttribute)attributes[0] : null;
10038 }
10039 }
10040 public static class TaskExtensions
10041 {
10042 public static async Task<T> TimeoutAfter<T>(this Task<T> task, TimeSpan timeout, string what = null)
10043 {
10044 Task completedTask = null;
10045 using (var cts = new CancellationTokenSource())
10046 {
10047 var delayTask = Task.Delay(timeout, cts.Token);
10048 try
10049 {
10050 completedTask = await Task.WhenAny(task, delayTask).ConfigureAwait(false);
10051 }
10052 catch (Exception ex)
10053 {
10054 var ex2 = ex;
10055 }
10056
10057 if (completedTask == delayTask)
10058 {
10059 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{what ?? "Operation"}] timed out after [{timeout.TotalSeconds}]s", null, GPALObjectType.Puppeteer);
10060 return default;
10061 }
10062
10063 // Cancel the delay task immediately so it stops consuming system resources
10064 cts.Cancel();
10065
10066 if (task.IsFaulted)
10067 {
10068 if (false == task.Exception?.InnerException?.Message.Contains("wasn't found"))
10069 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Task faulted: [{task.Exception?.InnerException?.Message ?? "Unknown error"}]", null, GPALObjectType.Puppeteer);
10070 return default;
10071 }
10072
10073 if (task.IsCanceled)
10074 {
10075 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Task canceled", null, GPALObjectType.Puppeteer);
10076 return default;
10077 }
10078
10079 // using task.Result avoids spinning up another await context state machine.
10080 return task.Result;
10081 }
10082 }
10083 }
10084
10085 public class BoxModelResponse
10086 {
10090 public Model Model { get; set; }
10091 }
10092
10093 public class Model
10094 {
10098 public double[] Content { get; set; } // Use double[] for the array of numbers
10102 public double[] Padding { get; set; }
10106 public double[] Border { get; set; }
10110 public double[] Margin { get; set; }
10114 public int Width { get; set; }
10118 public int Height { get; set; }
10119 }
10120}
10121
Model Model
Gets or sets the box model details (content, padding, border, margin boxes and dimensions) for the qu...
static void SeleniumRemoveScriptToEvaluateOnNewDocument(BrowserSettings browserSettings, string identifier)
Removes a script previously registered via SeleniumAddScriptToEvaluateOnNewDocument,...
static string GetCurrentUrl(BrowserSettings browserSettings)
Returns the current page URL, queried via OttoMagic, Puppeteer, or the Selenium WebDriver depending o...
Browser object that contains the fluent methods to create your Browser workflow. Instatiated using G...
Definition Browser.cs:68
BrowserSettings BrowserSettings
The settings backing this Browser, including configuration, state, and engine handles.
Definition Browser.cs:7048
int ServerResponseCode
HTTP status code of the most recent page navigation/response.
Definition Browser.cs:6918
double[] Content
Gets or sets the quad of (x, y) coordinate pairs describing the content box, in the order top-left,...
int Height
Gets or sets the overall height of the node's box model, in pixels.
int Width
Gets or sets the overall width of the node's box model, in pixels.
string ObjectId
The CDP remote object id of the resolved DOM node.
int BackendNodeId
The CDP backend node id of the resolved DOM node.
int NodeId
The CDP DOM node id of the resolved DOM node.
async Task< bool > IsClickable(string elementId, string sessionId=null)
Determines whether the element with the given DOM id is clickable, using the browser's checkVisibilit...
async Task< Dictionary< string, object > > GetDomAttributes(int backendNodeId, string sessionId=null)
Retrieves all DOM attributes (name/value pairs) of the element identified by the given backend node i...
async Task< string > GetXPathForElement(int backendNodeId, bool optimized=true, string sessionId=null)
Generates an XPath expression that identifies the element with the given backend node id.
async Task< int > QueryByCss(string selector, string sessionId=null)
Queries the document for the first element matching the given CSS selector.
async Task< int > WindowOuterHeight(string sessionId=null)
Gets the outer height of the browser window (window.outerHeight), including browser chrome,...
async Task ScrollWindow(int hPixels, int vPixels, string sessionId=null)
Scrolls the whole window/viewport by the specified number of pixels horizontally and vertically,...
async Task< bool > Minimize(string sessionId=null)
Minimizes the browser window. Implementation for the "minimize" workflow action.
async Task RestoreDownloadBehavior(string sessionId=null)
Hands the download directory back to the browser, so a profile the workflow borrowed is not left savi...
async Task< dynamic > ExecuteJavaScript(string expression, string sessionId=null)
Evaluates an arbitrary JavaScript expression in the current page context and returns its value.
async Task< string > PreviousWindow(string currentTargetId)
Switches the active browser window/context to the previous window in the window queue (wrapping aroun...
async Task SetDownloadBehavior(string downloadPath, string sessionId=null)
Accept downloads into downloadPath without asking where to put them. Browser scoped rather than pag...
async Task< bool > Maximize(string sessionId=null)
Maximizes the browser window so it fills the primary screen. Implementation for the "maximize" workfl...
async Task< string > GetCurrentUrl(string sessionId=null)
Gets the URL of the currently active page/frame by evaluating window.location.href.
async Task< string > GetXPathOrCssForElement(int backendNodeId, string jsFunc, bool optimized, string sessionId)
Shared helper used by GetCssForElement and GetXPathForElement that resolves the element with the give...
async Task WaitForEvent(string eventName, TimeSpan timeout)
Polls the internal CDP event queue until an event named eventName is dequeued or the specified timeo...
async Task< string > InjectScript(string scriptToInject, string sessionId=null)
Registers a script via EnableFetchScriptInjection and tracks its CDP identifier so it can later be re...
async Task< int > GetCurrentWindow(string sessionId=null)
Gets the browser window id associated with the current target via Browser.getWindowForTarget.
async Task< bool > DeleteStorage(string storageType, string sessionId, string domain=null, string storeName=null, string path=null, string key=null)
written by chatgpt Deletes browser storage items according to hierarchical wildcard rules....
async Task< bool > StopCasting(string sessionId)
Attempts to stop an active Cast session for the current sink. Note: there may be no reliable programm...
async Task FillInOverwrite(string objectId, string text, int delayMs=0, string sessionId=null)
Replaces an element's current value entirely with the given text using FillIn.
async Task< bool > WaitForLayoutReady(GPALElement element, string sessionId, int maxWaitMs=3000, int pollIntervalMs=150)
Polls until the given element's layout has stabilized with non-zero dimensions, first using DOM....
async Task< bool > ScrollWindowByPixelsAsync(int hPixels, int vPixels, string sessionId)
Scrolls the page or the nearest scrollable element by the given relative pixel offsets,...
async Task Focus(int backendNodeId, string sessionId=null)
Sets browser focus on the element identified by the given backend node id, using DOM....
async Task< bool > Normal(string sessionId=null)
Restores the browser window to its normal (non-maximized, non-minimized) state. Implementation for th...
string CurrentTargetID
The target id of the PuppeteerClient's currently selected tab/session.
async Task SwitchToFrame(string frameSelector)
Resolves an iframe element matching the given selector and switches the current evaluation context in...
async Task< bool > CloseWindow(string browserContextId, string sessionId=null)
Closes all tabs belonging to the browser window with the given browser context id,...
async Task< string > GetTargetWindowIdByUrl(string url, string sessionId=null)
Searches every tracked window and tab for one whose underlying CDP target URL contains url ,...
async Task< string > EnableFetchScriptInjection(string scriptToInject, string sessionId=null)
Registers a JavaScript snippet to be evaluated automatically on every new document load for the given...
async Task< int > WindowInnerWidth(string sessionId=null)
Gets the width of the browser window's viewport (window.innerWidth), in pixels.
async Task< dynamic > FillIn(string objectId, string text, int delayMs=0, string sessionId=null)
Fills a form field with the given text, trying multiple strategies in order of speed/reliability: bul...
async Task< string > GetStorage(string storageType, string sessionId, string domain=null, string storeName=null, string path=null, string key=null)
written by chatgpt Retrieves browser storage items as raw JSON text. Supports hierarchical wildcards:...
async Task< bool > CheckNetworkIdle(string sessionId=null, int maxConnections=0, int timeoutMs=30000, int pruneMs=3000, string sessionToken=null)
Enables network event monitoring and waits until no new network requests have been observed for prune...
async Task< dynamic > CaptureVisibleTab(string sessionId=null)
Captures a screenshot of the currently visible tab as a PNG image.
string GetEffectiveSessionId()
Gets the session ID that should be used for the current operation, preferring the current iframe sess...
async Task CastDesktop(string sinkName, string sessionId)
Searches for a Cast device with the given sink name and, if found, starts mirroring the entire deskto...
async Task FillInInsert(string objectId, string text, int delayMs=0, string sessionId=null)
Inserts text before an element's current value (prepending it), then fills it in using FillIn.
async Task ClickElement(GPALElement element, string sessionId, List< dynamic > responses, ClickType clickType=ClickType.LeftClick, int modifiers=0)
Performs a click (or other configured click type) on the given element, using CDP input dispatch,...
void ClearCapturedCalls()
Forgets everything recorded so far. Recording carries on.
async Task< bool > CloseTab(string url=null, string tabId=null, string sessionId=null)
Closes a browser tab identified by tab id, URL, or (if neither is given) the current tab.
string GetCurrentTargetId()
Gets the CDP target ID of the active tab in the active window.
async Task DragAndDrop(GPALElement element, string sessionId, List< dynamic > responses, int deltaX, int deltaY, int offsetX=0, int offsetY=0)
Drags element by (deltaX , deltaY ) pixels from its current center, by dispatching a mousePressed/mo...
async Task< string > NextWindow(string currentTargetId)
Switches the active browser window/context to the next window in the window queue (wrapping around),...
async Task< string > GetCssForElement(int backendNodeId, bool optimized=false, string sessionId=null)
Generates a CSS selector string that uniquely identifies the element with the given backend node id.
async Task SwitchToElement(string elementSelector)
Resolves the shadow root of an element matching the given selector and stores its remote object id so...
async Task< string > GetTargetTabIdByUrl(string url, string sessionId=null)
Searches for an open tab whose CDP target URL contains url , checking the tabs of the currently activ...
async Task PageEnd(string sessionId=null)
Scrolls to the very bottom of the page. Implementation for the "page-end" workflow action.
ConcurrentQueue< KeyValuePair< string, string > > CurrentSessions
Queue of (sessionId, targetId) pairs for tabs in the currently active window.
async Task< bool > ScrollIntoView(GPALElement element, string sessionId=null)
Scrolls the given element into the viewport, first attempting the native CDP DOM.scrollIntoViewIfNeed...
async Task ClearInjectedScripts(string sessionId=null)
Removes all scripts previously registered via InjectScript.
async Task LeftClickAndDownload(List< GPALElement > elems, string downloadPath, int modifiers, string sessionId, List< dynamic > responses)
Performs a left click on each element in elems to trigger a file download (headless mode)....
async Task< bool > GoTo(string url, string sessionId=null)
Navigates the current page/tab to the given URL.
async Task RemoveInjectedScript(string identifier, string sessionId=null)
Removes a script previously registered via EnableFetchScriptInjection, using Page....
int ServerResponseCode
server response code from last operation
async Task< bool > ScrollToPositionAsync(string position, string s)
Scrolls the page to the very top or very bottom, trying window.scrollTo first, then the nearest scrol...
List< GPALCall > GetCapturedCalls()
Everything recorded so far, oldest first. A copy, so reading it is never reading what the browser is ...
async Task CaptureCalls(bool capture, string sessionId=null)
Sets headers to be sent with every request for the rest of the session, which is how a credential tha...
async Task< string > CreateTarget(string url, bool newWindow=false, string sessionId=null)
Navigates to a new target by creating a new tab (or, optionally, a new browser window/context),...
async Task< dynamic > Back(string sessionId=null)
Navigates the page back to the previous entry in its navigation history, if one exists.
async Task< List< GPALElement > > EvaluateSelector(string selector, string sessionId=null, bool isRecursive=false)
Resolves a CSS selector, XPath expression, CDP node id, or remote object id to a list of matching GPA...
async Task< string > GoToTab(object tabIdOrUrlOrIndex, string sessionId=null)
Switches the active tab to the one identified by the given index, target id, or URL.
async Task CastTab(string sinkName, string sessionId)
Searches for a Cast device with the given sink name and, if found, starts mirroring the current tab t...
async Task LeftClickAndUpload(List< GPALElement > elems, object uploadFiles, int modifiers, string sessionId, List< dynamic > responses)
Attaches one or more files to file input element(s) using CDP. Supports single file path (string) or ...
ConcurrentQueue< KeyValuePair< string, string > > TabQueue
Queue of (sessionId, targetId) pairs for tabs open within this window session.
string BrowserContextId
The CDP browser context id (window) this session belongs to.
int ActiveTabIndex
Index of the currently active tab within TabQueue.
Represents a client-side bounding rectangle (similar to DOMRect) with additional convenience properti...
float Y
Gets or sets the Y coordinate.
float Height
Gets or sets the height.
Size Size
Gets or sets the Size as a System.Drawing.Size.
float Width
Gets or sets the width.
float X
Gets or sets the X coordinate.
Pseudo element used in Applications and Browser workflows for image matching and unified automation....
string TagName
HTML tag name of the element.
string GetAttribute(string attributeName)
Gets an attribute value with fallback to internal dictionary.
string Css
CSS selector used to locate this element.
ClientRectangle BoundingRect
Client bounding rectangle with detailed coordinates.
GPAL File object instantied with GPAL.File Used to load tokens into a GPALGrid [rows/columns].
Definition GPALFile.cs:36
Everything starts here, all the GPAL controls and global settings using fluent syntax are here....
Definition GPAL.cs:49
static void PublishSimpleEvent(GPALEventType gPALEventType, string msg, dynamic gPALObject=null, Enums.GPALObjectType gPALObjectType=GPALObjectType.None, Exception ex=null)
Publish a message to either the information channel or exception channel (if exception passed in) Pub...
Definition GPAL.cs:2406