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 // Root that scoped selectors resolve against: a shadow root from InShadowDom or an element from
97 // InElement. Holds a remote object, which is valid only in the execution context it came from and is
98 // cleared when that context changes
99 private string CurrentRootObjectId { get; set; }
100
109 internal async Task EnablePageEvents()
110 {
111 await SendCommand<object>(DevToolsMethods.PageEnable, new { }, GetEffectiveSessionId()).ConfigureAwait(false);
112 }
113
114 private ConcurrentDictionary<long, TaskCompletionSource<dynamic>> _responseTasks { get; } = new ConcurrentDictionary<long, TaskCompletionSource<dynamic>>();
115 private ConcurrentQueue<(string Event, dynamic Data)> _events { get; } = new ConcurrentQueue<(string, dynamic)>();
119 internal ConcurrentQueue<WindowSession> _windowSessionsQueue { get; set; } = new ConcurrentQueue<WindowSession>();
123 public ConcurrentQueue<KeyValuePair<string, string>> CurrentSessions
124 {
125 get
126 {
127 var tabQueue = GetActiveWindowTabQueue();
128 return tabQueue ?? new ConcurrentQueue<KeyValuePair<string, string>>();
129 }
130 }
131
134 public string CurrentTargetID
135 {
136 get => ((PuppeteerClient)PuppeteerClient)._currentTargetId;
137 }
138 private int _commandId = 0xdead;
142 internal bool _isRunning { get; set; }
146 internal bool _usePipes { get; set; }
147 private CancellationTokenSource _cts;
148 private readonly SemaphoreSlim _sendSemaphore = new SemaphoreSlim(1, 1); // Added for SendAsync serialization
152 internal IPuppeteerClient PuppeteerClient { get; set; }
156 internal Browser Browser { get; set; }
160 private bool _suppressNetworkEvents { get; set; } = false;
161
162 // every request the page issued while capture was on, in the order chrome reported them. off by default,
163 // because recording every request of every run costs memory nobody asked to spend
164 internal bool CapturingCalls { get; set; } = false;
165 internal List<GPALCall> CapturedCalls { get; } = new List<GPALCall>();
166
173 public List<GPALCall> GetCapturedCalls()
174 {
175 return new List<GPALCall>(CapturedCalls);
176 }
177
181 public void ClearCapturedCalls()
182 {
183 CapturedCalls.Clear();
184 }
185
186 // requestId to the call it belongs to, so the status the site answered with lands on the right one
187 private readonly Dictionary<string, GPALCall> _capturedById = new Dictionary<string, GPALCall>();
191 internal Task ReceiveTask { get; set; }
195 internal TaskCompletionSource<bool> _readerReadyTcs { get; set; }
196
197 // Per instance. Static, one browser would suppress another browser's messages
198 List<string> lastErrorMessage = new List<string>();
199
200 // CDP answers one of these for a node with no layout box. The element is there and simply is not drawn
201 static readonly string[] NotRenderedAnswers = { "Node does not have a layout object", "Could not compute box model" };
202 bool supressedMessage = false;
203 string lastSessionToken;
204 // Per instance. Static, one browser closing would block commands from every other browser
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 // Per instance. The client holds the target it is bound to, so a shared one would send both
232 // browsers' commands down the socket of whichever was built last
233 PuppeteerClient = new PuppeteerClient();
234 _usePipes = usePipes;
235 _windowSessionsQueue = new ConcurrentQueue<WindowSession>(); // Initialize with new type
236
237 _puppeteerUrl = puppeteerUrl;
238
239 // longer than the wait inside GetWebSocketUrl, so a browser that is merely slow to open its debug
240 // port is waited out rather than killed by this while that one is still counting
241 var initTask = InitializeCommunication(Browser);
242 bool initFinished;
243
244 try
245 {
246 initFinished = initTask.Wait(TimeSpan.FromSeconds(45));
247 }
248 catch (AggregateException ex)
249 {
250 // Wait wraps what the init threw. the reason leaves here instead, with its own stack
251 Exception reason = ex.Flatten().InnerExceptions.FirstOrDefault() ?? ex;
252
253 System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(reason).Throw();
254
255 throw; // never reached, the compiler cannot know that
256 }
257
258 if (false == initFinished)
259 {
260 string msg = $"Puppeteer communication initialization timed out talking to [{puppeteerUrl}]. Workflow canot run without it, Try again.";
261
262 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, this, GPALObjectType.PuppeteerCommunicator);
263
264 // GPAL's own, so it arrives on the GPAL channel and a workflow that catches GPALException catches
265 // this too. a bare framework exception out of a constructor is nobody's to answer for
266 throw new GPALException($"{GPAL.MyMethodName()}: {msg}");
267 }
268 ReceiveTask = initTask.GetAwaiter().GetResult(); // Already finished, this just unwraps results/
269
270 //InitializeCommunication().GetAwaiter().GetResult();
271
272 // the client dispatches from its own endpointMap and never makes an http request, so there was no
273 // base url to give it. Whatever chooses the transport when pipes are finished, it will not be here
274 PuppeteerClient.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 bool notRendered = true == NotRenderedAnswers.Any(answer => true == $"{message}".StartsWith(answer));
843 string msg = true == notRendered ? "Element is not rendered, so it has no position on the page." : $"CDP error : [{message}].";
844 if (false == lastErrorMessage.Contains(msg)) // we are not in control of lastmessages.clear, whomever calls us is
845 {
846 lastErrorMessage.Add(msg);
847 GPAL.PublishSimpleEvent(true == notRendered ? GPALEventType.INFO : GPALEventType.ERROR, msg, this, GPALObjectType.PuppeteerCommunicator);
848 supressedMessage = false;
849 }
850 else if (false == supressedMessage)
851 {
852 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", msg, GPALObjectType.Other);
853 supressedMessage = true;
854 }
855
856 throw new ApplicationException(msg);
857 }
858 else if (message?.error != null)
859 {
860 if (true == "Session with given id not found.".Equals(message?.error?.message?.ToString()))
861 {
862 if (1 < retry--)
863 {
864 ClearCurrentSessionId(); // the stored one is dead, so drop it and let GetCurrentSessionId attach a fresh one
865 CurrentFrameSessionId = null;
866 sessionId = GetEffectiveSessionId();
867 continue;
868 }
869 }
870
871 bool notRendered = true == NotRenderedAnswers.Any(answer => true == $"{message.error.message}".StartsWith(answer));
872 string msg = true == notRendered ? "Element is not rendered, so it has no position on the page." : $"CDP error : [{message.error.message}].";
873 if (false == lastErrorMessage.Contains(msg)) // we are not in control of lastmessages.clear, whomever calls us is
874 {
875 lastErrorMessage.Add(msg);
876 GPAL.PublishSimpleEvent(true == notRendered ? GPALEventType.INFO : GPALEventType.ERROR, msg, this, GPALObjectType.PuppeteerCommunicator);
877 supressedMessage = false;
878 }
879 else if (false == supressedMessage)
880 {
881 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", msg, GPALObjectType.Other);
882 supressedMessage = true;
883 }
884
885 throw new ApplicationException(msg);
886 }
887 }
888 catch //(Exception ex)
889 {
890 //var noErrorMessage = true;
891 }
892
893 if (null == resultExtractors)
894 {
895 var protocolJson = GetProtocolJSON();
896 resultExtractors = CdpResultExtractors.Build(protocolJson);
897 }
898 // Handle response data
899 if (resultExtractors != null && resultExtractors.TryGetValue(method, out var extractor))
900 {
901 if (null == message || message is string || message.result == null)
902 {
903 string msg = $"Command [{method}] returned null result.";
904 if (false == lastErrorMessage.Contains(msg))
905 {
906 lastErrorMessage.Add(msg);
907 GPAL.PublishSimpleEvent(GPALEventType.WARNING, msg, this, GPALObjectType.PuppeteerCommunicator);
908 supressedMessage = false;
909 }
910 else if (false == supressedMessage)
911 {
912 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", msg, GPALObjectType.Other);
913 supressedMessage = true;
914 }
915 return null;
916 }
917 data = extractor((Newtonsoft.Json.Linq.JObject)message?.result);
918 }
919 else
920 {
921 data = message.result?.ToObject<dynamic>() ?? new { };
922 }
923
924 break;
925 //if (data?.Length > 50_000_000) // Limit to 50MB
926 //{
927 // GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Message data too large", this, GPALObjectType.PuppeteerCommunicator);
928 // return null;
929 //}
930 }
931 }
932 finally
933 {
934 if (false == doNotGetSemaphore)
935 _sendSemaphore.Release();
936 }
937 }
938 catch (Exception ex)
939 {
940 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Command [{method}][{parameters}] failed.", null, GPALObjectType.None, ex);
941 }
942
943 return data;
944 }
953 public async Task<string> EnableFetchScriptInjection(string scriptToInject, string sessionId = null)
954 {
955 sessionId ??= GetEffectiveSessionId();
956 string identifier = null;
957 try
958 {
959 if (true == Browser.UseSelenium)
960 {
961 // Use Selenium CDP command for non-Puppeteer case
962 identifier = BrowserHelper.SeleniumAddScriptToEvaluateOnNewDocument(Browser.BrowserSettings, scriptToInject);
963 }
964 else if (true == Browser.UsePuppeteer)
965 {
966 // Use Puppeteer CDP command directly
967 var result = await SendCommand<object>(DevToolsMethods.PageAddScriptToEvaluateOnNewDocument, new
968 {
969 source = scriptToInject
970 }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId).ConfigureAwait(false);
971 identifier = result?.identifier?.ToString();
972 }
973
974 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Script injected via CDP for sessionId: [{sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
975 }
976 catch (Exception ex)
977 {
978 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to inject script via CDP", this, GPALObjectType.Puppeteer, ex);
979 throw;
980 }
981
982 return identifier;
983 }
984
991 public async Task RemoveInjectedScript(string identifier, string sessionId = null)
992 {
993 if (string.IsNullOrEmpty(identifier))
994 {
995 return;
996 }
997
998 sessionId ??= GetEffectiveSessionId();
999 try
1000 {
1001 if (true == Browser.UseSelenium)
1002 {
1003 BrowserHelper.SeleniumRemoveScriptToEvaluateOnNewDocument(Browser.BrowserSettings, identifier);
1004 }
1005 else if (true == Browser.UsePuppeteer)
1006 {
1007 await SendCommand<object>(DevToolsMethods.PageRemoveScriptToEvaluateOnNewDocument, new
1008 {
1009 identifier
1010 }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId).ConfigureAwait(false);
1011 }
1012
1013 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Injected script [{identifier}] removed via CDP for sessionId: [{sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
1014 }
1015 catch (Exception ex)
1016 {
1017 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to remove injected script via CDP", this, GPALObjectType.Puppeteer, ex);
1018 throw;
1019 }
1020 }
1021
1022 private readonly List<string> _injectedScriptIdentifiers = new List<string>();
1023
1028 public async Task<string> InjectScript(string scriptToInject, string sessionId = null)
1029 {
1030 var identifier = await EnableFetchScriptInjection(scriptToInject, sessionId).ConfigureAwait(false);
1031 if (!string.IsNullOrEmpty(identifier))
1032 {
1033 _injectedScriptIdentifiers.Add(identifier);
1034 }
1035 return identifier;
1036 }
1037
1041 public async Task ClearInjectedScripts(string sessionId = null)
1042 {
1043 foreach (var identifier in _injectedScriptIdentifiers)
1044 {
1045 await RemoveInjectedScript(identifier, sessionId).ConfigureAwait(false);
1046 }
1047 _injectedScriptIdentifiers.Clear();
1048 }
1049 // using fetchenable request - doesn't work due to nonce issues
1050 // 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
1051 // but we are already stealthy except synthetic events, but then use hardware and chrome mode, not headless
1052 //public async Task EnableFetchScriptInjection(string scriptToInject, string sessionId = null)
1053 //{
1054 // try
1055 // {
1056 // // Ensure Fetch domain is enabled with retries
1057 // int maxRetries = 3;
1058 // for (int attempt = 0; attempt < maxRetries; attempt++)
1059 // {
1060 // try
1061 // {
1062 // await SendCommand<object>(DevToolsMethods.FetchEnable, new
1063 // {
1064 // patterns = new[]
1065 // {
1066 // new { urlPattern = "*", resourceType = "Document", requestStage = "Response" }
1067 // }
1068 // }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId);
1069 // GPAL.PublishSimpleEvent(GPALEventType.INFO, $"FetchEnable successful on attempt {attempt + 1}", this, GPALObjectType.PuppeteerCommunicator);
1070 // break;
1071 // }
1072 // catch (Exception ex)
1073 // {
1074 // GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"FetchEnable failed on attempt {attempt + 1}: {ex.Message}", this, GPALObjectType.PuppeteerCommunicator);
1075 // if (attempt == maxRetries - 1) throw;
1076 // await Task.Delay(500); // Wait before retrying
1077 // }
1078 // }
1079
1080 // Action<object, dynamic> handler = async (sender, e) =>
1081 // {
1082 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Fetch.requestPaused event data: {Newtonsoft.Json.JsonConvert.SerializeObject(e)}", this, GPALObjectType.PuppeteerCommunicator);
1083
1084 // var requestIdToken = e["requestId"];
1085 // if (requestIdToken == null)
1086 // {
1087 // GPAL.PublishSimpleEvent(GPALEventType.WARNING, "requestId is null in Fetch.requestPaused event. Full event data logged.", this, GPALObjectType.PuppeteerCommunicator);
1088 // // Add more context for debugging
1089 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Event context: sessionId={e.sessionId}, targetId={e.targetInfo?.targetId}, url={e.targetInfo?.url}", this, GPALObjectType.PuppeteerCommunicator);
1090 // return; // Exit if requestId is missing
1091 // }
1092
1093 // var requestId = requestIdToken.ToString();
1094 // var responseStatusCode = e["responseStatusCode"]?.ToObject<int>() ?? 200;
1095 // var resourceType = e["resourceType"]?.ToString();
1096
1097 // if (string.IsNullOrEmpty(resourceType) || (resourceType == "Document" && responseStatusCode >= 200 && responseStatusCode < 300))
1098 // {
1099 // try
1100 // {
1101 // var response = await SendCommand<object>(DevToolsMethods.FetchGetResponseBody, new { requestId }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId);
1102 // var body = response["body"]?.ToString();
1103 // var isBase64 = response["base64Encoded"]?.ToObject<bool>() ?? false;
1104
1105 // if (string.IsNullOrEmpty(body))
1106 // {
1107 // GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Empty body received for requestId: {requestId}", this, GPALObjectType.PuppeteerCommunicator);
1108 // await SendCommand<object>(DevToolsMethods.FetchContinueRequest, new { requestId }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId);
1109 // return;
1110 // }
1111
1112 // if (isBase64)
1113 // {
1114 // body = Encoding.UTF8.GetString(Convert.FromBase64String(body));
1115 // }
1116
1117 // // Extract the existing nonce from responseHeaders
1118 // string existingNonce = null;
1119 // var responseHeaders = e["responseHeaders"] as IEnumerable<dynamic>;
1120 // string cspHeader = null;
1121 // if (responseHeaders != null)
1122 // {
1123 // cspHeader = responseHeaders
1124 // .Where(h => h["name"].ToString().ToLower() == "content-security-policy")
1125 // .Select(h => h["value"].ToString())
1126 // .FirstOrDefault();
1127 // if (!string.IsNullOrEmpty(cspHeader))
1128 // {
1129 // var nonceMatch = System.Text.RegularExpressions.Regex.Match(cspHeader, @"nonce-([a-zA-Z0-9+/=]{16})");
1130 // if (nonceMatch.Success)
1131 // {
1132 // existingNonce = nonceMatch.Groups[1].Value;
1133 // }
1134 // }
1135 // }
1136
1137 // // Use the existing nonce if found, otherwise generate a new one
1138 // var nonce = !string.IsNullOrEmpty(existingNonce) ? $"nonce-{existingNonce}" : $"nonce-{Guid.NewGuid().ToString("N").Substring(0, 16)}";
1139 // var modifiedBody = body.Replace(
1140 // "<head>",
1141 // $"<head><script {nonce}>{scriptToInject}</script>"
1142 // );
1143
1144 // var responseHeadersList = new List<object>
1145 // {
1146 // new { name = "Content-Type", value = "text/html" }
1147 // };
1148
1149 // if (!string.IsNullOrEmpty(existingNonce) && cspHeader != null)
1150 // {
1151 // responseHeadersList.Add(new { name = "Content-Security-Policy", value = cspHeader });
1152 // }
1153 // else if (!string.IsNullOrEmpty(existingNonce))
1154 // {
1155 // responseHeadersList.Add(new { name = "Content-Security-Policy", value = $"script-src 'nonce-{existingNonce}' 'strict-dynamic' https: http:; object-src 'none';" });
1156 // }
1157 // else
1158 // {
1159 // responseHeadersList.Add(new { name = "Content-Security-Policy", value = $"script-src {nonce} 'strict-dynamic' https: http:; object-src 'none';" });
1160 // }
1161
1162 // await SendCommand<object>(DevToolsMethods.FetchFulfillRequest, new
1163 // {
1164 // requestId,
1165 // responseCode = responseStatusCode,
1166 // responseHeaders = responseHeadersList,
1167 // body = Convert.ToBase64String(Encoding.UTF8.GetBytes(modifiedBody))
1168 // }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId);
1169
1170 // GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Injected script into HTML response for requestId: {requestId} with nonce: {nonce}", this, GPALObjectType.PuppeteerCommunicator);
1171 // }
1172 // catch (Exception ex)
1173 // {
1174 // GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Failed to inject script: {ex.Message}", this, GPALObjectType.Puppeteer, ex);
1175 // await SendCommand<object>(DevToolsMethods.FetchContinueRequest, new { requestId }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId);
1176 // }
1177 // }
1178 // else
1179 // {
1180 // await SendCommand<object>(DevToolsMethods.FetchContinueRequest, new { requestId }, sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId);
1181 // }
1182 // };
1183
1184 // string eventKey = $"Fetch.requestPaused_{sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId}";
1185 // _eventHandlers[eventKey] = handler;
1186
1187 // GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Subscribed to Fetch.requestPaused for sessionId: {sessionId ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId}", this, GPALObjectType.PuppeteerCommunicator);
1188 // }
1189 // catch (Exception ex)
1190 // {
1191 // GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to enable Fetch script injection: {ex.Message}", this, GPALObjectType.Puppeteer, ex);
1192 // throw;
1193 // }
1194 //}
1205 internal async Task ReceivePipeMessages(CancellationToken ct)
1206 {
1207 // Signal: "I'm alive and reading"
1208 _readerReadyTcs?.SetResult(true);
1209
1210 var stream = Browser.BrowserSettings.OutboundPipe; // FileStream (byte-oriented)
1211 if (stream == null || !stream.IsConnected || !stream.CanRead)
1212 {
1213 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "OutboundPipe null, disconnected, or not readable", this, GPALObjectType.PuppeteerCommunicator);
1214 return;
1215 }
1216
1217 var encoding = Encoding.UTF8;
1218
1219 // Helper to process one complete JSON text message (unchanged)
1220 void ProcessMessageString(string text)
1221 {
1222 try
1223 {
1224 dynamic message = Newtonsoft.Json.JsonConvert.DeserializeObject(text);
1225 if (message == null) return;
1226
1227 if (message.id != null)
1228 {
1229 if (_responseTasks.TryRemove((int)message.id, out TaskCompletionSource<dynamic> tcs))
1230 {
1231 if (message.error != null)
1232 {
1233 string errorMessage = (message.error.message != null ? message.error.message.ToString() :
1234 message.error.ToString()) ?? "Unknown CDP error";
1235 if (!errorMessage.Contains("wasn't found"))
1236 GPAL.PublishSimpleEvent(GPALEventType.ERROR, errorMessage, this, GPALObjectType.PuppeteerCommunicator);
1237 tcs.TrySetResult(errorMessage);
1238 }
1239 else
1240 {
1241 tcs.TrySetResult(message);
1242 }
1243 }
1244 }
1245 else if (message.method != null)
1246 {
1247 string method = (string)message.method;
1248
1249 // turning the Page domain on to hear about dialogs brings every lifecycle and frame event with it,
1250 // and the dynamic reads below are not cheap enough to run on all of them for nothing
1251 if (true == method.StartsWith("Page.") && "Page.javascriptDialogOpening" != method)
1252 return;
1253
1254 dynamic paramsData = message.@params;
1255 string title = paramsData?.targetInfo?.title;
1256 string targetType = paramsData?.targetInfo?.type?.ToString();
1257 string sessionId = message.sessionId?.ToString() ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId;
1258 string url = paramsData?.targetInfo?.url?.ToString() ?? "unknown";
1259
1260 if (method.StartsWith("Network."))
1261 {
1262 if (method == "Network.requestWillBeSent" ||
1263 method == "Network.loadingFinished" ||
1264 method == "Network.loadingFailed" ||
1265 method == "Network.requestServedFromCache" ||
1266 method == "Network.requestWillBeSentExtraInfo")
1267 {
1268 _events.Enqueue((method, paramsData));
1269 }
1270
1271 // recorded before the suppression check, so a capture still sees the requests made
1272 // while something else is waiting for the network to go quiet
1273 if (true == CapturingCalls && "Network.requestWillBeSent" == method)
1274 RecordCall(paramsData);
1275 else if (true == CapturingCalls && "Network.responseReceived" == method)
1276 RecordStatus(paramsData);
1277
1278 if (_suppressNetworkEvents)
1279 return;
1280 }
1281
1282 // a dialog stops the page dead until it is answered, so it is answered from here rather than
1283 // handed to a workflow that cannot reach the page while one is up. the page's own alert,
1284 // confirm and prompt are untouched, so nothing about this is visible to it
1285 if ("Page.javascriptDialogOpening" == method && null != Browser.BrowserSettings.DialogsAccepted)
1286 {
1287 bool accept = true == Browser.BrowserSettings.DialogsAccepted;
1288 string typed = Browser.BrowserSettings.DialogText;
1289
1290 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Answering a [{paramsData?.type}] with accept [{accept}] text [{typed}]", this, GPALObjectType.PuppeteerCommunicator);
1291
1292 // not awaited, because this reader is the only thing that will ever see the reply arrive.
1293 // and sent past the send semaphore, because the click that raised the dialog is holding it
1294 // and cannot let go until the dialog is answered, which is what this is
1295 _ = SendCommand<object>(DevToolsMethods.PageHandleJavaScriptDialog,
1296 null == typed ? (object)new { accept } : new { accept, promptText = typed },
1297 sessionId, 3, true);
1298 }
1299
1300 if (method == "Target.targetCreated" && _sendSemaphore.CurrentCount == 1)
1301 {
1302 if (true == targetType?.Equals("page") && false == url?.StartsWith("chrome://") && false == url?.Equals("about:blank"))
1303 {
1304 string targetId = paramsData?.targetInfo?.targetId?.ToString();
1305 string browserContextId = paramsData?.targetInfo?.browserContextId?.ToString();
1306 if (!string.IsNullOrEmpty(targetId))
1307 {
1308 // TODO: this needs to be pushed onto a queue, then the next time send
1309 // Fire-and-forget is fine here
1310 _ = HandleNewTarget(targetId, browserContextId, null, true);
1311 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Detected new uncontrolled tab: TargetId [{targetId}] in Window [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
1312 }
1313 }
1314 }
1315
1316 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"Received (pipe): [{text}]", this, GPALObjectType.PuppeteerCommunicator);
1317 }
1318 }
1319 catch (Exception ex)
1320 {
1321 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Error processing pipe message", this, GPALObjectType.PuppeteerCommunicator, ex);
1322 }
1323 }
1324
1325 // CDP over a pipe separates messages with a nul byte and never says how long one is. read bytes
1326 // until one turns up, and what came before it is a whole message
1327 var pending = new List<byte>();
1328 var chunk = new byte[65536];
1329
1330 try
1331 {
1332 while (_isRunning && !Browser.BrowserSettings.Process.HasExited && !ct.IsCancellationRequested)
1333 {
1334 var readTask = stream.ReadAsync(chunk, 0, chunk.Length, ct);
1335
1336 if (await Task.WhenAny(readTask, Task.Delay(5000, ct)).ConfigureAwait(false) != readTask)
1337 {
1338 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No data on OutboundPipe after 5s - Chrome not responding", this, GPALObjectType.PuppeteerCommunicator);
1339 continue;
1340 }
1341
1342 int bytesRead = readTask.Result;
1343
1344 if (0 == bytesRead)
1345 break; // EOF
1346
1347 for (int i = 0; i < bytesRead; i++)
1348 {
1349 if (0 != chunk[i])
1350 {
1351 pending.Add(chunk[i]);
1352 continue;
1353 }
1354
1355 // a nul with nothing before it is a separator after a message already taken
1356 if (0 < pending.Count)
1357 {
1358 ProcessMessageString(encoding.GetString(pending.ToArray()));
1359 pending.Clear();
1360 }
1361 }
1362 }
1363 }
1364 catch (Exception ex)
1365 {
1366 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"ReceivePipeMessages failed", this, GPALObjectType.PuppeteerCommunicator, ex);
1367 }
1368 }
1380 private async Task ReceiveMessages(CancellationToken ct)
1381 {
1382 var buffer = new byte[102400];
1383
1384 while (_isRunning && _webSocket.State == WebSocketState.Open)
1385 {
1386 // Check if the cancellation token was canceled before proceeding
1387 // prolly connecting to a new page after closing a tab or window?
1388 if (ct.IsCancellationRequested)
1389 {
1390 // Handle cancellation gracefully
1391 try
1392 {
1393 await _webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Cancellation requested", CancellationToken.None).ConfigureAwait(false);
1394 _webSocket.Dispose();
1395 }
1396 catch (Exception ex)
1397 {
1398 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Error during WebSocket cleanup on cancellation", this, GPALObjectType.PuppeteerCommunicator, ex);
1399 }
1400 break;
1401 }
1402
1403 try
1404 {
1405 using var ms = new System.IO.MemoryStream();
1406 WebSocketReceiveResult result;
1407 do
1408 {
1409 result = await _webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), ct).ConfigureAwait(false);
1410 ms.Write(buffer, 0, result.Count);
1411 } while (!result.EndOfMessage);
1412
1413 try
1414 {
1415 if (result.MessageType == WebSocketMessageType.Close)
1416 {
1417 await _webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "WebSocket closed", CancellationToken.None).ConfigureAwait(false);
1418 break;
1419 }
1420 }
1421 catch (OperationCanceledException)
1422 {
1423 // Graceful cancellation
1424 }
1425 catch (WebSocketException ex)
1426 {
1427 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"WebSocket error", this, GPALObjectType.PuppeteerCommunicator, ex);
1428 }
1429 finally
1430 {
1431 if (_webSocket.State == WebSocketState.Closed || _webSocket.State == WebSocketState.Aborted)
1432 {
1433 try { await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Cleanup", CancellationToken.None).ConfigureAwait(false); } catch { }
1434 _webSocket.Dispose();
1435 _webSocket = null;
1436 }
1437 }
1438
1439 if (null != ms)
1440 {
1441 var json = Encoding.UTF8.GetString(ms.ToArray());
1442 var message = Newtonsoft.Json.JsonConvert.DeserializeObject<dynamic>(json);
1443
1444 if (message?.id != null)
1445 {
1446 if (_responseTasks.TryRemove((int)message.id, out TaskCompletionSource<dynamic> tcs))
1447 {
1448 if (message.error != null)
1449 {
1450 string errorMessage = (message.error.message != null ? message.error.message.ToString() :
1451 message.error.ToString()) ?? "Unknown CDP error";
1452 if (false == errorMessage.Contains("wasn't found")) // trying to remove a token we removed already
1453 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, errorMessage, this, GPALObjectType.PuppeteerCommunicator);
1454 tcs.SetResult(errorMessage);
1455 }
1456 else
1457 tcs.SetResult(message);
1458 }
1459 }
1460 else if (message.method != null)
1461 {
1462 string method = (string)message.method;
1463
1464 // turning the Page domain on to hear about dialogs brings every lifecycle and frame event with it,
1465 // and the dynamic reads below are not cheap enough to run on all of them for nothing
1466 // continue, not return: this one is the receive loop itself, and returning from it
1467 // ends the reader for the life of the browser
1468 if (true == method.StartsWith("Page.") && "Page.javascriptDialogOpening" != method)
1469 continue;
1470
1471 dynamic paramsData = message.@params;
1472 string title = paramsData.targetInfo?.title;
1473 string targetType = paramsData.targetInfo?.type?.ToString();
1474 string sessionId = message.sessionId?.ToString() ?? ((PuppeteerClient)PuppeteerClient)._currentTargetId;
1475 string url = paramsData.targetInfo?.url?.ToString() ?? "unknown";
1476
1477
1478 // Filter network events when suppressed
1479 if (method.StartsWith("Network."))
1480 {
1481 // Always enqueue relevant network events
1482 if (method == "Network.requestWillBeSent" ||
1483 method == "Network.loadingFinished" ||
1484 method == "Network.loadingFailed" ||
1485 method == "Network.requestServedFromCache" ||
1486 method == "Network.requestWillBeSentExtraInfo")
1487 {
1488 _events.Enqueue((method, paramsData));
1489 }
1490
1491 // recorded before the suppression check, so a capture still sees the requests made
1492 // while something else is waiting for the network to go quiet
1493 if (true == CapturingCalls && "Network.requestWillBeSent" == method)
1494 RecordCall(paramsData);
1495 else if (true == CapturingCalls && "Network.responseReceived" == method)
1496 RecordStatus(paramsData);
1497
1498 if (method == "Network.responseReceived")
1499 {
1500 // paramsData is message.@params
1501 string type = paramsData.type?.ToString();
1502
1503 // We only care about "Document" (the actual HTML page)
1504 if (type == "Document")
1505 {
1506 this.ServerResponseCode = (int)paramsData.response.status;
1507 string responseUrl = paramsData.response.url?.ToString();
1508
1509 Browser.ServerResponseCode = this.ServerResponseCode;
1510 //GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
1511 // $"HTTP Status Captured: [{this.ServerResponseCode}] for [{responseUrl}]",
1512 // this, GPALObjectType.PuppeteerCommunicator);
1513
1514 // You might want to store this in a field like: this.LastStatusCode = statusCode;
1515 }
1516 }
1517 if (_suppressNetworkEvents)
1518 continue; // Skip logging and further processing
1519 }
1520
1521 if (method.StartsWith("Cast.")) // prolly just Cast.sinksUpdated
1522 {
1523 try
1524 {
1525 JObject payload = paramsData;
1526 JToken sink = payload["sinks"];
1527 try
1528 {
1529 if (null != sink)
1530 CastDevices.Add(new CastDevice(sink[0]["name"]?.ToString(), sink[0]["id"]?.ToString()));
1531 } catch { } // index issue
1532
1533 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
1534 $"[{method}] [{Newtonsoft.Json.JsonConvert.SerializeObject(paramsData)}]",
1535 this, GPALObjectType.PuppeteerCommunicator);
1536
1537 // TODO: Store sinks globally so CastTo() can read them later and decipher friendly name
1538 // LastKnownSinks = paramsData.sinks;
1539 }
1540 catch (Exception ex)
1541 {
1542 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
1543 $"Error processing [{method}]",
1544 this, GPALObjectType.PuppeteerCommunicator, ex);
1545 }
1546 }
1547
1548 // a dialog stops the page dead until it is answered, so it is answered from here rather than
1549 // handed to a workflow that cannot reach the page while one is up. the page's own alert,
1550 // confirm and prompt are untouched, so nothing about this is visible to it
1551 if ("Page.javascriptDialogOpening" == method && null != Browser.BrowserSettings.DialogsAccepted)
1552 {
1553 bool accept = true == Browser.BrowserSettings.DialogsAccepted;
1554 string typed = Browser.BrowserSettings.DialogText;
1555
1556 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Answering a [{paramsData?.type}] with accept [{accept}] text [{typed}]", this, GPALObjectType.PuppeteerCommunicator);
1557
1558 // not awaited, because this reader is the only thing that will ever see the reply arrive.
1559 // and sent past the send semaphore, because the click that raised the dialog is holding it
1560 // and cannot let go until the dialog is answered, which is what this is
1561 _ = SendCommand<object>(DevToolsMethods.PageHandleJavaScriptDialog,
1562 null == typed ? (object)new { accept } : new { accept, promptText = typed },
1563 sessionId, 3, true);
1564 }
1565
1566 if (method == "Target.targetCreated" && _sendSemaphore.CurrentCount == 1)
1567 {
1568 if (true == targetType.Equals("page") && false == url?.StartsWith("chrome://") && false == url?.Equals("about:blank"))
1569 {
1570 string targetId = paramsData.targetInfo?.targetId?.ToString();
1571 string browserContextId = paramsData.targetInfo?.browserContextId?.ToString();
1572
1573 if (!string.IsNullOrEmpty(targetId))
1574 {
1575 // Handle like NewTab: attach, enable domains, add to queue
1576 // TODO: this needs to be pushed onto a queue, then the next time send
1577 await HandleNewTarget(targetId, browserContextId, null, true).ConfigureAwait(false);
1578 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Detected new uncontrolled tab: TargetId [{targetId}] in Window [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
1579 }
1580 }
1581 }
1582 //else if (method == "Target.targetInfoChanged" && _sendSemaphore.CurrentCount == 1)
1583 //{
1584 // string targetId = paramsData.targetInfo?.targetId?.ToString();
1585 // string browserContextId = paramsData.targetInfo?.browserContextId?.ToString();
1586 // targetType = paramsData.targetInfo?.type?.ToString();
1587 // url = paramsData.targetInfo?.url?.ToString() ?? "unknown";
1588
1589 // if (targetType == "page" && targetId != null && true == Browser.Started)
1590 // {
1591 // if (url.StartsWith("chrome://") || url == "about:blank")
1592 // {
1593 // ; GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Skipping tab info change: TargetId [{targetId}], URL [{url}]", this, GPALObjectType.PuppeteerCommunicator);
1594 // }
1595 // else
1596 // {
1597 // // Re-attach to ensure valid sessionId
1598 // var session = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new { targetId, flatten = true }, null, 3, true);
1599 // string newSessionId = session?.sessionId?.ToString();
1600 // if (newSessionId != null)
1601 // {
1602 // await HandleNewTarget(targetId, browserContextId, newSessionId);
1603 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Processed tab info change: TargetId [{targetId}] in Window [{browserContextId}], Session [{newSessionId}]", this, GPALObjectType.PuppeteerCommunicator);
1604 // }
1605 // else
1606 // {
1607 // GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to re-attach on Target.targetInfoChanged: TargetId [{targetId}]", this, GPALObjectType.PuppeteerCommunicator);
1608 // }
1609 // }
1610 // }
1611 //}
1612 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"Received: [{Newtonsoft.Json.JsonConvert.SerializeObject(message)}]", this, GPALObjectType.PuppeteerCommunicator);
1613 }
1614 }
1615 }
1616 catch (Exception ex) // or catch (WebSocketException ex) if you want to be more specific
1617 {
1618 string innerMsg = "";
1619
1620 // Option 1 – Simple & most common (recommended for logging)
1621 if (ex.InnerException != null)
1622 {
1623 innerMsg = $" Inner: [{ex.InnerException.Message}]";
1624 // Optional: also add type if helpful
1625 // innerMsg += $" ({ex.InnerException.GetType().Name})";
1626 }
1627
1628 string exMsg = $"Error receiving WebSocket message: [{ex.Message}][{innerMsg}]";
1629
1630 // Optional – even better: walk the full inner chain (very deep nesting is rare in WebSocket scenarios)
1631 // string fullInnerChain = GetFullInnerExceptionMessage(ex);
1632
1633 GPAL.PublishSimpleEvent(
1634 GPALEventType.DEEPDEBUG,
1635 exMsg,
1636 this,
1637 GPALObjectType.Puppeteer,
1638 ex); // - still pass original ex (it already contains .InnerException)
1639
1640 // throw new ApplicationException(exMsg, ex); // - Important: pass original ex as InnerException! // can't do this kills everything
1641 }
1642 }
1643 }
1644
1654 private async Task HandleNewTarget(string targetId, string browserContextId, string newSessionId = null, bool doNotGetSendSemaphore = false, bool? isActive = null)
1655 {
1656 // a tab GPAL opened is the tab to be on, and a tab noticed arriving is not. that was being worked out
1657 // from whether a session id came with it, which had it exactly backwards: NewTab passes no session
1658 // and so marked the tab it just opened inactive, leaving every later command on the previous tab
1659 bool activeTab = isActive ?? !(null == newSessionId);
1660
1661 // Add to the window's tab queue
1662 await AddTabToQueue(targetId, newSessionId, activeTab, doNotGetSendSemaphore).ConfigureAwait(false);
1663
1664 // Set as active tab (mimic NewTab behavior)
1665 //SetActiveTabIndex(CurrentSessions.Count - 1);
1666
1667 // a new tab is a new session with the Network domain off, so a capture that was on for the page that
1668 // opened it would go quiet exactly where the interesting request is
1669 if (true == CapturingCalls)
1670 await CaptureCalls(true, newSessionId).ConfigureAwait(false);
1671
1672 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Handled new target: ID [{targetId}] in Window [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
1673 }
1687 public async Task WaitForEvent(string eventName, TimeSpan timeout)
1688 {
1689 var start = DateTime.UtcNow;
1690 while (DateTime.UtcNow - start < timeout)
1691 {
1692 if (_events.TryDequeue(out var evt) && evt.Event == eventName)
1693 return;
1694 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Waiting for [{eventName}] got [{evt.Event}]", this, GPALObjectType.PuppeteerCommunicator);
1695 await Task.Delay(100).ConfigureAwait(false);
1696 }
1697 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Event [{eventName}] not received within [{timeout.TotalSeconds}]s", this, GPALObjectType.PuppeteerCommunicator);
1698 }
1699
1719 public async Task<bool> WaitForLayoutReady(GPALElement element, string sessionId, int maxWaitMs = 3000, int pollIntervalMs = 150)
1720 {
1721 var stopwatch = System.Diagnostics.Stopwatch.StartNew();
1722 sessionId ??= GetEffectiveSessionId();
1723
1724 while (stopwatch.ElapsedMilliseconds < maxWaitMs)
1725 {
1726 // Option A: Try CDP box model (most accurate)
1727 var box = await SendCommand<dynamic>(
1728 DevToolsMethods.DOMGetBoxModel,
1729 new { backendNodeId = element.ElementBackendNodeId },
1730 sessionId
1731 ).ConfigureAwait(false);
1732
1733 if (box?.model?.content != null)
1734 {
1735 var quad = box.model.content;
1736 if (quad.Count >= 8 && quad[2] > quad[0] && quad[7] > quad[1]) // width & height > 0
1737 {
1738 return true;
1739 }
1740 }
1741 else if (null == box) // no box model? maybe it got detached? try to find the new element?
1742 {
1743 List<GPALElement> elems = await EvaluateSelector(element.Css, sessionId).ConfigureAwait(false);
1744 if (0 < elems.Count)
1745 {
1746 element.ElementBackendNodeId = elems[0].ElementBackendNodeId;
1747 element.ElementHandle = elems[0].ElementHandle;
1748 element.ElementNodeId = elems[0].ElementNodeId;
1749 element.Css = elems[0].Css;
1750 element.Xpath = elems[0].Xpath;
1751 }
1752 }
1753
1754 // Option B: Fallback to JS dimensions check (works even if CDP box fails)
1755 var jsResult = await SendCommand<dynamic>(
1756 DevToolsMethods.RuntimeEvaluate,
1757 new
1758 {
1759 expression = $@"
1760 (function() {{
1761 const el = document.querySelector('{element.Css}') ||
1762 document.evaluate('{element.Xpath}', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
1763 if (!el) return false;
1764 const r = el.getBoundingClientRect();
1765 return r.width > 0.1 && r.height > 0.1 && r.top >= 0; // visible-ish
1766 }})()",
1767 contextId = CurrentContextId,
1768 returnByValue = true
1769 },
1770 sessionId
1771 ).ConfigureAwait(false);
1772
1773 if (jsResult?.result?.value is bool visible && visible)
1774 {
1775 return true;
1776 }
1777
1778 await Task.Delay(pollIntervalMs).ConfigureAwait(false);
1779 }
1780
1781 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
1782 $"Timeout waiting for layout on [{element.TagName}][{element.Css ?? element.Xpath}] after [{maxWaitMs}]ms",
1783 this, GPALObjectType.PuppeteerCommunicator);
1784
1785 return false;
1786 }
1787
1796 /// <param name="sessionId">The CDP session used to issue the creation commands; if null, the effective session ID is resolved automatically.</param>
1797 /// <returns>A task that resolves to the CDP target ID of the newly created tab.</returns>
1798 /// <example>
1799 /// <code>
1800 /// string targetId = await communicator.CreateTarget("https://example.com", newWindow: true);
1801 /// </code>
1802 /// </example>
1803 public async Task<string> CreateTarget(string url, bool newWindow = false, string sessionId = null)
1804 {
1805 sessionId ??= GetEffectiveSessionId();
1806 url = MagicHelper.GetFullUrl(url, Browser, out Browser._areRobotsAllowed);
1807
1808 if (false == Browser.AreRobotsAllowed && true == Browser.BrowserSettings.ObeyRobotsTxt)
1809 {
1810 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);
1811 url = "https://google.com";
1812 }
1813
1814 object parameters = new { url };
1815 if (newWindow)
1816 {
1817 var context = await SendCommand<object>(DevToolsMethods.TargetCreateBrowserContext, new { }, sessionId).ConfigureAwait(false);
1818 string browserContextId = context.browserContextId.ToString();
1819 parameters = new { url, browserContextId };
1820 }
1821
1822 var result = await SendCommand<object>(DevToolsMethods.TargetCreateTarget, parameters, null).ConfigureAwait(false); // null for browser-level
1823 string targetId = result.targetId.ToString();
1824 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId }, null).ConfigureAwait(false);
1825 var session = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new { targetId, flatten = true }, null).ConfigureAwait(false);
1826 string newSessionId = session.sessionId.ToString();
1827 await AddTabToQueue(targetId, newSessionId).ConfigureAwait(false); // Add to queue, set active
1828
1829 // Enable Page domain for new tab
1830 //await SendCommand<object>(DevToolsMethods.PageEnable, new { }, newSessionId);
1831
1832 return targetId;
1833 }
1839 internal async Task CloseOutputWebSocketAsync()
1840 {
1841 // Close the WebSocket connection with a normal closure status and an empty description
1842 try
1843 {
1844 if (null != _webSocket)
1845 await _webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Client closing", CancellationToken.None).ConfigureAwait(false);
1846 }
1847 catch
1848 {
1849 // we are terminating anyways...
1850 }
1851 }
1858 internal async Task<string> GetInitialTargetId(string sessionId)
1859 {
1860 dynamic targets = await SendCommand<dynamic>(DevToolsMethods.TargetGetTargets, new { }, null).ConfigureAwait(false);
1861 var pageTarget = ((IEnumerable<dynamic>)targets.targetInfos).FirstOrDefault(t => (string)t.type == "page"); // Cast to IEnumerable<dynamic> and use typed lambda
1862 return pageTarget != null ? (string)pageTarget.targetId : null;
1863 }
1870 internal async Task<int> TabCount()
1871 {
1872 dynamic targets = await SendCommand<dynamic>(DevToolsMethods.TargetGetTargets, new { }, null).ConfigureAwait(false);
1873 return ((IEnumerable<dynamic>)targets.targetInfos).Count(t => (string)t.type == "page");
1874 }
1881 /// <param name="sessionId">Unused; reserved for future use.</param>
1882 /// <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>
1883 /// <example>
1884 /// <code>
1885 /// string windowId = await communicator.GetTargetWindowIdByUrl("https://example.com/dashboard");
1886 /// </code>
1887 /// </example>
1888 public async Task<string> GetTargetWindowIdByUrl(string url, string sessionId = null)
1889 {
1890 if (string.IsNullOrWhiteSpace(url))
1891 {
1892 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "URL is empty or null in GetTargetWindowIdByUrl", this, GPALObjectType.PuppeteerCommunicator);
1893 return null;
1894 }
1895
1896 url = MagicHelper.GetFullUrl(url, Browser, out bool _); // Normalize URL (e.g., add "https://" if needed)
1897 var windowArray = _windowSessionsQueue.ToArray();
1898 foreach (var window in windowArray)
1899 {
1900 var tabArray = window.TabQueue.ToArray();
1901 foreach (var tab in tabArray)
1902 {
1903 var targetInfo = await SendCommand<object>(DevToolsMethods.TargetGetTargetInfo, new { targetId = tab.Key }, null).ConfigureAwait(false);
1904 string targetUrl = targetInfo?.targetInfo?.url?.ToString();
1905 if (targetUrl != null && targetUrl.Contains(url))
1906 {
1907 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Found window with browserContextId [{window.BrowserContextId}] for URL [{url}]", this, GPALObjectType.PuppeteerCommunicator);
1908 return window.BrowserContextId; // Return the window's BrowserContextId
1909 }
1910 }
1911 }
1912
1913 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No window found with a tab matching URL [{url}]", this, GPALObjectType.PuppeteerCommunicator);
1914 return null;
1915 }
1922 /// <param name="sessionId">The CDP session used to resolve the effective session if needed; if null, the effective session ID is resolved automatically.</param>
1923 /// <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>
1924 /// <example>
1925 /// <code>
1926 /// string tabId = await communicator.GetTargetTabIdByUrl("https://example.com/page2");
1927 /// </code>
1928 /// </example>
1929 public async Task<string> GetTargetTabIdByUrl(string url, string sessionId = null)
1930 {
1931 sessionId ??= GetEffectiveSessionId();
1932
1933 if (string.IsNullOrWhiteSpace(url))
1934 {
1935 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "URL is empty or null in GetTargetTabIdByUrl", this, GPALObjectType.PuppeteerCommunicator);
1936 return GetCurrentTargetId();
1937 }
1938
1939 url = MagicHelper.GetFullUrl(url, Browser, out bool _);
1940 var windowArray = _windowSessionsQueue.ToArray();
1941
1942 if (_activeWindowIndex >= 0 && _activeWindowIndex < windowArray.Length)
1943 {
1944 var tabArray = windowArray[_activeWindowIndex].TabQueue.ToArray();
1945 foreach (var tab in tabArray)
1946 {
1947 var targetInfo = await SendCommand<object>(DevToolsMethods.TargetGetTargetInfo, new { targetId = tab.Key }, null).ConfigureAwait(false);
1948 string targetUrl = targetInfo?.targetInfo?.url?.ToString();
1949 if (targetUrl != null)
1950 {
1951 string decodedTarget = Uri.UnescapeDataString(targetUrl);
1952 string decodedUrl = Uri.UnescapeDataString(url);
1953
1954 if (true == decodedTarget.Contains(decodedUrl))
1955 {
1956 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
1957 $"Found tab with targetId [{tab.Key}] for URL [{url}] in active window",
1958 this, GPALObjectType.PuppeteerCommunicator);
1959
1960 return tab.Key;
1961 }
1962 }
1963 }
1964 }
1965
1966 for (int i = 0; i < windowArray.Length; i++)
1967 {
1968 if (i == _activeWindowIndex) continue;
1969 var tabArray = windowArray[i].TabQueue.ToArray();
1970 foreach (var tab in tabArray)
1971 {
1972 var targetInfo = await SendCommand<object>(DevToolsMethods.TargetGetTargetInfo, new { targetId = tab.Key }, null).ConfigureAwait(false);
1973 string targetUrl = targetInfo?.targetInfo?.url?.ToString();
1974 if (targetUrl != null && targetUrl.Contains(url))
1975 {
1976 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Found tab with targetId [{tab.Key}] for URL [{url}] in window [{i}]", this, GPALObjectType.PuppeteerCommunicator);
1977 return tab.Key;
1978 }
1979 }
1980 }
1981
1982 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No tab found with URL [{url}]", this, GPALObjectType.PuppeteerCommunicator);
1983 return null;
1984 }
1992 internal async Task<string> GetNextWindowId(string id, string sessionId = null)
1993 {
1994 sessionId ??= GetEffectiveSessionId();
1995 var targets = await SendCommand<object>(DevToolsMethods.TargetGetTargets, new { }, null).ConfigureAwait(false);
1996 var windowList = ((IEnumerable<dynamic>)targets.targetInfos)
1997 .Where(t => t.type == "browser" || t.type == "page")
1998 .GroupBy(t => t.browserContextId ?? t.targetId)
1999 .Select(g => g.First())
2000 .OrderBy(t => t.targetId)
2001 .ToList();
2002 for (int i = 0; i < windowList.Count; i++)
2003 {
2004 if (windowList[i].targetId.ToString() == id)
2005 {
2006 return i < windowList.Count - 1 ? windowList[i + 1].targetId.ToString() : null;
2007 }
2008 }
2009 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No next window found for ID: [{id}]", this, GPALObjectType.PuppeteerCommunicator);
2010 return null;
2011 }
2019 internal async Task<string> GetPreviousWindowId(string id, string sessionId = null)
2020 {
2021 sessionId ??= GetEffectiveSessionId();
2022 var targets = await SendCommand<object>(DevToolsMethods.TargetGetTargets, new { }, null).ConfigureAwait(false);
2023 var windowList = ((IEnumerable<dynamic>)targets.targetInfos)
2024 .Where(t => t.type == "browser" || t.type == "page")
2025 .GroupBy(t => t.browserContextId ?? t.targetId)
2026 .Select(g => g.First())
2027 .OrderBy(t => t.targetId)
2028 .ToList();
2029 for (int i = 0; i < windowList.Count; i++)
2030 {
2031 if (windowList[i].targetId.ToString() == id)
2032 {
2033 return i > 0 ? windowList[i - 1].targetId.ToString() : null;
2034 }
2035 }
2036 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No previous window found for ID: [{id}]", this, GPALObjectType.PuppeteerCommunicator);
2037 return null;
2045 public async Task<int> QueryByCss(string selector, string sessionId = null)
2046 {
2047 sessionId ??= GetEffectiveSessionId();
2048 //await WaitForEvent("Page.loadEventFired", TimeSpan.FromSeconds(10));
2049
2050 var doc = await SendCommand<object>(DevToolsMethods.DOMGetDocument, new { depth = 0 }, sessionId).ConfigureAwait(false);
2051 var result = await SendCommand<object>(DevToolsMethods.DOMQuerySelector, new { nodeId = (int)doc.root.nodeId, selector }, sessionId).ConfigureAwait(false);
2052
2053 var nodeId = (int)result.nodeId;
2054
2055 if (nodeId == 0)
2056 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No nodes found for Css: [{selector}]", this, GPALObjectType.PuppeteerCommunicator);
2057
2058 return nodeId;
2059 }
2060 public class Node
2061 {
2065 public string ObjectId { get; set; }
2068
2069 public int BackendNodeId { get; set; }
2073 public int NodeId { get; set; }
2074 }
2075
2076 private readonly SemaphoreSlim _evaluateSelectorSemaphore = new SemaphoreSlim(1, 1);
2077
2078 /// <summary>
2079 /// Resolves an iframe element matching the given selector and switches the current evaluation context
2080 /// into that iframe by attaching to its frame target (for cross-origin frames) and creating an isolated
2081 /// JavaScript execution world. Subsequent DOM/JS operations will operate inside the iframe until
2082 /// the context is reset.
2083 /// </summary>
2084 /// <param name="frameSelector">A CSS or XPath selector identifying the iframe element.</param>
2085 public async Task SwitchToFrame(string frameSelector)
2086 {
2087 // Cleared on a context change: a remote object from the previous context resolves nothing and the
2088 // failure looks like a bad selector
2089 CurrentRootObjectId = null;
2090
2091
2092 var elements = await EvaluateSelector(frameSelector, isRecursive: true).ConfigureAwait(false);
2093 if (elements.Count == 0)
2094 {
2095 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Frame not found: " + frameSelector, this, GPALObjectType.PuppeteerCommunicator);
2096 return;
2097 }
2098
2099 var iframeElement = elements[0];
2100
2101 var describe = await SendCommand<dynamic>(DevToolsMethods.DOMDescribeNode, new
2102 {
2103 objectId = iframeElement.ElementHandle,
2104 pierce = true
2105 }, GetEffectiveSessionId()).ConfigureAwait(false);
2106
2107 string frameId = describe?.node?.frameId?.ToString();
2108
2109 if (string.IsNullOrEmpty(frameId))
2110 {
2111 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "No frameId for frame [" + frameSelector + "]", this, GPALObjectType.PuppeteerCommunicator);
2112 return;
2113 }
2114
2115 CurrentFrameId = frameId;
2116
2117 // drop any session left over from a previously resolved iframe - the attach below is allowed to fail
2118 // silently for same-origin frames, and without this a stale cross-origin session would be reused
2119 CurrentFrameSessionId = null;
2120
2121 // Try to attach to frame target (works for cross-origin OOPIFs)
2122 try
2123 {
2124 var attach = await SendCommand<dynamic>(DevToolsMethods.TargetAttachToTarget, new
2125 {
2126 targetId = frameId,
2127 flatten = true
2128 }, null).ConfigureAwait(false);
2129
2130 string frameSessionId = attach?.sessionId?.ToString();
2131 if (!string.IsNullOrEmpty(frameSessionId))
2132 CurrentFrameSessionId = frameSessionId;
2133 }
2134 catch
2135 {
2136 // Normal for same-origin iframes - ignore
2137 }
2138
2139 // Always create isolated world (works for both same-origin and cross-origin)
2140 await Task.Delay(300).ConfigureAwait(false);
2141
2142 var isolated = await SendCommand<dynamic>(DevToolsMethods.PageCreateIsolatedWorld, new
2143 {
2144 frameId = frameId,
2145 worldName = "gpal_isolated_" + Guid.NewGuid().ToString("N"),
2146 grantUniversalAccess = false
2147 // a cross-origin frame lives in its own target, so the world has to be created on that target's
2148 // session - the page session does not know the frame at all. same-origin falls back to the page.
2149 }, GetEffectiveSessionId()).ConfigureAwait(false);
2150
2151 CurrentContextId = isolated?.executionContextId?.ToObject<long?>();
2152
2153 if (CurrentContextId.HasValue)
2154 {
2155 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
2156 "Resolved iframe [" + frameSelector + "] | FrameId: " + frameId + " | ContextId: " + CurrentContextId.Value,
2157 this, GPALObjectType.PuppeteerCommunicator);
2158 }
2159 else
2160 {
2161 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Failed to create isolated world for " + frameSelector, this, GPALObjectType.PuppeteerCommunicator);
2162 }
2163 }
2164
2172 /// as the root they resolve against. CDP has no current element, so the scope is held here and only the
2173 /// element lookup goes over the wire.
2174 /// <br/><br/>
2175 /// Not a substitute for entering a frame. A frame needs its own execution context, which
2176 /// <see cref="SwitchToFrame"/> attaches to, and a cross-origin iframe is not reachable as a node.
2177 /// </summary>
2178 /// <param name="elementSelector">A CSS or XPath selector identifying the element to search inside.</param>
2179 public async Task SwitchToElement(string elementSelector)
2180 {
2181 var found = await EvaluateSelector(elementSelector, isRecursive: true).ConfigureAwait(false);
2182
2183 if (0 == found.Count)
2184 {
2185 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Element to search inside not found: " + elementSelector, this, GPALObjectType.PuppeteerCommunicator);
2186 return;
2187 }
2188
2189 // The element's remote object, which querySelector is called on. SwitchToShadowDom differs only in
2190 // taking one further hop to the shadowRoot
2191 CurrentRootObjectId = found[0].ElementHandle;
2192
2193 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
2194 "Searching inside [" + elementSelector + "] | ObjectId: " + CurrentRootObjectId,
2195 this, GPALObjectType.PuppeteerCommunicator);
2196 }
2197
2198 public async Task SwitchToShadowDom(string shadowHostSelector)
2199 {
2200 var hosts = await EvaluateSelector(shadowHostSelector, isRecursive: true).ConfigureAwait(false);
2201 if (hosts.Count == 0)
2202 {
2203 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Shadow host not found: " + shadowHostSelector, this, GPALObjectType.PuppeteerCommunicator);
2204 return;
2205 }
2206
2207 var host = hosts[0];
2208 var s = GetEffectiveSessionId();
2209
2210 var describe = await SendCommand<dynamic>(DevToolsMethods.DOMDescribeNode, new
2211 {
2212 objectId = host.ElementHandle,
2213 pierce = true,
2214 depth = 1
2215 }, s).ConfigureAwait(false);
2216
2217 var shadowRoots = describe?.node?.shadowRoots as IEnumerable<dynamic>;
2218
2219 if (shadowRoots?.Any() != true)
2220 {
2221 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "No shadow root on host: " + shadowHostSelector, this, GPALObjectType.PuppeteerCommunicator);
2222 return;
2223 }
2224
2225 var shadowRoot = shadowRoots.First();
2226
2227 if (shadowRoot.backendNodeId != null)
2228 {
2229 var resolve = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, new
2230 {
2231 backendNodeId = (long)shadowRoot.backendNodeId
2232 }, s).ConfigureAwait(false);
2233
2234 if (resolve?.@object?.objectId != null)
2235 {
2236 CurrentRootObjectId = (string)resolve.@object.objectId;
2237 }
2238 }
2239
2240 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
2241 "Resolved shadow DOM [" + shadowHostSelector + "] | ObjectId: " + CurrentRootObjectId,
2242 this, GPALObjectType.PuppeteerCommunicator);
2243 }
2244
2249 /// (via <see cref="SwitchToFrame"/>). If nothing is found and the call is not recursive, it performs
2250 /// a deep search by recursively evaluating the selector inside any iframe targets.
2251 /// </summary>
2252 /// <param name="selector">A CSS selector, XPath expression, numeric node id, or CDP remote object id.</param>
2253 /// <param name="sessionId">CDP session id; the effective session (or current session, if inside an isolated iframe context) is used if null.</param>
2254 /// <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>
2255 /// <returns>Task that resolves to the list of matching elements, or an empty list if none were found or an error occurred.</returns>
2256 public async Task<List<GPALElement>> EvaluateSelector(string selector, string sessionId = null, bool isRecursive = false)
2257 {
2258 var elements = new List<GPALElement>();
2259
2260 // an explicit session (a recursive deep search) wins, otherwise use the effective one: the isolated
2261 // world is created on whichever target owns the frame, so its context id is only meaningful on that
2262 // same session. cross-origin frames have their own, same-origin frames fall back to the page session.
2263 string effectiveSessionId = (!string.IsNullOrEmpty(sessionId) && sessionId != GetCurrentSessionId())
2264 ? sessionId
2266
2267 if (false == isRecursive)
2268 await _evaluateSelectorSemaphore.WaitAsync().ConfigureAwait(false);
2269
2270 try
2271 {
2272 var nodes = new List<Node>();
2273
2274 bool inRootContext = !string.IsNullOrEmpty(CurrentRootObjectId);
2275
2276 // When we have an isolated world (from ResolveIframe), use MAIN session for runtime commands
2277 // but keep effectiveSessionId for DOM commands when finding child iframes
2278 bool useIsolatedContext = CurrentContextId.HasValue;
2279
2280 if (inRootContext)
2281 {
2282 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
2283 "Evaluating inside the scope set by InShadowDom or InElement, for selector [" + selector + "]",
2284 this, GPALObjectType.PuppeteerCommunicator);
2285 }
2286
2287 try
2288 {
2289 // NodeId / ObjectId handling
2290 bool isNodeId = int.TryParse(selector, out int tmpNodeId);
2291 bool isObjectId = System.Text.RegularExpressions.Regex.IsMatch(selector, @"^-?\d+\.\d+\.\d+$");
2292
2293 // Enable pierce so shadow roots (and iframes) are visible in the DOM tree
2294 await SendCommand<object>(DevToolsMethods.DOMGetDocument, new
2295 {
2296 pierce = true,
2297 depth = 20
2298 }, effectiveSessionId).ConfigureAwait(false);
2299
2300 // === SCOPED QUERY (when .InShadowDom() or .InElement() is active) ===
2301 if (inRootContext && !isNodeId && !isObjectId)
2302 {
2303 try
2304 {
2305 var shadowQueryResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeCallFunctionOn, new
2306 {
2307 objectId = CurrentRootObjectId,
2308 functionDeclaration = @"
2309 function(selector) {
2310 let nodes = [];
2311 try {
2312 const cssNodes = this.querySelectorAll(selector);
2313 if (cssNodes?.length > 0) {
2314 nodes = Array.from(cssNodes);
2315 }
2316 } catch (e) {}
2317
2318 if (nodes.length === 0) {
2319 try {
2320 const result = document.evaluate(selector, this, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
2321 for (let i = 0; i < result.snapshotLength; i++) {
2322 const node = result.snapshotItem(i);
2323 if (node?.getBoundingClientRect) {
2324 node.getBoundingClientRect();
2325 nodes.push(node);
2326 }
2327 }
2328 } catch (e) {}
2329 }
2330 return { nodes: nodes };
2331 }",
2332 arguments = new[] { new { value = selector } },
2333 contextId = CurrentContextId,
2334 returnByValue = false
2335 }, effectiveSessionId).ConfigureAwait(false);
2336
2337 if (shadowQueryResult?.result?.objectId != null)
2338 {
2339 // Get wrapper { nodes: [...] }
2340 var wrapper = await SendCommand<dynamic>(DevToolsMethods.RuntimeGetProperties,
2341 new {
2342 objectId = (string)shadowQueryResult.result.objectId,
2343 contextId = CurrentContextId,
2344 ownProperties = true
2345 }, effectiveSessionId).ConfigureAwait(false);
2346
2347 string nodesArrayId = null;
2348
2349 // Find "nodes" property without lambda
2350 if (wrapper?.result != null)
2351 {
2352 foreach (var p in wrapper.result)
2353 {
2354 if (p.name == "nodes" && p.value?.objectId != null)
2355 {
2356 nodesArrayId = (string)p.value.objectId;
2357 break;
2358 }
2359 }
2360 }
2361
2362 if (!string.IsNullOrEmpty(nodesArrayId))
2363 {
2364 // Get array items
2365 var arrayProps = await SendCommand<dynamic>(DevToolsMethods.RuntimeGetProperties,
2366 new {
2367 objectId = nodesArrayId,
2368 contextId = CurrentContextId,
2369 ownProperties = true
2370 }, effectiveSessionId).ConfigureAwait(false);
2371
2372 nodes.Clear();
2373
2374 if (arrayProps?.result != null)
2375 {
2376 foreach (var item in arrayProps.result)
2377 {
2378 var val = item.value;
2379 if (val != null
2380 && val.type == "object"
2381 && val.subtype == "node"
2382 && val.objectId != null)
2383 {
2384 nodes.Add(new Node { ObjectId = (string)val.objectId });
2385 }
2386 }
2387 }
2388 }
2389
2390 if (nodes.Count > 0)
2391 goto ProcessFoundNodes;
2392 }
2393 }
2394 catch (Exception ex)
2395 {
2396 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
2397 $"Shadow DOM query failed for [{selector}]. Falling back to main path.",
2398 this, GPALObjectType.PuppeteerCommunicator, ex);
2399 }
2400 }
2401
2402 if (isNodeId || isObjectId)
2403 {
2404 var requestNodeResult = await SendCommand<object>(DevToolsMethods.DOMRequestNode, new { objectId = selector }, effectiveSessionId).ConfigureAwait(false);
2405 if (requestNodeResult?.nodeId != null)
2406 {
2407 var nodeId = (int)requestNodeResult.nodeId;
2408 var resolveNodeResult = await SendCommand<object>(DevToolsMethods.DOMResolveNode, new { nodeId }, effectiveSessionId).ConfigureAwait(false);
2409 if (resolveNodeResult?.@object?.objectId != null)
2410 {
2411 var describeNode = await SendCommand<object>(DevToolsMethods.DOMDescribeNode, new { objectId = (string)resolveNodeResult.@object.objectId, pierce = true }, effectiveSessionId).ConfigureAwait(false);
2412 nodes.Add(new Node
2413 {
2414 ObjectId = (string)resolveNodeResult.@object.objectId,
2415 BackendNodeId = describeNode?.node?.backendNodeId ?? 0,
2416 NodeId = nodeId
2417 });
2418 }
2419 }
2420 }
2421 else
2422 {
2423 // === YOUR ORIGINAL JS EVALUATION PATH (100% unchanged) ===
2424 string jsFunc = @"
2425 function evaluateSelector(selector) {
2426 let nodes = [];
2427 let type = 'unknown';
2428 let error = null;
2429
2430 // Try CSS first
2431 try {
2432 const cssNodes = document.querySelectorAll(selector);
2433 if (cssNodes.length > 0) {
2434 nodes = Array.from(cssNodes);
2435 type = 'css';
2436 }
2437 } catch (e) {}
2438
2439 // If CSS failed, try XPath
2440 if (nodes.length === 0) {
2441 try {
2442 const result = document.evaluate(
2443 selector,
2444 document,
2445 null,
2446 XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
2447 null
2448 );
2449
2450 // Force CDP to wrap each node by accessing a layout property
2451 for (let i = 0; i < result.snapshotLength; i++) {
2452 const node = result.snapshotItem(i);
2453 if (node && node.getBoundingClientRect) {
2454 // This forces CDP to create a RemoteObject with objectId
2455 node.getBoundingClientRect();
2456 nodes.push(node);
2457 }
2458 }
2459
2460 if (nodes.length > 0) {
2461 type = 'xpath';
2462 } else {
2463 error = 'No elements found for selector [' + selector + ']';
2464 }
2465 } catch (e) {
2466 error = 'Invalid XPath or error: ' + e.message;
2467 }
2468 }
2469
2470 return { nodes: nodes };
2471 }";
2472
2473 string escapedSelector = selector;
2474 var evalResult = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
2475 {
2476 expression = $"({jsFunc})(\"{escapedSelector}\")",
2477 contextId = CurrentContextId,
2478 returnByValue = false
2479 }, effectiveSessionId).ConfigureAwait(false);
2480
2481 if (evalResult?.result?.objectId != null)
2482 {
2483 var nodesProps = await SendCommand<object>(DevToolsMethods.RuntimeGetProperties,
2484 new {
2485 objectId = (string)evalResult.result.objectId,
2486 contextId = CurrentContextId,
2487 ownProperties = true
2488 }, effectiveSessionId).ConfigureAwait(false);
2489 dynamic errorProp = null;
2490
2491 if (nodesProps?.result != null)
2492 {
2493 foreach (var p in nodesProps.result)
2494 {
2495 if (p.name == "error" && p.value != null && p.value.value != null)
2496 {
2497 errorProp = p;
2498 break;
2499 }
2500 }
2501 }
2502
2503 if (errorProp != null)
2504 {
2505 string errMsg = (string)errorProp.value.value ?? "Unknown selector error";
2506 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{errMsg}]", this, GPALObjectType.PuppeteerCommunicator);
2507 if (false == isRecursive)
2508 return new List<GPALElement>();
2509 }
2510
2511 foreach (var prop in nodesProps.result)
2512 {
2513 if (prop.name == "nodes" && prop.value?.objectId != null)
2514 {
2515 var nodeArray = await SendCommand<object>(DevToolsMethods.RuntimeGetProperties,
2516 new {
2517 objectId = (string)prop.value.objectId,
2518 contextId = CurrentContextId,
2519 ownProperties = true
2520 }, effectiveSessionId).ConfigureAwait(false);
2521 foreach (var np in nodeArray.result)
2522 {
2523 if (np.value != null && np.value.type == "object" && np.value.subtype == "node")
2524 nodes.Add(new Node { ObjectId = (string)np.value.objectId });
2525 }
2526 }
2527 }
2528 }
2529 }
2530
2531 ProcessFoundNodes:
2532
2533 // Sequential per-node extraction (your original logic - completely untouched)
2534 foreach (var node in nodes)
2535 {
2536 try
2537 {
2538 var attributes = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
2539
2540 var describeNode = await SendCommand<object>(DevToolsMethods.DOMDescribeNode, new { objectId = node.ObjectId, pierce = true }, effectiveSessionId).ConfigureAwait(false);
2541 if (describeNode?.node != null)
2542 {
2543 node.BackendNodeId = describeNode.node.backendNodeId ?? 0;
2544 var requestNodeResult = await SendCommand<object>(DevToolsMethods.DOMRequestNode, new
2545 {
2546 objectId = node.ObjectId
2547 }, effectiveSessionId).ConfigureAwait(false);
2548
2549 node.NodeId = requestNodeResult.nodeId;
2550 attributes["tagName"] = describeNode.node.nodeName;
2551 }
2552
2553 var jsProps = await SendCommand<object>(DevToolsMethods.RuntimeCallFunctionOn, new
2554 {
2555 functionDeclaration = @"
2556 function() {
2557 const rect = this.getBoundingClientRect();
2558 const cleanText = (str) => (str || '').replace(/\s+/g, ' ').trim();
2559
2560 const attrs = {
2561 enabled: !this.disabled,
2562 // a checkbox says checked and an option says selected, and Selected is
2563 // one question over both of them, the way it is on selenium
2564 selected: this.checked === true || this.selected === true,
2565 indeterminate: this.indeterminate === true,
2566 displayed: this.offsetParent !== null,
2567 href: this.href || '',
2568 src: this.src || '',
2569
2570 value: cleanText(this.value),
2571 text: cleanText(this.text || this.innerText),
2572 placeholder: cleanText(this.placeholder),
2573
2574 index: this.index || '',
2575 length: this.files ? this.files.length : this.length || '',
2576 type: this.type || '',
2577 id: this.id || '',
2578 };
2579 for (let {name,value} of Array.from(this.attributes||[])) attrs[name]=value;
2580
2581 // Force boolean properties last - multiple not getting set above, being dropped by cdp?
2582 attrs.multiple = this.multiple === true;
2583
2584 function getCss(el){
2585 if(!(el instanceof Element)) return '';
2586 const parts=[];
2587 while(el && el.nodeType===1){
2588 let part=el.nodeName.toLowerCase();
2589 if(el.id){part+='#'+el.id; parts.unshift(part); break;}
2590 let sib=el, nth=1;
2591 while((sib=sib.previousElementSibling)!=null){if(sib.nodeName.toLowerCase()===part)nth++;}
2592 if(nth>1) part+=':nth-of-type('+nth+')';
2593 parts.unshift(part); el=el.parentElement;
2594 }
2595 return parts.join(' > ');
2596 }
2597 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}};
2598 }",
2599 objectId = node.ObjectId,
2600 contextId = CurrentContextId,
2601 returnByValue = true
2602 }, effectiveSessionId).ConfigureAwait(false);
2603
2604 if (jsProps?.result?.value != null)
2605 {
2606 var dict = ((Newtonsoft.Json.Linq.JObject)jsProps.result.value).ToObject<Dictionary<string, object>>();
2607
2608 if (dict.ContainsKey("attributes"))
2609 {
2610 var jsAttrDict = ((Newtonsoft.Json.Linq.JObject)dict["attributes"]).ToObject<Dictionary<string, object>>();
2611 foreach (var kvp in jsAttrDict) if (!attributes.ContainsKey(kvp.Key)) attributes[kvp.Key] = kvp.Value;
2612 }
2613
2614 if (dict.ContainsKey("boundingRect"))
2615 {
2616 var brObj = ((Newtonsoft.Json.Linq.JObject)dict["boundingRect"]).ToObject<Dictionary<string, object>>();
2617 float ToFloat(object o)
2618 {
2619 if (o is double d) return (float)d;
2620 if (o is long l) return (float)l;
2621 if (o is int i) return (float)i;
2622 if (o is string s && float.TryParse(s, out var r)) return r;
2623 return 0f;
2624 }
2625
2626 var rect = new ClientRectangle
2627 {
2628 X = ToFloat(brObj.ContainsKey("x") ? brObj["x"] : 0),
2629 Y = ToFloat(brObj.ContainsKey("y") ? brObj["y"] : 0),
2630 Width = ToFloat(brObj.ContainsKey("width") ? brObj["width"] : 0),
2631 Height = ToFloat(brObj.ContainsKey("height") ? brObj["height"] : 0),
2632 Top = ToFloat(brObj.ContainsKey("top") ? brObj["top"] : 0),
2633 Left = ToFloat(brObj.ContainsKey("left") ? brObj["left"] : 0),
2634 Bottom = ToFloat(brObj.ContainsKey("bottom") ? brObj["bottom"] : 0),
2635 Right = ToFloat(brObj.ContainsKey("right") ? brObj["right"] : 0)
2636 };
2637
2638 attributes["boundingRect"] = rect;
2639 attributes["location"] = new System.Drawing.Point((int)rect.X, (int)rect.Y);
2640 attributes["size"] = new System.Drawing.Size((int)rect.Width, (int)rect.Height);
2641 }
2642
2643 if (dict.ContainsKey("css")) attributes["css"] = dict["css"]?.ToString();
2644 }
2645
2646 attributes["xpath"] = await GetXPathForElement(node.BackendNodeId).ConfigureAwait(false);
2647
2648 elements.Add(new GPALElement(attributes, Browser)
2649 {
2650 ElementHandle = node.ObjectId,
2651 ElementBackendNodeId = node.BackendNodeId,
2652 ElementNodeId = node.NodeId
2653 });
2654 }
2655 catch
2656 {
2657 // Skip failed nodes
2658 }
2659 }
2660
2661 // Recurse into iframes if still nothing found (questionable if this every found anything - fix the selector)
2662 //if (!elements.Any() && false == isRecursive)
2663 //{
2664 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Element STILL not found, performing deep search in iframes.", this, GPALObjectType.PuppeteerCommunicator);
2665
2666 // var targets = await SendCommand<object>(DevToolsMethods.TargetGetTargets, new { }, null).ConfigureAwait(false);
2667 // var iframeTargets = ((IEnumerable<dynamic>)targets.targetInfos).Where(t => t.type == "iframe");
2668 // foreach (var target in iframeTargets)
2669 // {
2670 // var frameSession = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new { targetId = (string)target.targetId, flatten = true }, null).ConfigureAwait(false);
2671 // var frameSessionId = (string)frameSession.sessionId;
2672
2673 // var frameElements = await EvaluateSelector(selector, frameSessionId, true).ConfigureAwait(false);
2674 // if (frameElements.Any())
2675 // return frameElements;
2676 // }
2677 //}
2678
2679 return elements;
2680 }
2681 catch
2682 {
2683 return new List<GPALElement>();
2684 }
2685 }
2686 finally
2687 {
2688 if (false == isRecursive)
2689 _evaluateSelectorSemaphore.Release();
2690 }
2691 }
2692
2693 // generate selectors from backendnodeid
2694 /// <summary>
2695 /// Generates a CSS selector string that uniquely identifies the element with the given backend node id.
2696 /// </summary>
2697 /// <param name="backendNodeId">The CDP backend node id of the element.</param>
2698 /// <param name="optimized">Whether to prefer a shorter, optimized selector (e.g. using the element's id) when possible.</param>
2699 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
2700 /// <returns>Task that resolves to the generated CSS selector, or an empty string if it could not be determined.</returns>
2701 public async Task<string> GetCssForElement(int backendNodeId, bool optimized = false, string sessionId = null)
2702 {
2703 sessionId ??= GetEffectiveSessionId();
2704 string jsFunc = @"
2705 function getCssSelector(node) {
2706// console.log('getCssSelector called with node:', node);
2707
2708 if (!node || node.nodeType !== Node.ELEMENT_NODE) {
2709 console.error('Invalid node, returning empty string');
2710 return '';
2711 }
2712
2713 if (node.id) {
2714// console.log('Found ID:', node.id);
2715 return '#' + node.id.replace(/([ #.;+*~'!^$[\‍]()=>|\/@])/g, '\\$1');
2716 }
2717
2718 let selector = node.localName.toLowerCase();
2719// console.log('Tag name:', selector);
2720
2721 if (node.classList && node.classList.length > 0) {
2722 const classes = Array.from(node.classList)
2723 .filter(c => c && /^[a-zA-Z][\w-]*$/.test(c))
2724 .map(c => c.replace(/([ #.;+*~'!^$[\‍]()=>|\/@])/g, '\\$1'));
2725 if (classes.length > 0) {
2726 selector += '.' + classes.join('.');
2727 }
2728 // console.log('Selector with classes:', selector);
2729 }
2730
2731 try {
2732 const matches = document.querySelectorAll(selector);
2733 // console.log('Matches for selector:', matches.length, selector);
2734 if (matches.length === 1 && matches[0] === node) {
2735 return selector;
2736 }
2737 } catch (e) {
2738 // console.error('Error in querySelectorAll:', e.message);
2739 return '';
2740 }
2741
2742 // console.log('Returning fallback selector:', selector);
2743 return selector;
2744 }";
2745 return await GetXPathOrCssForElement(backendNodeId, jsFunc, optimized, sessionId).ConfigureAwait(false);
2746 }
2747 /// <summary>
2748 /// Generates an XPath expression that identifies the element with the given backend node id.
2749 /// </summary>
2750 /// <param name="backendNodeId">The CDP backend node id of the element.</param>
2751 /// <param name="optimized">Whether to prefer a shorter, optimized path (e.g. using the element's id) when possible.</param>
2752 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
2753 /// <returns>Task that resolves to the generated XPath expression, or an empty string if it could not be determined.</returns>
2754 public async Task<string> GetXPathForElement(int backendNodeId, bool optimized = true, string sessionId = null)
2755 {
2756 sessionId ??= GetEffectiveSessionId();
2757 // Your full JS template as a string
2758 string jsFunc = @"
2759 function getXPathForElement(node, optimized) {
2760 let Elements = { DOMPath: {} };
2761
2762 Elements.DOMPath.xPath = function(node, optimized) {
2763 if (node.nodeType === Node.DOCUMENT_NODE) return '/';
2764 const steps = [];
2765 let contextNode = node;
2766 while (contextNode) {
2767 const step = Elements.DOMPath._xPathValue(contextNode, optimized);
2768 if (!step) break;
2769 steps.push(step);
2770 if (step.optimized) break;
2771 contextNode = contextNode.parentNode;
2772 }
2773 steps.reverse();
2774 return (steps.length && steps[0].optimized ? '' : '/') + steps.map(step => step.value).join('/');
2775 };
2776
2777 Elements.DOMPath._xPathValue = function(node, optimized) {
2778 let ownValue;
2779 const ownIndex = Elements.DOMPath._xPathIndex(node);
2780 if (ownIndex === -1) return null;
2781 switch (node.nodeType) {
2782 case Node.ELEMENT_NODE:
2783 if (optimized && node.getAttribute('id'))
2784 return new Elements.DOMPath.Step('//*[@id=\'' + node.getAttribute('id') + '\']', true);
2785 ownValue = node.localName;
2786 break;
2787 case Node.ATTRIBUTE_NODE:
2788 ownValue = '@' + node.nodeName;
2789 break;
2790 case Node.TEXT_NODE:
2791 case Node.CDATA_SECTION_NODE:
2792 ownValue = 'text()';
2793 break;
2794 case Node.PROCESSING_INSTRUCTION_NODE:
2795 ownValue = 'processing-instruction()';
2796 break;
2797 case Node.COMMENT_NODE:
2798 ownValue = 'comment()';
2799 break;
2800 case Node.DOCUMENT_NODE:
2801 ownValue = '';
2802 break;
2803 default:
2804 ownValue = '';
2805 break;
2806 }
2807 if (ownIndex > 0)
2808 ownValue += '[' + ownIndex + ']';
2809 return new Elements.DOMPath.Step(ownValue, node.nodeType === Node.DOCUMENT_NODE);
2810 };
2811
2812 Elements.DOMPath._xPathIndex = function(node) {
2813 function areNodesSimilar(left, right) {
2814 if (left === right) return true;
2815 if (left.nodeType === Node.ELEMENT_NODE && right.nodeType === Node.ELEMENT_NODE)
2816 return left.localName === right.localName;
2817 if (left.nodeType === right.nodeType) return true;
2818 const leftType = left.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : left.nodeType;
2819 const rightType = right.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : right.nodeType;
2820 return leftType === rightType;
2821 }
2822 const siblings = node.parentNode ? node.parentNode.children : null;
2823 if (!siblings) return 0;
2824 let hasSameNamedElements;
2825 for (let i = 0; i < siblings.length; ++i) {
2826 if (areNodesSimilar(node, siblings[i]) && siblings[i] !== node) {
2827 hasSameNamedElements = true;
2828 break;
2829 }
2830 }
2831 if (!hasSameNamedElements) return 0;
2832 let ownIndex = 1;
2833 for (let i = 0; i < siblings.length; ++i) {
2834 if (areNodesSimilar(node, siblings[i])) {
2835 if (siblings[i] === node) return ownIndex;
2836 ++ownIndex;
2837 }
2838 }
2839 return -1;
2840 };
2841
2842 Elements.DOMPath.Step = function(value, optimized) {
2843 this.value = value;
2844 this.optimized = optimized || false;
2845 };
2846
2847 return Elements.DOMPath.xPath(node, optimized);
2848 }";
2849 return await GetXPathOrCssForElement(backendNodeId, jsFunc, optimized, sessionId);
2850 }
2854 /// function on it to compute a selector or path string.
2855 /// </summary>
2856 /// <param name="backendNodeId">The CDP backend node id of the element to resolve.</param>
2857 /// <param name="jsFunc">A JavaScript function declaration that takes (node, optimized) and returns the selector/path string.</param>
2858 /// <param name="optimized">Whether the JavaScript function should prefer a shorter, optimized result.</param>
2859 /// <param name="sessionId">CDP session id to use for the resolve and evaluate commands.</param>
2860 /// <returns>Task that resolves to the computed selector/path string, or an empty string on failure.</returns>
2861 public async Task<string> GetXPathOrCssForElement(int backendNodeId, string jsFunc, bool optimized, string sessionId)
2862 {
2863 try
2864 {
2865 // Resolve to objectId using backendNodeId
2866 var resolve = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, new { backendNodeId, pierce = true }, sessionId).ConfigureAwait(false);
2867 string objectId = resolve.@object.objectId;
2868
2869 // Call function
2870 var result = await SendCommand<dynamic>(DevToolsMethods.RuntimeCallFunctionOn, new
2871 {
2872 objectId,
2873 functionDeclaration = $"function(optimized) {{ return ({jsFunc})(this, optimized); }}",
2874 arguments = new object[] { new { value = optimized } },
2875 contextId = CurrentContextId,
2876 returnByValue = true
2877 }, sessionId).ConfigureAwait(false);
2878
2879 // Release objectId to prevent leak
2880 //await SendCommand<object>(DevToolsMethods.RuntimeReleaseObject, new { objectId }, sessionId);
2881
2882 return result?.result?.value?.ToString() ?? "";
2883 }
2884 catch (Exception ex)
2885 {
2886 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
2887 $"Failed to get XPath for backendNodeId [{backendNodeId}]",
2888 this, GPALObjectType.PuppeteerCommunicator, ex);
2889 return "";
2890 }
2891 }
2892
2893 // Helper method to get attributes
2894 static Dictionary<string, string> GetAttributesAsDictionary(string jsonString)
2895 {
2896 try
2897 {
2898 using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(jsonString);
2899
2900 // Navigate to the 'node' object
2901 if (!doc.RootElement.TryGetProperty("node", out System.Text.Json.JsonElement nodeElement))
2902 {
2903 return null;
2904 }
2905
2906 // Navigate to the 'attributes' array
2907 if (!nodeElement.TryGetProperty("attributes", out System.Text.Json.JsonElement attributesElement) ||
2908 attributesElement.ValueKind != System.Text.Json.JsonValueKind.Array)
2909 {
2910 return null;
2911 }
2912
2913 var dict = new Dictionary<string, string>();
2914 for (int i = 0; i < attributesElement.GetArrayLength() - 1; i += 2)
2915 {
2916 string key = attributesElement[i].GetString();
2917 string value = attributesElement[i + 1].GetString();
2918 dict[key] = value;
2919 }
2920
2921 return dict;
2922 }
2923 catch (System.Text.Json.JsonException)
2924 {
2925 return null; // Invalid JSON
2927 }
2933 public async Task LeftClickByCss(string selector, string sessionId = null)
2934 {
2935 List<GPALElement> elems = await EvaluateSelector(selector, sessionId).ConfigureAwait(false);
2936 List<dynamic> responses = new List<dynamic>();
2937
2938 foreach (GPALElement elem in elems)
2939 await ClickElement(elem, sessionId, responses, ClickType.LeftClick).ConfigureAwait(false);
2940 }
2948 public async Task<dynamic> ExecuteJavaScript(string expression, string sessionId = null)
2949 {
2950 sessionId ??= GetEffectiveSessionId();
2951 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
2952 new {
2953 expression,
2954 contextId = CurrentContextId,
2955 returnByValue = true,
2956 awaitPromise = true
2957 }
2958 , sessionId).ConfigureAwait(false);
2959 }
2960
2972 internal string LastNavigationError { get; set; }
2973
2988 /// Starts or stops recording what the browser asks for, reported over the DevTools protocol.
2989 /// Every session GPAL knows about is told, not only the active tab, because a click that opens a tab
2990 /// is exactly the request worth seeing.
2991 /// <example>await browser.PuppeteerCommunicator.CaptureCalls(true);</example>
2992 /// </summary>
2993 /// <param name="capture">True to record, false to stop</param>
2994 /// <param name="sessionId">The session to tell, or null for every session known</param>
2995 public async Task CaptureCalls(bool capture, string sessionId = null)
2996 {
2997 CapturingCalls = capture;
2998
2999 // nothing is reported until the Network domain is on, and it is not on at startup. it is on per
3000 // session rather than per browser, so every tab GPAL knows about is told, not just the active one:
3001 // a click that opens a tab is exactly the request worth seeing. sending it twice to one session is
3002 // harmless, so nothing has to work out which of these it already told
3003 if (true == capture)
3004 foreach (string session in KnownSessions(sessionId))
3005 await SendCommand<object>(DevToolsMethods.NetworkEnable, new { }, session).ConfigureAwait(false);
3006 }
3007
3008 // the session handed in, the effective one, and every tab of every window, without repeats
3009 private List<string> KnownSessions(string sessionId)
3010 {
3011 List<string> retVal = new List<string>();
3012
3013 foreach (string session in new[] { sessionId, GetEffectiveSessionId() })
3014 if (false == string.IsNullOrEmpty(session) && false == retVal.Contains(session))
3015 retVal.Add(session);
3016
3017 foreach (WindowSession window in _windowSessionsQueue.ToArray())
3018 foreach (KeyValuePair<string, string> tab in window.TabQueue.ToArray())
3019 if (false == string.IsNullOrEmpty(tab.Value) && false == retVal.Contains(tab.Value))
3020 retVal.Add(tab.Value);
3021
3022 return retVal;
3023 }
3024
3025 // the status the site answered this request with, matched back to it by the browser's own request id
3026 private void RecordStatus(dynamic paramsData)
3027 {
3028 try
3029 {
3030 string requestId = paramsData?.requestId?.ToString();
3031
3032 if (false == string.IsNullOrEmpty(requestId) && true == _capturedById.TryGetValue(requestId, out GPALCall call))
3033 call.Status = (int)(paramsData?.response?.status ?? 0);
3034 }
3035 catch (Exception ex)
3036 {
3037 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Could not record a response status", this, GPALObjectType.PuppeteerCommunicator, ex);
3038 }
3039 }
3040
3041 // one line of the network tab, kept as the fields a workflow would act on rather than the whole event
3042 private void RecordCall(dynamic paramsData)
3043 {
3044 try
3045 {
3046 GPALCall call = new GPALCall
3047 {
3048 Url = paramsData?.request?.url?.ToString(),
3049 Method = paramsData?.request?.method?.ToString(),
3050 ResourceType = paramsData?.type?.ToString(),
3051 PostData = paramsData?.request?.postData?.ToString(),
3052 Initiator = paramsData?.initiator?.type?.ToString()
3053 };
3054
3055 // what the page's own code put on the request, which is the half a hand-built call is missing
3056 if (paramsData?.request?.headers is Newtonsoft.Json.Linq.JObject headers)
3057 foreach (KeyValuePair<string, Newtonsoft.Json.Linq.JToken> header in headers)
3058 call.Headers[header.Key] = header.Value?.ToString();
3059
3060 // a filter narrows what is kept rather than what is asked for, so nothing is missed by a
3061 // declaration made after the fact, only unrecorded
3062 string filter = Browser?.BrowserSettings?.CallFilter;
3063
3064 if (false == string.IsNullOrEmpty(filter) && true != call.Url?.Contains(filter))
3065 return;
3066
3067 call.RequestId = paramsData?.requestId?.ToString();
3068
3069 if (false == string.IsNullOrEmpty(call.RequestId))
3070 _capturedById[call.RequestId] = call;
3071
3072 CapturedCalls.Add(call);
3073 }
3074 catch (Exception ex)
3075 {
3076 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Could not record a request", this, GPALObjectType.PuppeteerCommunicator, ex);
3077 }
3078 }
3079
3080 internal async Task SetExtraHttpHeaders(Dictionary<string, object> headers, string sessionId = null)
3081 {
3082 sessionId ??= GetEffectiveSessionId();
3083
3084 // the Network domain has to be on before it will accept this, and it is not enabled at startup. Without
3085 // it the command is accepted and quietly does nothing, which reads as the credentials being wrong
3086 await SendCommand<object>(DevToolsMethods.NetworkEnable, new { }, sessionId).ConfigureAwait(false);
3087 await SendCommand<object>(DevToolsMethods.NetworkSetExtraHTTPHeaders, new { headers }, sessionId).ConfigureAwait(false);
3088 }
3089
3090 public async Task<bool> NavigateToUrl(string url, string sessionId)
3091 {
3092 _attachedFilesPerSession.Clear();
3093 var result = await SendCommand<object>(DevToolsMethods.PageNavigate, new { url }, sessionId).ConfigureAwait(false);
3094
3095 // a navigation that fails still answers, and answers with a perfectly good result carrying errorText,
3096 // so the presence of a result says only that the command was accepted, never that a page loaded
3097 LastNavigationError = (result as Newtonsoft.Json.Linq.JObject)?["errorText"]?.ToString();
3098
3099 if (null == result)
3100 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to Go to [{url}]", this, GPALObjectType.PuppeteerCommunicator);
3101 else if (false == string.IsNullOrEmpty(LastNavigationError))
3102 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to Go to [{url}][{LastNavigationError}]", this, GPALObjectType.PuppeteerCommunicator);
3103 else
3104 //await WaitForEvent("Page.loadEventFired", TimeSpan.FromSeconds(30));
3105 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Go to [{url}]", this, GPALObjectType.PuppeteerCommunicator);
3106
3107 return null != result && true == string.IsNullOrEmpty(LastNavigationError);
3108 }
3109
3110 // Implementation for "back"
3114 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
3115 /// <returns>Task that resolves to the navigation result, or null if there is no previous history entry.</returns>
3116 /// <example>
3117 /// <code>
3118 /// await communicator.Back();
3119 /// </code>
3120 /// </example>
3121 public async Task<dynamic> Back(string sessionId = null)
3122 {
3123 sessionId ??= GetEffectiveSessionId();
3124 dynamic history = await SendCommand<object>(DevToolsMethods.PageGetNavigationHistory, new { }, sessionId).ConfigureAwait(false);
3125 if (history?.currentIndex != null)
3126 {
3127 var entries = (IEnumerable<dynamic>)history.entries;
3128 var nextEntry = entries.ElementAtOrDefault((int)history.currentIndex - 1);
3129 if (nextEntry?.id != null)
3130 {
3131 var result = await SendCommand(DevToolsMethods.PageNavigateToHistoryEntry, new { entryId = (int)nextEntry.id }, sessionId).ConfigureAwait(false);
3132 return result;
3133 }
3134 }
3135 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No previous page in history", this, GPALObjectType.PuppeteerClient);
3136 return null;
3137 }
3139 // Implementation for "capture-visible-tab"
3143
3145 public async Task<dynamic> CaptureVisibleTab(string sessionId = null)
3146 {
3147 sessionId ??= GetEffectiveSessionId();
3148 return await SendCommand<object>(DevToolsMethods.PageCaptureScreenshot, new { format = "png" }, sessionId).ConfigureAwait(false);
3149 }
3150
3151 // Implementation for CastDesktop
3156 /// <param name="sinkName">The display name of the Cast sink/device to mirror to.</param>
3157 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
3158 /// <example>
3159 /// <code>
3160 /// await communicator.CastDesktop("Living Room TV", sessionId);
3161 /// </code>
3162 /// </example>
3163 public async Task CastDesktop(string sinkName, string sessionId)
3164 {
3165 CastDevice sink = null;
3166 int retries = 10;
3167 sessionId ??= GetEffectiveSessionId();
3168
3169 await SendCommand<object>(DevToolsMethods.CastDisable, null, sessionId).ConfigureAwait(false);
3170
3171 while (null == sink && 0 < retries--)
3172 {
3173 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Searching for [{sinkName}].", this, GPALObjectType.PuppeteerCommunicator);
3174 await SendCommand<object>(DevToolsMethods.CastEnable, new { }, sessionId).ConfigureAwait(false);
3175
3176 await Task.Delay(3_000).ConfigureAwait(false);
3177
3178 sink = CastDevices.Find(device => device.name == sinkName);
3179 }
3180
3181 await Task.Delay(3_000).ConfigureAwait(false);
3182
3183 if (null != sink)
3184 {
3185 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{sinkName}] found.", this, GPALObjectType.PuppeteerCommunicator);
3186 SinkName = sinkName;
3187 object param = new { sinkName = sink.name };
3188
3189 // cast or javascript, not both
3190 await SendCommand<object>(DevToolsMethods.CastStartDesktopMirroring, param, sessionId).ConfigureAwait(false);
3191 }
3192 else
3193 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{sinkName}] NOT found.", this, GPALObjectType.PuppeteerCommunicator);
3194 }
3195
3196 // Implementation for CastTab
3201 /// <param name="sinkName">The display name of the Cast sink/device to mirror to.</param>
3202 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
3203 /// <example>
3204 /// <code>
3205 /// await communicator.CastTab("Living Room TV", sessionId);
3206 /// </code>
3207 /// </example>
3208 public async Task CastTab(string sinkName, string sessionId)
3209 {
3210 CastDevice sink = null;
3211 int retries = 10;
3212 sessionId ??= GetEffectiveSessionId();
3213
3214 await SendCommand<object>(DevToolsMethods.CastDisable, null, sessionId).ConfigureAwait(false);
3215
3216 while (null == sink && 0 < retries--)
3217 {
3218 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Searching for [{sinkName}].", this, GPALObjectType.PuppeteerCommunicator);
3219 await SendCommand<object>(DevToolsMethods.CastEnable, new { }, sessionId).ConfigureAwait(false);
3220
3221 await Task.Delay(3_000).ConfigureAwait(false);
3222
3223 sink = CastDevices.Find(device => device.name == sinkName);
3224 }
3225
3226 await Task.Delay(3_000).ConfigureAwait(false);
3227
3228 if (null != sink)
3229 {
3230 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{sinkName}] found.", this, GPALObjectType.PuppeteerCommunicator);
3231 SinkName = sinkName;
3232 object param = new { sinkName = sink.name };
3233
3234 // cast or javascript, not both
3235 await SendCommand<object>(DevToolsMethods.CastStartTabMirroring, param, sessionId).ConfigureAwait(false);
3236 }
3237 else
3238 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{sinkName}] NOT found.", this, GPALObjectType.PuppeteerCommunicator);
3239 }
3240
3241 // Implementation for "stop-casting"
3246 /// <param name="sessionId">CDP session id to send the stop-casting command on.</param>
3247 /// <returns>Task that resolves to true once the stop attempt has completed (regardless of whether casting actually stopped).</returns>
3248 /// <example>
3249 /// <code>
3250 /// await communicator.StopCasting(sessionId);
3251 /// </code>
3252 /// </example>
3253 public async Task<bool> StopCasting(string sessionId)
3254 {
3255 // NOTE: there may be no programmatic way to cleanly stop casting except to close the tab or window. This doesn't seem to work.
3256 try
3257 {
3258 await SendCommand<object>(DevToolsMethods.CastStopCasting, new { sinkName = SinkName }, sessionId).ConfigureAwait(false);
3259 }
3260 catch { }
3261
3262 Thread.Sleep(500);
3263
3264 return true;
3265 }
3266
3267 // Implementation for "check-network-idle"
3272 /// </summary>
3273 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
3274 /// <param name="maxConnections">Reserved for future use; currently not enforced.</param>
3275 /// <param name="timeoutMs">The maximum total time, in milliseconds, to wait for the network to become idle.</param>
3276 /// <param name="pruneMs">The duration of inactivity, in milliseconds, required to consider the network idle.</param>
3277 /// <param name="sessionToken">An opaque token used to deduplicate repeated status messages across calls.</param>
3278 /// <returns>Task that resolves to true if the network became idle within the timeout, otherwise false.</returns>
3279 public async Task<bool> CheckNetworkIdle(string sessionId = null, int maxConnections = 0, int timeoutMs = 30000, int pruneMs = 3000, string sessionToken = null)
3280 {
3281 bool retVal = false;
3282 GPALEventType consoleTypeSave = GPAL.GPALSettings.ConsoleEvents;
3283 GPALEventType debugTypeSave = GPAL.GPALSettings.DebugEvents;
3284 string currentErrorMessage = null;
3285
3286
3287 if (lastSessionToken != sessionToken)
3288 {
3289 lastErrorMessage.Clear();
3290 supressedMessage = false;
3291 lastSessionToken = sessionToken;
3292 }
3293
3294 currentErrorMessage = $"Waiting up to [{timeoutMs}] ms for network to idle to [{maxConnections}] connections for [{pruneMs}] ms";
3295
3296 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage, this, GPALObjectType.PuppeteerCommunicator);
3297
3298 try
3299 {
3300 _suppressNetworkEvents = true; // Suppress non-essential network events
3301 await SendCommand(DevToolsMethods.NetworkEnable, new { cacheDisabled = true }, sessionId).ConfigureAwait(false);
3302 var start = DateTime.UtcNow;
3303 DateTime? lastRequestTime = null;
3304 var pendingEvents = new List<(string Event, dynamic Data, DateTime Timestamp)>();
3305 int unmatchedCount = 0; // Track unmatched events to throttle logging
3306 bool isFirstIteration = true; // Flag for initial queue debugging
3307
3308 sessionId ??= GetEffectiveSessionId();
3309
3310 await Task.Delay(500).ConfigureAwait(false);
3311
3312 // Track network events
3313 while (DateTime.UtcNow - start < TimeSpan.FromMilliseconds(timeoutMs))
3314 {
3315 // Collect events with timestamps
3316 while (_events.TryDequeue(out (string Event, dynamic Data) evt))
3317 {
3318 pendingEvents.Add((evt.Event, evt.Data, DateTime.UtcNow));
3319 }
3320
3321 // Sort events by timestamp to process in order
3322 pendingEvents.Sort((a, b) => a.Timestamp.CompareTo(b.Timestamp));
3323
3324 // Process events
3325 foreach (var evt in pendingEvents)
3326 {
3327 string requestId = evt.Data?.requestId?.ToString() ?? "unknown";
3328 string url = evt.Data?.url?.ToString() ?? "unknown";
3329 string resourceType = evt.Data?.resourceType?.ToString() ?? "unknown";
3330 string initiator = evt.Data?.initiator?.type?.ToString() ?? "unknown";
3331 string frameId = evt.Data?.frameId?.ToString() ?? "unknown";
3332 string loaderId = evt.Data?.loaderId?.ToString() ?? "unknown";
3333 string dataJson = evt.Data != null ? Newtonsoft.Json.JsonConvert.SerializeObject(evt.Data) : "null";
3334
3335 if (evt.Event == "Network.requestWillBeSent")
3336 {
3337 lastRequestTime = DateTime.UtcNow;
3338 //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);
3339 }
3340 else if (evt.Event == "Network.requestWillBeSentExtraInfo")
3341 {
3342 string associatedRequestId = evt.Data?.associatedRequestId?.ToString();
3343 //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);
3344 }
3345 else if (evt.Event == "Network.loadingFinished" || evt.Event == "Network.loadingFailed")
3346 {
3347 unmatchedCount++;
3348 if (unmatchedCount % 10 == 0) // Throttle logging
3349 {
3350 //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);
3351 }
3352 }
3353 else if (evt.Event == "Network.requestServedFromCache")
3354 {
3355 //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);
3356 }
3357 else if (isFirstIteration)
3358 {
3359 //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);
3360 }
3361 }
3362 pendingEvents.Clear(); // Clear processed events
3363
3364 // Check for idle condition
3365 if (lastRequestTime == null || (DateTime.UtcNow - lastRequestTime >= TimeSpan.FromMilliseconds(pruneMs)))
3366 {
3367 retVal = true; // No new requests for pruneMs
3368 break;
3369 }
3370
3371 // Log initial state after first iteration
3372 if (isFirstIteration)
3373 {
3374 isFirstIteration = false;
3375 }
3376
3377 await Task.Delay(50, _cts.Token).ConfigureAwait(false); // Reduced polling delay for responsiveness
3378 }
3379
3380 // Final debug log
3381 //GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"CheckNetworkIdle complete: unmatchedCount=[{unmatchedCount}], result=[{retVal}]", this, GPALObjectType.PuppeteerCommunicator);
3382 }
3383 catch (Exception ex)
3384 {
3385 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to check network idle", null, GPALObjectType.PuppeteerCommunicator, ex);
3386 }
3387 finally
3388 {
3389 // only turn the domain off if this method is what turned it on. Credentials presented as a header
3390 // live in the Network domain's state, so disabling here would quietly drop them and the next
3391 // request would come back a 401 with nothing in the log to explain it
3392 if (null == Browser?.BrowserSettings.CredentialsPresented)
3393 await SendCommand(DevToolsMethods.NetworkDisable, new { }, sessionId).ConfigureAwait(false);
3394
3395 _suppressNetworkEvents = false; // Re-enable event processing
3396
3397 currentErrorMessage = $"Network IS{(retVal ? "" : " NOT")} Idle. Status [{retVal}]";
3398
3399 if (false == lastErrorMessage.Contains(currentErrorMessage))
3400 {
3401 lastErrorMessage.Add(currentErrorMessage);
3402 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage, this, GPALObjectType.PuppeteerCommunicator);
3403 supressedMessage = false;
3404 }
3405 else if (false == supressedMessage)
3406 {
3407 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
3408 supressedMessage = true;
3409 }
3410 }
3411 return retVal;
3413
3414 // Implementation for "clear-referrer"
3417
3419 public async Task ClearReferrer(string sessionId = null)
3420 {
3421 sessionId ??= GetEffectiveSessionId();
3422 await SendCommand<object>(DevToolsMethods.NetworkSetExtraHTTPHeaders, new { headers = new { Referer = "" } }, sessionId).ConfigureAwait(false);
3423 }
3424
3430 private void CheckForLastBrowserWindowOpen(string reason)
3431 {
3432 if (_windowSessionsQueue.IsEmpty)
3433 {
3434 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);
3435 }
3436 }
3442 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
3443 /// <returns>Task that resolves to true if the tab (and possibly its now-empty window) was closed successfully.</returns>
3444 /// <example>
3445 /// <code>
3446 /// await communicator.CloseTab(url: "https://example.com");
3447 /// </code>
3448 /// </example>
3449 public async Task<bool> CloseTab(string url = null, string tabId = null, string sessionId = null)
3450 {
3451 sessionId ??= GetEffectiveSessionId();
3452 string targetId = tabId ?? (url != null ? await GetTargetTabIdByUrl(url).ConfigureAwait(false) : GetCurrentTargetId());
3453 return await CloseTab(targetId, sessionId).ConfigureAwait(false);
3454 }
3455 /// <summary>
3456 /// Closes the browser tab with the given target id, removes it from the internal tab queue, and
3457 /// closes its parent window if that was the last remaining tab.
3458 /// </summary>
3459 /// <param name="targetId">The CDP target id of the tab to close.</param>
3460 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
3461 /// <returns>Task that resolves to true if the tab was closed (and the parent window closed, if it became empty), otherwise false.</returns>
3462 public async Task<bool> CloseTab(string targetId, string sessionId = null)
3463 {
3464 bool retVal = false;
3465 if (string.IsNullOrEmpty(targetId))
3466 {
3467 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No targetId provided for CloseTab", this, GPALObjectType.PuppeteerCommunicator);
3468 }
3469 else
3470 {
3471
3472 await SendCommand<object>(DevToolsMethods.TargetCloseTarget, new { targetId }, null).ConfigureAwait(false);
3473 bool removed = RemoveTabFromQueue(targetId);
3474 if (!removed)
3475 {
3476 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Failed to remove tab [{targetId}] from queue", this, GPALObjectType.PuppeteerCommunicator);
3477 }
3478
3479 var windowArray = _windowSessionsQueue.ToArray();
3480 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
3481 {
3482 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"No active window after closing tab [{targetId}]", this, GPALObjectType.PuppeteerCommunicator);
3483 retVal = true;
3484 }
3485 else
3486 {
3487 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
3488 if (tabQueue != null && tabQueue.IsEmpty)
3489 {
3490 string browserContextId = windowArray[_activeWindowIndex].BrowserContextId;
3491 await CloseWindow(browserContextId, sessionId).ConfigureAwait(false);
3492 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Closed window for targetId [{targetId}] (context [{browserContextId}]) as it has no tabs", this, GPALObjectType.PuppeteerCommunicator);
3493 retVal = true;
3494 }
3495 }
3496 }
3497 CheckForLastBrowserWindowOpen("CloseTab()");
3498 return retVal;
3499 }
3505 /// <param name="sessionId">CDP session id; currently unused but reserved for future use.</param>
3506 /// <returns>Task that resolves to true if the window and all its tabs were closed successfully, otherwise false.</returns>
3507 /// <example>
3508 /// <code>
3509 /// await communicator.CloseWindow(browserContextId);
3510 /// </code>
3511 /// </example>
3512 public async Task<bool> CloseWindow(string browserContextId, string sessionId = null)
3513 {
3514 bool retVal = false;
3515 try
3516 {
3517 var windowArray = _windowSessionsQueue.ToArray();
3518 var window = windowArray.FirstOrDefault(w => w.BrowserContextId == browserContextId);
3519 if (window == null)
3520 {
3521 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Window with browserContextId [{browserContextId}] not found", this, GPALObjectType.PuppeteerCommunicator);
3522 }
3523 else
3524 {
3525 // iterate thru all open tabs, close them which will close the window, also cleanup our internal housekeeping
3526 var tabArray = window.TabQueue.ToArray();
3527 foreach (var tab in tabArray)
3528 {
3529 await SendCommand<object>(DevToolsMethods.TargetCloseTarget, new { targetId = tab.Key }, null).ConfigureAwait(false);
3530 RemoveTabFromQueue(tab.Key);
3531 }
3532
3533 if (RemoveWindowFromQueue(browserContextId))
3534 {
3535 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Closed window with browserContextId [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
3536 retVal = true;
3537 }
3538 else
3539 {
3540 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to remove window with browserContextId [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
3541 }
3542 }
3543 }
3544 catch (Exception ex)
3545 {
3546 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to close window with browserContextId [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator, ex);
3547 }
3548
3549 CheckForLastBrowserWindowOpen("CloseWindow()");
3550 return retVal;
3551 }
3552
3553 // Implementation for "delete-storage"
3569 /// <param name="storageType">Storage type</param>
3570 /// <param name="sessionId">CDP session id</param>
3571 /// <param name="domain">Domain/origin; null acts as wildcard</param>
3572 /// <param name="storeName">Optional object store (IndexedDB)</param>
3573 /// <param name="path">Optional path (cookies)</param>
3574 /// <param name="key">Optional key; null acts as wildcard</param>
3575 /// <returns>Number of items successfully deleted</returns>
3576 public async Task<bool> DeleteStorage(
3577 string storageType,
3578 string sessionId,
3579 string domain = null,
3580 string storeName = null,
3581 string path = null,
3582 string key = null)
3583 {
3584 sessionId ??= GetEffectiveSessionId();
3585 int deletedCount = 0;
3586 bool retVal = false;
3587
3588 bool IsMissing(string s) => string.IsNullOrEmpty(s);
3589
3590 try
3591 {
3592 string origin = false == string.IsNullOrEmpty(domain) ? UrlHelper.GetOrigin(domain) : UrlHelper.GetOrigin(BrowserHelper.GetCurrentUrl(Browser.BrowserSettings));
3593
3594 if (IsMissing(domain))
3595 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"DOMAIN IS MISSING. Deleting all [{storageType}] for origin [{origin}].", this, GPALObjectType.PuppeteerCommunicator);
3596
3597 switch (storageType)
3598 {
3599 case "cookie":
3600 {
3601 var cookiesJson = await GetStorage("cookie", sessionId, domain, storeName, path, key).ConfigureAwait(false);
3602 var cookies = JsonConvert.DeserializeObject<List<dynamic>>(cookiesJson) ?? new List<dynamic>();
3603 foreach (dynamic c in cookies)
3604 {
3605 var delParams = new Dictionary<string, object>
3606 {
3607 { "name", (string)c.Key },
3608 { "domain", (string)c.Domain },
3609 //{ "url", $"https://{(string)c.Domain.ToString().TrimStart('.')}{(string)c.Path}" },
3610 { "path", (string)c.Path },
3611 };
3612
3613 // build the partitionKey object exactly as it appears in the cookie. a cookie
3614 // that has no partition answers with a json null, which is an object and not a
3615 // c# null, so asking that for topLevelSite threw after the delete had gone out
3616 JToken cookiePartition = ((JObject)c)["PartitionKey"];
3617
3618 if (null != cookiePartition && JTokenType.Object == cookiePartition.Type)
3619 {
3620 var pk = new Dictionary<string, object>
3621 {
3622 { "topLevelSite", (string)cookiePartition["topLevelSite"] },
3623 { "hasCrossSiteAncestor", (bool?)cookiePartition["hasCrossSiteAncestor"] }
3624 };
3625
3626 delParams.Add("partitionKey", pk);
3627 }
3628 // deleteCookies answers with the reply object rather than a flag, and assigning
3629 // that to a bool threw once the cookie had already gone, so the loop stopped on
3630 // the first one and nothing counted it
3631 dynamic deleteReply = await SendCommand(DevToolsMethods.NetworkDeleteCookies, delParams, sessionId).ConfigureAwait(false);
3632
3633 retVal = null != deleteReply;
3634
3635 if (true == retVal)
3636 deletedCount++;
3637 }
3638 break;
3639 }
3640 case "localStorage":
3641 case "sessionStorage":
3642 {
3643 if (IsMissing(domain))
3644 {
3645 string js = $@"(function() {{
3646 try {{
3647 {storageType}.clear();
3648 return true;
3649 }} catch (e) {{
3650 return false;
3651 }}
3652 }})();";
3653
3654 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
3655 {
3656 expression = js,
3657 awaitPromise = false,
3658 contextId = CurrentContextId,
3659 returnByValue = true
3660 }, sessionId).ConfigureAwait(false).GetAwaiter().GetResult()?.ToString().Contains("true");
3661
3662 if (true == retVal)
3663 deletedCount++;
3664
3665 break;
3666 }
3667
3668 if (IsMissing(key))
3669 {
3670 var keysJson = await GetStorage(storageType, sessionId, domain).ConfigureAwait(false);
3671
3672 // the read answers the whole store as a key/value object, and taking that as a
3673 // list of names threw before a single item was removed
3674 List<string> keys = new List<string>();
3675
3676 if (false == string.IsNullOrWhiteSpace(keysJson))
3677 {
3678 JToken storeRead = JToken.Parse(keysJson);
3679
3680 if (JTokenType.Object == storeRead.Type)
3681 keys = ((JObject)storeRead).Properties().Select(each => each.Name).ToList();
3682 else if (JTokenType.Array == storeRead.Type)
3683 keys = storeRead.Select(each => each.ToString()).ToList();
3684 }
3685 foreach (var k in keys)
3686 {
3687 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
3688 {
3689 expression = $"{storageType}.removeItem('{EscapeJsString(k)}');",
3690 awaitPromise = false,
3691 contextId = CurrentContextId,
3692 returnByValue = true
3693 }, sessionId).ConfigureAwait(false).GetAwaiter().GetResult()?.ToString().Contains("true");
3694 if (true == retVal)
3695 deletedCount++;
3696 }
3697 }
3698 else
3699 {
3700 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
3701 {
3702 expression = $"{storageType}.removeItem('{EscapeJsString(key)}');",
3703 awaitPromise = false,
3704 contextId = CurrentContextId,
3705 returnByValue = true
3706 }, sessionId).ConfigureAwait(false).GetAwaiter().GetResult()?.ToString().Contains("true");
3707 if (true == retVal)
3708 deletedCount++;
3709 }
3710 break;
3711 }
3712 case "indexedDb":
3713 {
3714 if (IsMissing(domain))
3715 {
3716 retVal = SendCommand<dynamic>(DevToolsMethods.StorageClearDataForOrigin, new
3717 {
3718 origin = origin,
3719 storageTypes = "indexeddb"
3720 }, sessionId).GetAwaiter().GetResult() != null;
3721
3722 if (retVal)
3723 deletedCount++;
3724
3725 break;
3726 }
3727
3728 if (IsMissing(storeName))
3729 {
3730 // enumerate all DBs and delete
3731 var dbsJson = await GetStorage("indexeddb", sessionId, domain).ConfigureAwait(false);
3732 var dbs = JsonConvert.DeserializeObject<List<string>>(dbsJson) ?? new List<string>();
3733 foreach (var db in dbs)
3734 {
3735 string js = $@"(function(){{ indexedDB.deleteDatabase('{EscapeJsString(db)}'); }})();";
3736 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate,
3737 new {
3738 expression = js,
3739 awaitPromise = true,
3740 contextId = CurrentContextId,
3741 returnByValue = true
3742 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
3743 if (true == retVal)
3744 deletedCount++;
3745 }
3746 }
3747 else if (IsMissing(key))
3748 {
3749 string js = $@"(function(){{ indexedDB.deleteDatabase('{EscapeJsString(storeName)}'); }})();";
3750 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate,
3751 new {
3752 expression = js,
3753 awaitPromise = true,
3754 contextId = CurrentContextId,
3755 returnByValue = true
3756 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
3757 if (true == retVal)
3758 deletedCount++;
3759 }
3760 else
3761 {
3762 string js = $@"(function() {{
3763 return new Promise((resolve, reject) => {{
3764 const dbName = '{EscapeJsString(path)}'; // path = dbName (per your note)
3765 const store = '{EscapeJsString(storeName)}'; // storeName = store (per your note)
3766 const recordKey = '{EscapeJsString(key)}';
3767
3768 const openReq = indexedDB.open(dbName);
3769
3770 openReq.onerror = () => reject(openReq.error);
3771
3772 openReq.onsuccess = () => {{
3773 const db = openReq.result;
3774
3775 const tx = db.transaction(store, 'readwrite');
3776 const objectStore = tx.objectStore(store);
3777
3778 objectStore.delete(recordKey);
3779
3780 tx.oncomplete = () => {{
3781 db.close();
3782 resolve(true); // so your .Contains(""true"") check still works
3783 }};
3784
3785 tx.onerror = () => {{
3786 db.close();
3787 reject(tx.error);
3788 }};
3789 }};
3790 }});
3791 }})();
3792 ";
3793
3794 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate,
3795 new
3796 {
3797 expression = js,
3798 awaitPromise = true,
3799 contextId = CurrentContextId,
3800 returnByValue = true
3801 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
3802
3803 if (retVal)
3804 deletedCount++;
3805 }
3806 break;
3807 }
3808
3809 case "cache":
3810 {
3811 if (IsMissing(domain))
3812 {
3813 string js = @"(async function() {
3814 try {
3815 const keys = await caches.keys();
3816 await Promise.all(keys.map(k => caches.delete(k)));
3817 return true;
3818 } catch (e) {
3819 return false;
3820 }
3821 })();";
3822
3823 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
3824 {
3825 expression = js,
3826 awaitPromise = true,
3827 contextId = CurrentContextId,
3828 returnByValue = true
3829 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
3830
3831 if (true == retVal)
3832 deletedCount++;
3833
3834 break;
3835 }
3836
3837 if (IsMissing(key))
3838 {
3839 // the store name names the cache, the same as the read does. without one the read
3840 // answers the cache names and each whole cache goes; with one it answers the keys
3841 // held in that cache and they go one at a time
3842 var cachesJson = await GetStorage("cache", sessionId, domain, storeName).ConfigureAwait(false);
3843 var cacheEntries = JsonConvert.DeserializeObject<List<string>>(cachesJson) ?? new List<string>();
3844
3845 foreach (var each in cacheEntries)
3846 {
3847 string cacheDelete = IsMissing(storeName)
3848 ? $"caches.delete('{EscapeJsString(each)}');"
3849 : $"caches.open('{EscapeJsString(storeName)}').then(cache => cache.delete('{EscapeJsString(each)}',{{ignoreVary: true}}));";
3850
3851 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate,
3852 new {
3853 expression = cacheDelete,
3854 awaitPromise = true,
3855 contextId = CurrentContextId,
3856 returnByValue = true
3857 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
3858 if (true == retVal)
3859 deletedCount++;
3860 }
3861 }
3862 else
3863 {
3864 retVal = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate,
3865 new {
3866 expression = $"caches.open('{EscapeJsString(storeName)}').then(cache => cache.delete('{EscapeJsString(key)}', {{ignoreVary: true}}));",
3867 awaitPromise = true,
3868 contextId = CurrentContextId,
3869 returnByValue = true
3870 }, sessionId).GetAwaiter().GetResult()?.ToString().Contains("true");
3871 if (true == retVal)
3872 deletedCount++;
3873 }
3874 break;
3875 }
3876
3877 default:
3879 GPALEventType.ERROR,
3880 $"Unsupported storage type: [{storageType}]",
3881 this,
3882 GPALObjectType.PuppeteerCommunicator);
3883 break;
3885 }
3886 catch (Exception ex)
3887 {
3889 GPALEventType.EXCEPTION,
3890 $"Failed for [{storageType}] d[{domain}] sn[{storeName}] p[{path}] k[{key}]",
3891 this,
3892 GPALObjectType.PuppeteerCommunicator,
3893 ex);
3894 }
3895
3896 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Deleted [{deletedCount}] [{storageType}](s)", this, GPALObjectType.PuppeteerCommunicator);
3897
3898 return 0 < deletedCount;
3899 }
3900 // Small helper to prevent JS injection / quoting issues
3906 /// <returns>The escaped string, or an empty string if <paramref name="value"/> is null or empty.</returns>
3907 private static string EscapeJsString(string value)
3908 {
3909 if (string.IsNullOrEmpty(value)) return "";
3910 return value.Replace("\\", "\\\\")
3911 .Replace("'", "\\'")
3912 .Replace("\r", "\\r")
3913 .Replace("\n", "\\n");
3914 }
3915
3916 // Implementation for "evaluate"
3928 public async Task<dynamic> Evaluate(string xpath, string sessionId = null)
3929 {
3930 sessionId ??= GetEffectiveSessionId();
3931 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
3932 new {
3933 contextId = CurrentContextId,
3934 expression = $"document.evaluate('{xpath}', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue"
3935 }, sessionId).ConfigureAwait(false);
3936 }
3937
3938 // Implementation for "evaluate-all"
3950 public async Task<dynamic> EvaluateAll(string xpath, string sessionId = null)
3951 {
3952 sessionId ??= GetEffectiveSessionId();
3953 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
3954 new {
3955 contextId = CurrentContextId,
3956 expression = $"Array.from(document.evaluate('{xpath}', document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null))"
3957 }, sessionId).ConfigureAwait(false);
3958 }
3959
3966 private string GetKeyCode(char c)
3967 {
3968 char upper = char.ToUpperInvariant(c);
3969
3970 if (upper >= 'A' && upper <= 'Z')
3971 {
3972 return "Key" + upper;
3973 }
3974
3975 if (char.IsDigit(c))
3976 {
3977 return "Digit" + c;
3978 }
3979
3980 switch (c)
3981 {
3982 case '`':
3983 case '~':
3984 return "Backquote";
3985
3986 case '-':
3987 case '_':
3988 return "Minus";
3989
3990 case '=':
3991 case '+':
3992 return "Equal";
3993
3994 case '[':
3995 case '{':
3996 return "BracketLeft";
3997
3998 case ']':
3999 case '}':
4000 return "BracketRight";
4001
4002 case '\\':
4003 case '|':
4004 return "Backslash";
4005
4006 case ';':
4007 case ':':
4008 return "Semicolon";
4009
4010 case '\'':
4011 case '"':
4012 return "Quote";
4013
4014 case ',':
4015 case '<':
4016 return "Comma";
4017
4018 case '.':
4019 case '>':
4020 return "Period";
4021
4022 case '/':
4023 case '?':
4024 return "Slash";
4025
4026 case ' ':
4027 return "Space";
4028
4029 case '\t':
4030 return "Tab";
4031
4032 case '\r':
4033 case '\n':
4034 return "Enter";
4035
4036 // Add more special keys here as needed, e.g.:
4037 // case '\b': return "Backspace";
4038 // case '\x1B': return "Escape";
4039
4040 default:
4041 return c.ToString(); // fallback
4042 }
4043 }
4044
4045 // Implementation for "fillin" helper
4059 public async Task<dynamic> FillIn(string objectId, string text, int delayMs = 0, string sessionId = null)
4060 {
4061 sessionId ??= GetEffectiveSessionId();
4062
4063 // Input.insertText (faster bulk insert)
4064 bool success = await TryInsertText(objectId, text, sessionId).ConfigureAwait(false);
4065 if (success) return true;
4066
4067 // JS direct set + synthetic events
4068 success = await TryJsDirectFill(objectId, text, sessionId).ConfigureAwait(false);
4069 if (success) return true;
4070
4071 // Step 1: Ensure focused
4072 await FocusElement(objectId, sessionId).ConfigureAwait(false);
4073
4074 // Per-char key events (most reliable for validation)
4075 success = await TryTypeWithKeyEvents(objectId, text, delayMs, sessionId).ConfigureAwait(false);
4076 if (success) return true;
4077
4078
4079 // All failed — log or throw
4080 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to fill element [{objectId}] with [{text}] using all methods.");
4081 return false;
4082
4083 }
4084
4094 private async Task<bool> TryTypeWithKeyEvents(string objectId, string text, int delayMs, string sessionId)
4095 {
4096 try
4097 {
4098 await DispatchTextCharByChar(text, 0 < delayMs ? delayMs : 50, sessionId).ConfigureAwait(false);
4099
4100 // After typing, verify
4101 string actualValue = await GetElementValue(objectId, sessionId).ConfigureAwait(false);
4102 if (actualValue == text)
4103 {
4104 // Final blur to trigger any validation/button enable
4105 await BlurElement(objectId, sessionId).ConfigureAwait(false);
4106 return true;
4107 }
4108 }
4109 catch (Exception ex)
4110 {
4111 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to type text, trying next method.", this, GPALObjectType.PuppeteerCommunicator, ex);
4112 }
4113 return false;
4114 }
4115
4124 private async Task<bool> TryInsertText(string objectId, string text, string sessionId)
4125 {
4126 try
4127 {
4128 // Bulk insert
4129 await SendCommand(DevToolsMethods.InputInsertText, new { text }, sessionId).ConfigureAwait(false);
4130
4131 // Verify
4132 string actualValue = await GetElementValue(objectId, sessionId).ConfigureAwait(false);
4133 if (actualValue == text)
4134 {
4135 await BlurElement(objectId, sessionId).ConfigureAwait(false);
4136 return true;
4137 }
4138 }
4139 catch (Exception ex)
4140 {
4141 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to type text, trying next method.", this, GPALObjectType.PuppeteerCommunicator, ex);
4142 }
4143 return false;
4144 }
4145
4155 private async Task<bool> TryJsDirectFill(string objectId, string text, string sessionId)
4156 {
4157 try
4158 {
4159 // Your original/enhanced JS function (set .value + dispatch events)
4160 var js = @"
4161 function(value) {
4162 this.value = value;
4163
4164 // Trigger everything frameworks listen to
4165 const desc = Object.getOwnPropertyDescriptor(this, 'value') ||
4166 Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value') ||
4167 Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
4168
4169 // Bypass any framework value setters that block direct assignment
4170 if (desc && desc.set) {
4171 desc.set.call(this, value);
4172 }
4173
4174 this.dispatchEvent(new Event('input', { bubbles: true }));
4175 this.dispatchEvent(new Event('change', { bubbles: true }));
4176 }";
4177
4178 await SendCommand(DevToolsMethods.RuntimeCallFunctionOn, new
4179 {
4180 objectId,
4181 functionDeclaration = js,
4182 arguments = new[] { new { value = text } },
4183 contextId = CurrentContextId,
4184 awaitPromise = true
4185 }, sessionId).ConfigureAwait(false);
4186
4187 // Verify
4188 string actualValue = await GetElementValue(objectId, sessionId).ConfigureAwait(false);
4189 if (actualValue == text)
4190 {
4191 await BlurElement(objectId, sessionId).ConfigureAwait(false);
4192 return true;
4193 }
4194 }
4195 catch (Exception ex)
4196 {
4197 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to type text, trying next method.", this, GPALObjectType.PuppeteerCommunicator, ex);
4198 }
4199 return false;
4200 }
4201
4202 // Helper: Get current value via JS evaluate
4209 private async Task<string> GetElementValue(string objectId, string sessionId)
4210 {
4211 var result = await SendCommand<dynamic>(DevToolsMethods.RuntimeCallFunctionOn, new
4212 {
4213 objectId,
4214 functionDeclaration = "function() { return this.value; }",
4215 contextId = CurrentContextId,
4216 returnByValue = true
4217 }, sessionId).ConfigureAwait(false);
4218
4219 // Parse the returned value (assuming it's a string)
4220 return result?.result?.value?.ToString() ?? string.Empty;
4221 }
4222
4223 // Helper: Focus
4229 internal async Task FocusElement(string objectId, string sessionId)
4230 {
4231 await SendCommand(DevToolsMethods.RuntimeCallFunctionOn, new
4232 {
4233 objectId,
4234 contextId = CurrentContextId,
4235 functionDeclaration = "function() { this.focus(); }"
4236 }, sessionId).ConfigureAwait(false);
4237 }
4238
4239 // Helper: Blur
4245 private async Task BlurElement(string objectId, string sessionId)
4246 {
4247 await SendCommand(DevToolsMethods.RuntimeCallFunctionOn, new
4248 {
4249 objectId,
4250 contextId = CurrentContextId,
4251 functionDeclaration = "function() { this.blur(); }"
4252 }, sessionId).ConfigureAwait(false);
4253 }
4254
4255 // Implementation for "fill-in-append"
4266
4267 public async Task FillInAppend(string objectId, string text, int delayMs = 0, string sessionId = null)
4268 {
4269 sessionId ??= GetEffectiveSessionId();
4270
4271 var currentResp = await SendCommand<object>(DevToolsMethods.RuntimeCallFunctionOn, new
4272 {
4273 objectId,
4274 functionDeclaration = "function() { return this.value || ''; }",
4275 contextId = CurrentContextId,
4276 returnByValue = true
4277 }, sessionId).ConfigureAwait(false);
4278
4279 string current = currentResp?.result?.value?.ToString() ?? "";
4280 await FillIn(objectId, current + text, delayMs, sessionId).ConfigureAwait(false);
4281 }
4282
4283 // Implementation for "fill-in-insert"
4291 /// <code>
4292 /// await communicator.FillInInsert(inputObjectId, "prefix-");
4293 /// </code>
4294 /// </example>
4295 public async Task FillInInsert(string objectId, string text, int delayMs = 0, string sessionId = null)
4296 {
4297 sessionId ??= GetEffectiveSessionId();
4298
4299 var currentResp = await SendCommand<object>(DevToolsMethods.RuntimeCallFunctionOn, new
4300 {
4301 objectId,
4302 functionDeclaration = "function() { return this.value || ''; }",
4303 contextId = CurrentContextId,
4304 returnByValue = true
4305 }, sessionId).ConfigureAwait(false);
4306
4307 string current = currentResp?.result?.value?.ToString() ?? "";
4308 await FillIn(objectId, text + current, delayMs, sessionId).ConfigureAwait(false);
4309 }
4310
4311 // Implementation for "fill-in-overwrite"
4314
4321 /// </code>
4322 /// </example>
4323 public async Task FillInOverwrite(string objectId, string text, int delayMs = 0, string sessionId = null)
4324 {
4325 await FillIn(objectId, text, delayMs, sessionId).ConfigureAwait(false);
4326 }
4327
4328 // Implementation for "fire-change-event"
4335 public async Task<dynamic> FireChangeEvent(string objectId, string sessionId = null)
4336 {
4337 sessionId ??= GetEffectiveSessionId();
4338 return await SendCommand<object>(DevToolsMethods.RuntimeCallFunctionOn, new
4339 {
4340 objectId,
4341 contextId = CurrentContextId,
4342 functionDeclaration = "function() {{ this.dispatchEvent(new Event('change')); }}"
4343 }, sessionId).ConfigureAwait(false);
4344 }
4345
4346 // Implementation for "focus"
4352 public async Task Focus(int backendNodeId, string sessionId = null)
4353 {
4354 sessionId ??= GetEffectiveSessionId();
4355 var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, sessionId).ConfigureAwait(false);
4356 await SendCommand<object>(DevToolsMethods.DOMFocus, new { nodeId }, sessionId).ConfigureAwait(false);
4357 }
4358
4359 // Implementation for "forward"
4365 public async Task<dynamic> Forward(string sessionId = null)
4366 {
4367 sessionId ??= GetEffectiveSessionId();
4368 dynamic history = await SendCommand<object>(DevToolsMethods.PageGetNavigationHistory, new { }, sessionId).ConfigureAwait(false);
4369 if (history?.currentIndex != null)
4370 {
4371 var entries = (IEnumerable<dynamic>)history.entries;
4372 var nextEntry = entries.ElementAtOrDefault((int)history.currentIndex + 1);
4373 if (nextEntry?.id != null)
4375 var result = await SendCommand(DevToolsMethods.PageNavigateToHistoryEntry, new { entryId = (int)nextEntry.id }, sessionId).ConfigureAwait(false);
4376 return result;
4377 }
4378 }
4379 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No next page in history", this, GPALObjectType.PuppeteerClient);
4380 return null;
4381 }
4382
4383 // Implementation for "fullscreen"
4390 public async Task<bool> FullScreen(string sessionId = null)
4392 if (true == Browser.BrowserSettings.UseHeadless)
4393 return await Maximize().ConfigureAwait(false);
4394 else
4395 {
4396 sessionId ??= GetEffectiveSessionId();
4397
4398 var windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
4399
4400 await SendCommand(DevToolsMethods.BrowserSetWindowBounds, new
4401 {
4402 windowId,
4403 bounds = new { windowState = "fullscreen" } // - this is the CDP way for browser fullscreen
4404 }, sessionId).ConfigureAwait(false);
4405 }
4406
4407 return true;
4408 }
4409
4410 // Implementation for "get-attribute"
4418 public async Task<dynamic> GetAttribute(int backendNodeId, string attribute, string sessionId = null)
4419 {
4420 var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, sessionId).ConfigureAwait(false);
4421 sessionId ??= GetEffectiveSessionId();
4422 return await SendCommand<object>(DevToolsMethods.DOMGetAttributes, new { nodeId, name = attribute }, sessionId).ConfigureAwait(false);
4423 }
4424
4425
4426 // Implementation for "get-bounding-client-rect"
4435 public async Task<Rectangle> GetBoundingClientRect(int backendNodeId, string sessionId = null)
4436 {
4437 sessionId ??= GetEffectiveSessionId();
4438
4439 // If we are inside an iframe (via InFrame()), prefer the iframe's context/session
4440 bool isInFrame = CurrentContextId.HasValue;
4441 string effectiveSessionId = isInFrame && !string.IsNullOrEmpty(CurrentFrameSessionId)
4442 ? CurrentFrameSessionId
4443 : sessionId;
4444
4445 // getBoundingClientRect answers against the document holding the element, which is what the screen
4446 // calculation expects since it adds the iframe position itself.
4447 // Taken whenever there is an execution context, not only when the frame has its own session: a
4448 // same-origin iframe never gets one, and the box model below answers in main-frame coordinates that
4449 // already include the iframe offset
4450 if (CurrentContextId.HasValue)
4451 {
4452 try
4453 {
4454 // 1. Convert backendNodeId to a JavaScript RemoteObject in the frame context
4455 var resolveResult = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, new
4456 {
4457 backendNodeId = backendNodeId,
4458 executionContextId = CurrentContextId.Value
4459 }, effectiveSessionId).ConfigureAwait(false);
4460
4461 string objectId = resolveResult?.@object?.objectId;
4462
4463 if (!string.IsNullOrEmpty(objectId))
4464 {
4465 // 2. Call getBoundingClientRect directly on that object
4466 var rectResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeCallFunctionOn, new
4467 {
4468 objectId = objectId,
4469 functionDeclaration = "function() { const r = this.getBoundingClientRect(); return {x: r.left, y: r.top, width: r.width, height: r.height}; }",
4470 contextId = CurrentContextId,
4471 returnByValue = true
4472 }, effectiveSessionId).ConfigureAwait(false);
4473
4474 if (rectResult?.result?.value != null)
4475 {
4476 var r = rectResult.result.value;
4477 if (0 < (int)r.width && 0 < (int)r.height)
4478 // relative to the viewport of the document the element sits in
4479 return new Rectangle((int)r.x, (int)r.y, (int)r.width, (int)r.height);
4480 }
4481 }
4482 }
4483 catch { }
4484 }
4485
4486 // === main page or fallback ===
4487 Point pageOffset = Point.Empty;
4488
4489 try
4490 {
4491 pageOffset = new Point(await WindowPageOffsetX(effectiveSessionId).ConfigureAwait(false),
4492 await WindowPageOffsetY(effectiveSessionId).ConfigureAwait(false));
4493 }
4494 catch (Exception)
4496 // a scroll that cannot be read is taken as none, which leaves the rect where the box model
4497 // put it rather than losing it
4498 }
4499
4500 try
4501 {
4502 var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, effectiveSessionId).ConfigureAwait(false);
4503 dynamic box = await SendCommand<dynamic>(DevToolsMethods.DOMGetBoxModel, new { nodeId }, effectiveSessionId).ConfigureAwait(false);
4504
4505 BoxModelResponse boxModel = ((JObject)box)?.ToObject<BoxModelResponse>();
4506
4507 if (boxModel?.Model != null)
4508 {
4509 // DOM.getBoxModel answers in document coordinates. this method is named for
4510 // getBoundingClientRect and the branch above it answers in viewport coordinates, so the
4511 // scroll of the same session comes off and both paths mean the same thing.
4512 // read outside the rect, because a scroll this cannot get is a rect adjusted by zero
4513 // rather than a rect nobody gets
4514 return new Rectangle(
4515 (int)boxModel.Model.Content[0] - pageOffset.X,
4516 (int)boxModel.Model.Content[1] - pageOffset.Y,
4517 (int)boxModel.Model.Width,
4518 (int)boxModel.Model.Height
4519 );
4520 }
4521 }
4522 catch (Exception ex)
4523 {
4524 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
4525 $"DOM.getBoxModel failed",
4526 this, GPALObjectType.PuppeteerCommunicator, ex);
4527 }
4528
4529 return new Rectangle(-1, -1, -1, -1);
4530 }
4531
4532 // Implementation for "get-content-and-css"
4539 public async Task<dynamic> GetContentAndCss(string elementId, string sessionId = null)
4540 {
4541 sessionId ??= GetEffectiveSessionId();
4542 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
4543 new {
4544 expression = $"let el = document.getElementById('{elementId}'); JSON.stringify({{content: el.innerHTML, css: getComputedStyle(el)}})",
4545 contextId = CurrentContextId,
4546 returnByValue = false
4547 }, sessionId).ConfigureAwait(false);
4548 }
4556 public async Task<Dictionary<string, object>> GetCssAttributes(int backendNodeId, string sessionId = null)
4557 {
4558 sessionId ??= GetEffectiveSessionId();
4559
4560 try
4561 {
4562 var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, sessionId).ConfigureAwait(false);
4563
4564 await SendCommand<object>(DevToolsMethods.DOMEnable, new { }, sessionId).ConfigureAwait(false);
4565 await SendCommand<object>(DevToolsMethods.CSSEnable, new { }, sessionId).ConfigureAwait(false);
4566
4567 var result = await SendCommand<dynamic>(DevToolsMethods.CSSGetComputedStyleForNode, new { nodeId }, sessionId).ConfigureAwait(false);
4568
4569 if (result?.computedStyle == null)
4570 {
4571 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CSS.getComputedStyleForNode returned null for nodeId [{nodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4572 return null;
4573 }
4574
4575 var relevantProps = new HashSet<string>(new[]
4576 {
4577 "display","visibility","opacity","pointer-events","overflow","clip-path",
4578 "position","top","left","right","bottom","z-index",
4579 "width","height","min-width","min-height","max-width","max-height",
4580 "margin","margin-top","margin-right","margin-bottom","margin-left",
4581 "padding","padding-top","padding-right","padding-bottom","padding-left",
4582 "border","border-width","border-style","border-color","box-sizing",
4583 "flex","flex-direction","flex-wrap","flex-grow","flex-shrink","flex-basis",
4584 "justify-content","align-items","align-self","order",
4585 "grid","grid-template","grid-area","grid-column","grid-row",
4586 "transform","transform-origin","transition","animation",
4587 "cursor","user-select","content","filter"
4588 });
4589
4590 var emptyValues = new HashSet<string> { "", "none", "normal", "inherit", "initial", "unset", "auto", "0px", "0", "transparent" };
4591
4592 var computedStyles = new Dictionary<string, object>();
4593 foreach (var style in result.computedStyle)
4594 {
4595 string name = style.name?.ToString();
4596 string value = style.value?.ToString()?.Trim();
4597
4598 if (name != null && relevantProps.Contains(name) && !string.IsNullOrEmpty(value) && !emptyValues.Contains(value))
4599 {
4600 computedStyles[name] = value;
4601 }
4602 }
4603
4604 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Retrieved [{computedStyles.Count}] filtered CSS attributes for nodeId [{nodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4605 return computedStyles;
4606 }
4607 catch (Exception ex)
4608 {
4609 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to get CSS attributes", this, GPALObjectType.PuppeteerCommunicator, ex);
4610 return null;
4611 }
4612 finally
4613 {
4614 await SendCommand<object>(DevToolsMethods.CSSDisable, new { }, sessionId).ConfigureAwait(false);
4615 await SendCommand<object>(DevToolsMethods.DOMDisable, new { }, sessionId).ConfigureAwait(false);
4616 }
4617 }
4618 //public async Task<Dictionary<string, object>> GetCssAttributes(int backendNodeId, string sessionId = null)
4619 //{
4620 // sessionId ??= GetEffectiveSessionId();
4621
4622 // try
4623 // {
4624 // var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, sessionId);
4626 // await SendCommand<object>(DevToolsMethods.DOMEnable, new { }, sessionId);
4627 // 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
4628
4629 // // Send CSS.getComputedStyleForNode
4630 // var result = await SendCommand<dynamic>(DevToolsMethods.CSSGetComputedStyleForNode, new
4631 // {
4632 // nodeId
4633 // }, sessionId);
4634
4635
4636 // if (result?.computedStyle == null)
4637 // {
4638 // GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CSS.getComputedStyleForNode returned null for nodeId [{nodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4639 // return null;
4640 // }
4641
4642 // // Process computedStyle array
4643 // var computedStyles = new Dictionary<string, object>();
4644 // foreach (var style in result.computedStyle)
4645 // computedStyles[style.name?.ToString()] = style.value?.ToString();
4646
4647 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Retrieved {computedStyles.Count} CSS attributes for nodeId [{nodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4648 // return computedStyles;
4649 // }
4650 // catch (Exception ex)
4651 // {
4652 // GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to get CSS attributes for backendNodeId [{backendNodeId}]: {ex.Message}", this, GPALObjectType.PuppeteerCommunicator, ex);
4653 // return null;
4654 // }
4655 // finally
4656 // {
4657 // await SendCommand<object>(DevToolsMethods.CSSDisable, new { }, sessionId);
4658 // await SendCommand<object>(DevToolsMethods.DOMDisable, new { }, sessionId);
4659 // }
4660 //}
4661 // this appears to be identical to the attributes we get from evaluateselector javascript
4667 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
4668 /// <returns>Task that resolves to a dictionary of attribute names and values, or null on failure.</returns>
4669 public async Task<Dictionary<string, object>> GetDomAttributes(int backendNodeId, string sessionId = null)
4670 {
4671 sessionId ??= GetEffectiveSessionId();
4672
4673 try
4674 {
4675 // Send DOM.getAttributes
4676 var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, sessionId).ConfigureAwait(false);
4677 var result = await SendCommand<object>(DevToolsMethods.DOMGetAttributes, new
4678 {
4679 nodeId
4680 }, sessionId).ConfigureAwait(false);
4681
4682 if (result?.attributes == null)
4683 {
4684 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"DOM.getAttributes returned null for nodeId [{nodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4685 return null;
4686 }
4687
4688 // Process attributes array (alternating name/value pairs)
4689 var attributes = new Dictionary<string, object>();
4690 for (int i = 0; i < result.attributes.Count - 1; i += 2)
4691 {
4692 attributes[result.attributes[i]?.ToString()] = result.attributes[i + 1]?.ToString();
4693 }
4694
4695 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Retrieved [{attributes.Count}] DOM attributes for nodeId [{nodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4696 return attributes;
4697 }
4698 catch (Exception ex)
4699 {
4700 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to get DOM attributes for nodeId [{backendNodeId}]", this, GPALObjectType.PuppeteerCommunicator, ex);
4701 return null;
4702 }
4703 }
4711 public async Task<Dictionary<string, object>> GetDomProperties(int backendNodeId, string sessionId = null)
4712 {
4713 sessionId ??= GetEffectiveSessionId();
4714
4715 try
4716 {
4717 // Resolve the node to a JS object so we can run the same curated script the template
4718 // (ElementHelper.GetDomProperties) and OttoMagic use - not an attribute/property dump.
4719 var resolve = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, new { backendNodeId, pierce = true }, sessionId).ConfigureAwait(false);
4720 string objectId = resolve.@object.objectId;
4721
4722 // Same key set and truthiness guards as ElementHelper.GetDomProperties
4723 var jsProps = await SendCommand<dynamic>(DevToolsMethods.RuntimeCallFunctionOn, new
4724 {
4725 objectId,
4726 functionDeclaration = @"
4727 function() {
4728 const props = {};
4729 if ('textContent' in this) props.textContent = this.textContent;
4730 if ('innerHTML' in this) props.innerHTML = this.innerHTML;
4731 if ('innerText' in this) props.innerText = this.innerText;
4732 if ('outerHTML' in this) props.outerHTML = this.outerHTML;
4733 if ('className' in this) props.className = this.className;
4734 if ('disabled' in this) props.disabled = this.disabled;
4735 return props;
4736 }",
4737 contextId = CurrentContextId,
4738 returnByValue = true
4739 }, sessionId).ConfigureAwait(false);
4740
4741 // Release objectId to prevent leak
4742 await SendCommand<dynamic>(DevToolsMethods.RuntimeReleaseObject,
4743 new {
4744 contextId = CurrentContextId,
4745 objectId
4746 }, sessionId).ConfigureAwait(false);
4747
4748 var properties = new Dictionary<string, object>();
4749 if (jsProps?.result?.value != null)
4750 properties = ((Newtonsoft.Json.Linq.JObject)jsProps.result.value).ToObject<Dictionary<string, object>>();
4751
4752 // Normalize null -> "" (consistent with ElementHelper.GetDomProperties)
4753 properties = properties.ToDictionary(kvp => kvp.Key, kvp => kvp.Value ?? "");
4754
4755 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Retrieved [{properties.Count}] DOM properties for backendNodeId [{backendNodeId}]", this, GPALObjectType.PuppeteerCommunicator);
4756 return properties;
4757 }
4758 catch (Exception ex)
4759 {
4760 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to get DOM properties for backendNodeId [{backendNodeId}]", this, GPALObjectType.PuppeteerCommunicator, ex);
4761 return null;
4762 }
4763 }
4764 // pure javascript, message is too large from browser and cdp / websocket hangs
4765 /*
4766 public async Task<dynamic> GetCssAttributes(string selector, string sessionId = null)
4767 {
4768 sessionId ??= GetEffectiveSessionId();
4769
4770 if (string.IsNullOrEmpty(selector))
4771 {
4772 return new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
4773 }
4774
4775 var jsExpression = $@"
4776 (function() {{
4777 try {{
4778 console.log('get css attrs');
4779 // Helper to find the first matching element in a context
4780 function findFirstElement(sel, context = document) {{
4781 // Try CSS selector
4782 try {{
4783 console.log('css');
4784 const element = context.querySelector(sel);
4785 if (element) {{
4786 console.log('found css');
4787 return element;
4788 }}
4789 }} catch (e) {{
4790 console.log('error ' + e);
4791 }}
4792
4793 // Try XPath
4794 try {{
4795 console.log('xpath');
4796 const result = document.evaluate(sel, context, null, 9, null);
4797 if (result.singleNodeValue) {{
4798 console.log('found xpath');
4799 return result.singleNodeValue;
4800 }}
4801 }} catch (e) {{
4802 console.log('error ' + e);
4803 }}
4804
4805 return null;
4806 }}
4807
4808 console.log('find first');
4809 // Search in main document
4810 let element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}');
4811
4812 // Search in shadow DOM if no element found
4813 if (!element) {{
4814 console.log('try shadowdom');
4815 const shadowHosts = document.querySelectorAll('*');
4816 var shadowCount = 0;
4817 for (const host of shadowHosts) {{
4818 if (host.shadowRoot) {{
4819 console.log('searching shadow root' + shadowCount++);
4820 element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}', host.shadowRoot);
4821 if (element) break;
4822 }}
4823 }}
4824 }}
4825
4826 // Search in iframes if no element found
4827 if (!element) {{
4828 console.log('try iframes');
4829 const iframes = document.querySelectorAll('iframe');
4830 var iframeCount = 0;
4831 for (const iframe of iframes) {{
4832 try {{
4833 if (iframe.contentDocument) {{
4834 console.log('searching iframe' + iframeCount++);
4835 element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}', iframe.contentDocument);
4836 if (element) break;
4837 }}
4838 }} catch (e) {{
4839 // Ignore iframe access errors (e.g., cross-origin)
4840 }}
4841 }}
4842 }}
4843
4844 // If no element found, return empty object
4845 if (!element) {{
4846 console.log('no element found');
4847 return {{}};
4848 }}
4849
4850 console.log('get computed style');
4851 // Get computed styles for the element
4852 const styles = window.getComputedStyle(element);
4853 const props = {{}};
4854 for (let i = 0; i < styles.length; i++) {{
4855 const prop = styles[i];
4856 const value = styles.getPropertyValue(prop);
4857 if (value && value !== 'none') {{
4858 props[prop] = value;
4859 }}
4860 }}
4861 console.log('return props ' + props);
4862 return props;
4863 }} catch (e) {{
4864 console.error('Error in GetCssAttributes:', e);
4865 return {{}};
4866 }}
4867 }})()";
4868
4869 var jsProps = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
4870 {
4871 expression = jsExpression,
4872 contextId = CurrentContextId,
4873 returnByValue = true
4874 }, sessionId);
4875
4876 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Got jsprops [{jsProps}]");
4877
4878 return jsProps?.result?.value != null
4879 ? ((Newtonsoft.Json.Linq.JObject)jsProps.result.value).ToObject<Dictionary<string, object>>()
4880 : new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
4881 }
4882 public async Task<dynamic> GetDomAttributes(string selector, string sessionId = null)
4883 {
4884 sessionId ??= GetEffectiveSessionId();
4885
4886 var jsExpression = $@"
4887 (function() {{
4888 try {{
4889 console.log('get dom attrs');
4890 // Helper to find the first matching element in a context
4891 function findFirstElement(sel, context = document) {{
4892 // Try CSS selector
4893 try {{
4894 const element = context.querySelector(sel);
4895 if (element) return element;
4896 }} catch (e) {{
4897 // Invalid CSS selector; try XPath
4898 }}
4899
4900 // Try XPath
4901 try {{
4902 const result = document.evaluate(sel, context, null, 9, null);
4903 if (result.singleNodeValue) return result.singleNodeValue;
4904 }} catch (e) {{
4905 // Invalid XPath; continue to shadow DOM/iframes
4906 }}
4907
4908 return null;
4909 }}
4910
4911 // Search in main document
4912 let element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}');
4913
4914 // Search in shadow DOM if no element found
4915 if (!element) {{
4916 const shadowHosts = document.querySelectorAll('*');
4917 for (const host of shadowHosts) {{
4918 if (host.shadowRoot) {{
4919 element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}', host.shadowRoot);
4920 if (element) break;
4921 }}
4922 }}
4923 }}
4924
4925 // Search in iframes if no element found
4926 if (!element) {{
4927 const iframes = document.querySelectorAll('iframe');
4928 for (const iframe of iframes) {{
4929 try {{
4930 if (iframe.contentDocument) {{
4931 element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}', iframe.contentDocument);
4932 if (element) break;
4933 }}
4934 }} catch (e) {{
4935 // Ignore iframe access errors (e.g., cross-origin)
4936 }}
4937 }}
4938 }}
4939
4940 // If no element found, return empty object
4941 if (!element) return {{}};
4942
4943 // Get DOM attributes for the element
4944 const attributes = element.attributes;
4945 const attrs = {{}};
4946 for (let i = 0; i < attributes.length; i++) {{
4947 const name = attributes[i].name;
4948 const value = attributes[i].value;
4949 if (name && value) {{
4950 attrs[name] = value;
4951 }}
4952 }}
4953 return attrs;
4954 }} catch (e) {{
4955 console.error('Error in GetDomAttributes:', e);
4956 return {{}};
4957 }}
4958 }})()";
4959
4960 var jsProps = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
4961 {
4962 expression = jsExpression,
4963 contextId = CurrentContextId,
4964 returnByValue = true
4965 }, sessionId);
4966
4967 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Got jsprops [{jsProps}]");
4968
4969 return jsProps?.result?.value != null
4970 ? ((Newtonsoft.Json.Linq.JObject)jsProps.result.value).ToObject<Dictionary<string, object>>()
4971 : new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
4972 }
4973 public async Task<dynamic> GetDomProperties(string selector, string sessionId = null)
4974 {
4975 sessionId ??= GetEffectiveSessionId();
4976 var jsExpression = $@"
4977 (function() {{
4978 try {{
4979 //console.log('get dom props');
4980 // Helper to find the first matching element in a context
4981 function findFirstElement(sel, context = document) {{
4982 // Try CSS selector
4983 try {{
4984 //console.log('css');
4985 const element = context.querySelector(sel);
4986 if (element) {{
4987 //console.log('found css');
4988 return element;
4989 }}
4990 }} catch (e) {{
4991 //console.log('error ' + e);
4992 }}
4993
4994 // Try XPath
4995 try {{
4996 //console.log('xpath');
4997 const result = document.evaluate(sel, context, null, 9, null);
4998 if (result.singleNodeValue) {{
4999 return result.singleNodeValue;
5000 }}
5001 }} catch (e) {{
5002 //console.log('error ' + e);
5003 }}
5004
5005 return null;
5006 }}
5007
5008 // Search in main document
5009 let element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}');
5010
5011 // Search in shadow DOM if no element found
5012 if (!element) {{
5013 const shadowHosts = document.querySelectorAll('*');
5014 let hostCount = 0;
5015 for (const host of shadowHosts) {{
5016 //console.log('searching shadow host ' + hostCount);
5017 hostCount++;
5018 if (host.shadowRoot) {{
5019 element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}', host.shadowRoot);
5020 if (element) {{
5021 break;
5022 }}
5023 }}
5024 }}
5025 }}
5026
5027 // Search in iframes if no element found
5028 if (!element) {{
5029 const iframes = document.querySelectorAll('iframe');
5030 let iframeCount = 0;
5031 for (const iframe of iframes) {{
5032 iframeCount++;
5033 try {{
5034 if (iframe.contentDocument) {{
5035 //console.log('searching iframe ' + iframeCount);
5036 element = findFirstElement('{selector.Replace("'", "\\'").Replace("\\", "\\\\")}', iframe.contentDocument);
5037 if (element) {{
5038 break;
5039 }}
5040 }}
5041 }} catch (e) {{
5042 //console.log('error ' + e);
5043 }}
5044 }}
5045 }}
5046
5047 // If no element found, return empty object
5048 if (!element) {{
5049 return {{}};
5050 }}
5052 // Get DOM properties for the element
5053 //console.log('get dom props 2');
5054 const props = {{}};
5055 if (element.tagName) props.tagName = element.tagName;
5056 if (element.textContent) props.textContent = element.textContent;
5057 if (element.value != null && element.value !== '') props.value = element.value;
5058 if (element.innerHTML) props.innerHTML = element.innerHTML;
5059 if (element.outerHTML) props.outerHTML = element.outerHTML;
5060 if (element.className) props.className = element.className;
5061 if (element.id) props.id = element.id;
5062 //console.log('return props');
5063 return props;
5064 }} catch (e) {{
5065 return {{}};
5066 }}
5067 }})()";
5068
5069 var jsProps = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
5070 {
5071 expression = jsExpression,
5072 contextId = CurrentContextId,
5073 returnByValue = true
5074 }, sessionId);
5075
5076 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Got jsprops [{jsProps}]");
5077
5078 return jsProps?.result?.value != null
5079 ? ((Newtonsoft.Json.Linq.JObject)jsProps.result.value).ToObject<Dictionary<string, object>>()
5080 : new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
5081 }
5082 */
5083
5084 // Implementation for "get-current-url"
5085
5092 /// string url = await communicator.GetCurrentUrl();
5093 /// </code>
5094 /// </example>
5095 public async Task<string> GetCurrentUrl(string sessionId = null)
5096 {
5097 sessionId ??= GetEffectiveSessionId();
5098
5099 // Evaluate 'window.location.toString()' to get the current URL
5100 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5101 new {
5102 expression = "window.location.href",
5103 contextId = CurrentContextId,
5104 returnByValue = true
5105 }, sessionId).ConfigureAwait(false);
5106
5107 // Extract and log the URL
5108 return result?.result?.value;
5109 }
5110
5111 // Implementation for "get-current-window"
5113
5117 public async Task<int> GetCurrentWindow(string sessionId = null)
5118 {
5119 sessionId ??= GetEffectiveSessionId();
5120
5121 var result = await SendCommand<dynamic>(
5122 DevToolsMethods.BrowserGetWindowForTarget,
5123 new { }, // no params needed
5124 sessionId
5125 ).ConfigureAwait(false);
5126
5127 return (int)result.windowId;
5128 }
5129
5132
5136 public async Task<System.Drawing.Rectangle> GetWindowRectangle(string sessionId = null)
5137 {
5138 sessionId ??= GetEffectiveSessionId();
5139
5140 int windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
5141
5142 dynamic result = await SendCommand<dynamic>(
5143 DevToolsMethods.BrowserGetWindowBounds,
5144 new { windowId },
5145 sessionId
5146 ).ConfigureAwait(false);
5147
5148 dynamic bounds = result?.bounds;
5149
5150 // A minimized window reports its restored bounds
5151 return new System.Drawing.Rectangle(
5152 (int)(bounds?.left ?? 0),
5153 (int)(bounds?.top ?? 0),
5154 (int)(bounds?.width ?? 0),
5155 (int)(bounds?.height ?? 0));
5156 }
5157
5158 // Implementation for "get-element-attribute-hash"
5165 public async Task<dynamic> GetElementAttributeHash(string elementId, string sessionId = null)
5166 {
5167 sessionId ??= GetEffectiveSessionId();
5168 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5169 new
5170 {
5171 expression = "JSON.stringify(document.getElementById('" + elementId + "').attributes)",
5172 contextId = CurrentContextId,
5173 returnByValue = false
5174 }, sessionId).ConfigureAwait(false);
5175 }
5176
5189 public async Task<string> GetPageSource(string sessionId = null)
5190 {
5191 if (sessionId == null)
5192 {
5193 sessionId = GetEffectiveSessionId();
5194 }
5195
5196 // ── 1. Get root document ─────────────────────────────────────────────
5197 var docResult = await SendCommand(
5198 DevToolsMethods.DOMGetDocument,
5199 new Dictionary<string, object> { { "depth", 0 } },
5200 sessionId).ConfigureAwait(false);
5201
5202 if (docResult == null)
5203 {
5204 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "DOM.getDocument returned null",
5205 this, GPALObjectType.PuppeteerCommunicator);
5206 return string.Empty;
5207 }
5208
5209 // Normalize to JObject
5210 JObject rootDoc = docResult as JObject;
5211 if (rootDoc == null)
5212 {
5213 try
5214 {
5215 string jsonStr = Newtonsoft.Json.JsonConvert.SerializeObject(docResult);
5216 rootDoc = JObject.Parse(jsonStr);
5217 }
5218 catch (Exception ex)
5219 {
5220 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
5221 "Failed to convert DOM.getDocument response to JObject: [" + ex.Message + "]",
5222 this, GPALObjectType.PuppeteerCommunicator);
5223 return string.Empty;
5224 }
5225 }
5226
5227 // Find root (direct or wrapped in "result")
5228 JToken rootToken = rootDoc["root"] ?? rootDoc["result"]?["root"];
5229
5230 if (rootToken == null || rootToken.Type != JTokenType.Object)
5231 {
5232 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
5233 "No valid 'root' object found in DOM.getDocument response",
5234 this, GPALObjectType.PuppeteerCommunicator);
5235 return string.Empty;
5236 }
5237
5238 JObject root = (JObject)rootToken;
5239
5240 if (!long.TryParse(root["nodeId"]?.ToString(), out long rootNodeId))
5241 {
5242 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Invalid or missing nodeId in root",
5243 this, GPALObjectType.PuppeteerCommunicator);
5244 return string.Empty;
5245 }
5246
5247 // ── 2. Get outerHTML ─────────────────────────────────────────────────
5248 var htmlResult = await SendCommand(
5249 DevToolsMethods.DOMGetOuterHTML,
5250 new Dictionary<string, object> { { "nodeId", rootNodeId } },
5251 sessionId).ConfigureAwait(false);
5252
5253 if (htmlResult == null)
5254 {
5255 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "DOM.getOuterHTML returned null",
5256 this, GPALObjectType.PuppeteerCommunicator);
5257 return string.Empty;
5259
5260 // Normalize htmlResult to JObject
5261 JObject htmlDoc = htmlResult as JObject;
5262 if (htmlDoc == null)
5263 {
5264 try
5265 {
5266 string jsonStr = Newtonsoft.Json.JsonConvert.SerializeObject(htmlResult);
5267 htmlDoc = JObject.Parse(jsonStr);
5268 }
5269 catch (Exception ex)
5270 {
5271 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
5272 "Failed to convert DOM.getOuterHTML response to JObject: [" + ex.Message + "]",
5273 this, GPALObjectType.PuppeteerCommunicator);
5274 return string.Empty;
5275 }
5277
5278 // Extract the actual source string
5279 string fullSource = htmlDoc["outerHTML"]?.ToString();
5280
5281 if (string.IsNullOrWhiteSpace(fullSource))
5282 {
5283 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "outerHTML was empty or missing",
5284 this, GPALObjectType.PuppeteerCommunicator);
5285 return string.Empty;
5286 }
5287
5288 // Return the raw page source. Normalization (JS-unescaping and stripping
5289 // Chrome's XML-viewer wrapper when a file is being displayed) is applied
5290 // uniformly for every engine at the choke point in Browser.GetPageSource,
5291 // so this path stays as "ignorant" as the OttoMagic and Selenium paths.
5292 return fullSource;
5293 }
5294
5295 // Implementation for "get-parent-node"
5302 public async Task<dynamic> GetParentNode(string elementId, string sessionId = null)
5303 {
5304 sessionId ??= GetEffectiveSessionId();
5305 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5306 new {
5307 contextId = CurrentContextId,
5308 expression = "document.getElementById('" + elementId + "').parentNode"
5309 }, sessionId).ConfigureAwait(false);
5310 }
5311
5312 // Implementation for "get-ready-status"
5316
5320 public async Task<string> GetReadyStatus(string sessionId, string sessionToken)
5321 {
5322 sessionId ??= GetEffectiveSessionId();
5323 string retVal = null;
5325 if (lastSessionToken != sessionToken)
5326 {
5327 lastErrorMessage.Clear();
5328 supressedMessage = false;
5329 lastSessionToken = sessionToken;
5330 }
5331
5332 JObject result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
5333 {
5334 expression = "document.readyState",
5335 contextId = CurrentContextId,
5336 returnByValue = false
5337 }, sessionId).ConfigureAwait(false);
5338
5339 if (null != result?["result"]?["value"])
5340 retVal = result?["result"]?["value"]?.ToString();
5341 else
5342 retVal = @"""loading"""; //
5343
5344 string currentErrorMessage = $"Document.Ready status [{retVal}]";
5345
5346 if (false == lastErrorMessage.Contains(currentErrorMessage))
5347 {
5348 lastErrorMessage.Add(currentErrorMessage);
5349 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage, null, GPALObjectType.None);
5350 supressedMessage = false;
5351 }
5352 else if (false == supressedMessage)
5353 {
5354 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
5355 supressedMessage = true;
5356 }
5357
5358 return retVal;
5359 }
5360
5361 // Implementation for "get-shadow-root"
5368 public async Task<dynamic> GetShadowRoot(string css, string sessionId = null)
5369 {
5370 sessionId ??= GetEffectiveSessionId();
5371 var nodeId = await QueryByCss(css, sessionId).ConfigureAwait(false);
5372 return await SendCommand<object>(DevToolsMethods.DOMDescribeNode, new { nodeId, pierce = true }, sessionId).ConfigureAwait(false);
5373 }
5374
5375 // Implementation for "get-storage"
5398 public async Task<string> GetStorage(
5399 string storageType,
5400 string sessionId,
5401 string domain = null,
5402 string storeName = null,
5403 string path = null,
5404 string key = null)
5405 {
5406 sessionId ??= GetEffectiveSessionId();
5407 string resultJson = "null";
5408
5409 try
5410 {
5411 switch (storageType)
5412 {
5413 case "cookie":
5414 {
5415 // get all cookies via CDP
5416 dynamic cookiesResp = await SendCommand<dynamic>(DevToolsMethods.StorageGetCookies,
5417 null,
5418 sessionId).ConfigureAwait(false);
5419
5420 if (cookiesResp?.cookies != null)
5421 {
5422 var filtered = new List<dynamic>();
5423 path ??= "/";
5424
5425 foreach (dynamic c in cookiesResp.cookies)
5426 {
5427 if ((false == UrlHelper.CookieDomainMatches(((dynamic)c).domain?.ToString(), domain)) ||
5428 (!string.IsNullOrEmpty(path) && ((dynamic)c).path != path) ||
5429 (!string.IsNullOrEmpty(key) && ((dynamic)c).name != key))
5430 continue;
5431
5432 // NOTE: this gets all cookies, not just a list of names given a wildcard (missing parm)
5433 // add partitionKey conditionally
5434 if (((dynamic)c).partitionKey != null)
5435 {
5436 filtered.Add(new
5437 {
5438 Key = ((dynamic)c).name,
5439 Domain = ((dynamic)c).domain,
5440 Path = ((dynamic)c).path,
5441 Value = ((dynamic)c).value,
5442 Expires = ((dynamic)c).expires,
5443
5444 Secure = ((dynamic)c).secure,
5445 HttpOnly = ((dynamic)c).httpOnly,
5446 SameSite = ((dynamic)c).sameSite,
5447 Session = ((dynamic)c).session,
5448 Priority = ((dynamic)c).priority,
5449 SameParty = ((dynamic)c).sameParty,
5450 SourceScheme = ((dynamic)c).sourceScheme,
5451 SourcePort = ((dynamic)c).sourcePort,
5452
5453 PartitionKey = ((dynamic)c).partitionKey
5454 });
5455 }
5456 else
5457 {
5458 filtered.Add(new
5459 {
5460 Key = ((dynamic)c).name,
5461 Domain = ((dynamic)c).domain,
5462 Path = ((dynamic)c).path,
5463 Value = ((dynamic)c).value,
5464 Expires = ((dynamic)c).expires,
5465
5466 Secure = ((dynamic)c).secure,
5467 HttpOnly = ((dynamic)c).httpOnly,
5468 SameSite = ((dynamic)c).sameSite,
5469 Session = ((dynamic)c).session,
5470 Priority = ((dynamic)c).priority,
5471 SameParty = ((dynamic)c).sameParty,
5472 SourceScheme = ((dynamic)c).sourceScheme,
5473 SourcePort = ((dynamic)c).sourcePort
5474 });
5475 }
5476 }
5477
5478 resultJson = JsonConvert.SerializeObject(filtered);
5479 }
5480 break;
5481 }
5482 case "localStorage":
5483 case "sessionStorage":
5484 {
5485 string js = string.IsNullOrEmpty(key)
5486 ? $@"(function(){{
5487 let obj={{}};
5488 for(let i=0;i<{storageType}.length;i++){{ let k={storageType}.key(i); obj[k]={storageType}.getItem(k); }}
5489 return JSON.stringify(obj);
5490 }})()"
5491 : $"{storageType}.getItem('{EscapeJsString(key)}');";
5492
5493 var evalResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
5494 {
5495 expression = js,
5496 awaitPromise = true,
5497 contextId = CurrentContextId,
5498 returnByValue = true
5499 }, sessionId).ConfigureAwait(false);
5500
5501 resultJson = evalResult?.result?.value?.ToString() ?? "null";
5502 break;
5503 }
5504 case "indexedDb":
5505 {
5506 string js;
5507
5508 if (string.IsNullOrEmpty(path))
5509 {
5510 // No database name > list all IndexedDB databases
5511 js = @"
5512 (async () => {
5513 let dbs = await indexedDB.databases();
5514 return JSON.stringify(dbs.map(db => ({ name: db.name, version: db.version })));
5515 })();";
5516 }
5517 else if (string.IsNullOrEmpty(storeName))
5518 {
5519 // Path (database name) given + no object store name > list all object stores in that DB
5520 js = $@"
5521 (async () => {{
5522 let req = indexedDB.open('{EscapeJsString(path)}');
5523 return new Promise((resolve) => {{
5524 req.onsuccess = e => {{
5525 let db = e.target.result;
5526 let stores = Array.from(db.objectStoreNames);
5527 db.close();
5528 resolve(JSON.stringify(stores));
5529 }};
5530 req.onerror = () => resolve(null);
5531 }});
5532 }})();";
5533 }
5534 else if (string.IsNullOrEmpty(key))
5535 {
5536 // Path + storeName given + no key > list all keys in that specific object store
5537 js = $@"
5538 (async () => {{
5539 let req = indexedDB.open('{EscapeJsString(path)}');
5540 return new Promise((resolve) => {{
5541 req.onsuccess = e => {{
5542 let db = e.target.result;
5543 let tx = db.transaction('{EscapeJsString(storeName)}', 'readonly');
5544 let store = tx.objectStore('{EscapeJsString(storeName)}');
5545 let keys = [];
5546 let cursorReq = store.openKeyCursor();
5547 cursorReq.onsuccess = function(event) {{
5548 let cursor = event.target.result;
5549 if (cursor) {{
5550 keys.push(cursor.key);
5551 cursor.continue();
5552 }} else {{
5553 db.close();
5554 resolve(JSON.stringify(keys));
5555 }}
5556 }};
5557 cursorReq.onerror = () => {{ db.close(); resolve(null); }};
5558 }};
5559 req.onerror = () => resolve(null);
5560 }});
5561 }})();";
5562 }
5563 else
5564 {
5565 // All three (path + storeName + key) > get the value for that key
5566 js = $@"
5567 (async () => {{
5568 let req = indexedDB.open('{EscapeJsString(path)}');
5569 return new Promise((resolve) => {{
5570 req.onsuccess = e => {{
5571 let db = e.target.result;
5572 let tx = db.transaction('{EscapeJsString(storeName)}', 'readonly');
5573 let store = tx.objectStore('{EscapeJsString(storeName)}');
5574 let getReq = store.get('{EscapeJsString(key)}');
5575 getReq.onsuccess = () => {{
5576 db.close();
5577 resolve(JSON.stringify(getReq.result));
5578 }};
5579 getReq.onerror = () => {{ db.close(); resolve(null); }};
5580 }};
5581 req.onerror = () => resolve(null);
5582 }});
5583 }})();";
5584 }
5585
5586 var evalDb = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
5587 {
5588 expression = js,
5589 awaitPromise = true,
5590 contextId = CurrentContextId,
5591 returnByValue = true
5592 }, sessionId).ConfigureAwait(false);
5593
5594 resultJson = evalDb?.result?.value?.ToString() ?? "null";
5595 break;
5596 }
5597
5598 case "cache":
5599 {
5600 string js;
5601
5602 if (string.IsNullOrEmpty(storeName))
5603 {
5604 // no key = enumerate alll cache names
5605 // key = return all with the key name
5606 js = string.IsNullOrEmpty(key)
5607 ? @"(async ()=>{let c=await caches.keys(); return JSON.stringify(c);})()"
5608 : $@"(async () => {{
5609 const response = await caches.match('{EscapeJsString(key)}');
5610 if (response) {{
5611 const data = await response.json();
5612 return JSON.stringify(data);
5613 }}
5614 return null;
5615 }})();";
5616 }
5617 else if (string.IsNullOrEmpty(key))
5618 {
5619 // Path (cache name) is defined + no key > list all keys (as URLs) inside that specific cache
5620 js = $@"(async () => {{
5621 const cache = await caches.open('{EscapeJsString(storeName)}');
5622 const keys = await cache.keys();
5623 const keyUrls = keys.map(k => k.url); // keys are Request objects; we return clean URLs
5624 return JSON.stringify(keyUrls);
5625 }})();";
5626 }
5627 else
5628 {
5629 // Both path and key are defined > open the specific cache and match the key inside it
5630 js = $@"(async () => {{
5631 const cache = await caches.open('{EscapeJsString(storeName)}');
5632 const response = await cache.match('{EscapeJsString(key)}');
5633 if (response) {{{{
5634 const contentType = response.headers.get('content-type') || '';
5635 let data;
5636 if (contentType.includes('application/json') || contentType.includes('text/json')) {{{{
5637 data = await response.json();
5638 }}}} else {{{{
5639 data = await response.text();
5640 }}}}
5641 return JSON.stringify(data);
5642 }}}}
5643 return null;
5644 }})();";
5645
5646 }
5647 var evalCache = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
5648 {
5649 expression = js,
5650 awaitPromise = true,
5651 contextId = CurrentContextId,
5652 returnByValue = true
5653 }, sessionId).ConfigureAwait(false);
5654
5655 resultJson = evalCache?.result?.value?.ToString() ?? "null";
5656 break;
5657 }
5658
5659 case "notSet":
5660 GPAL.PublishSimpleEvent(
5661 GPALEventType.WARNING,
5662 $"Nothing to do because StorageType is [{storageType}] d[{domain}] sn[{storeName}] p[{path}] k[{key}]",
5663 this,
5664 GPALObjectType.PuppeteerCommunicator);
5665 break;
5666
5667 default:
5668 GPAL.PublishSimpleEvent(
5669 GPALEventType.ERROR,
5670 $"Unsupported storage type: [{storageType}]",
5671 this,
5672 GPALObjectType.PuppeteerCommunicator);
5673 break;
5674 }
5675 }
5676 catch (Exception ex)
5677 {
5678 GPAL.PublishSimpleEvent(
5679 GPALEventType.EXCEPTION,
5680 $"GetStorage failed for [{storageType}] d[{domain}] sn[{storeName}] p[{path}] k[{key}]",
5681 this,
5682 GPALObjectType.PuppeteerCommunicator,
5683 ex);
5684 }
5685
5686 return resultJson;
5687 }
5688
5691
5694 public async Task<string> GetUserAgent(string sessionId = null)
5695 {
5696 sessionId ??= GetEffectiveSessionId();
5697 JObject result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5698 new
5700 expression = "navigator.userAgent",
5701 contextId = CurrentContextId,
5702 returnByValue = true
5703 }, sessionId).ConfigureAwait(false);
5704 return result?["result"]?["value"]?.ToString();
5705 }
5706
5707 // Implementation for "goto"
5711 /// <param name="url">The URL to navigate to.</param>
5712 /// <param name="sessionId">CDP session id; the effective session is used if null.</param>
5713 /// <returns>Task that resolves to true if navigation succeeded, otherwise false.</returns>
5714 /// <example>
5715 /// <code>
5716 /// await communicator.GoTo("https://example.com");
5717 /// </code>
5718 /// </example>
5719 public async Task<bool> GoTo(string url, string sessionId = null)
5720 {
5721 sessionId ??= GetEffectiveSessionId();
5722 return await NavigateToUrl(url, sessionId).ConfigureAwait(false);
5723 }
5724
5725 // Implementation for "goto-tab"
5731 /// <returns>Task that resolves to the targetId of the activated tab, or null if no matching tab was found.</returns>
5732 public async Task<string> GoToTab(object tabIdOrUrlOrIndex, string sessionId = null)
5733 {
5734 string targetId = null;
5735 var windowArray = _windowSessionsQueue.ToArray();
5736
5737 if (tabIdOrUrlOrIndex is int index)
5738 {
5739 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
5740 {
5741 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No active window for GoToTab", this, GPALObjectType.PuppeteerCommunicator);
5742 return null;
5743 }
5744
5745 var tabQueue = windowArray[_activeWindowIndex].TabQueue.ToArray();
5746 if (index < 0 || index >= tabQueue.Length)
5747 {
5748 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Invalid tab index [{index}]", this, GPALObjectType.PuppeteerCommunicator);
5749 return null;
5750 }
5751
5752 // Get targetId from index
5753 targetId = tabQueue[index].Key;
5754 if (string.IsNullOrEmpty(targetId))
5755 {
5756 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No targetId at index [{index}]", this, GPALObjectType.PuppeteerCommunicator);
5757 return null;
5759
5760 // Target.activateTarget is the tab switch itself, not just window ordering - cdp has no way to
5761 // make a tab current without focusing it, and focusing it raises the window. that raise is the
5762 // cost of switching tabs, not something GPAL chose
5763 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId }, null).ConfigureAwait(false);
5764 SetActiveTabIndex(index);
5765 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
5766
5767 return targetId;
5768 }
5769 else if (tabIdOrUrlOrIndex is string str)
5770 {
5771 targetId = await GetTargetTabIdByUrl(str, sessionId).ConfigureAwait(false);
5772 if (string.IsNullOrEmpty(targetId))
5773 {
5774 // Check if it's a direct targetId
5775 foreach (var window in windowArray)
5776 {
5777 var tabArray = window.TabQueue.ToArray();
5778 if (tabArray.Any(kvp => kvp.Key == str))
5779 {
5780 targetId = str;
5781 break;
5782 }
5783 }
5784 }
5785
5786 if (string.IsNullOrEmpty(targetId))
5787 {
5788 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No target found for tabId or URL [{str}]", this, GPALObjectType.PuppeteerCommunicator);
5789 return null;
5790 }
5791
5792 int windowIndex = GetCurrentWindowIndex(targetId);
5793 if (windowIndex < 0)
5794 {
5795 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Tab with targetId [{targetId}] not found in any window", this, GPALObjectType.PuppeteerCommunicator);
5796 return null;
5797 }
5798
5799 SetActiveWindowIndex(windowIndex);
5800 var tabQueue = windowArray[windowIndex].TabQueue.ToArray();
5801 int tabIndex = Array.FindIndex(tabQueue, kvp => kvp.Key == targetId);
5802 if (tabIndex < 0)
5803 {
5804 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Tab with targetId [{targetId}] not found in window [{windowArray[windowIndex].BrowserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
5805 return null;
5806 }
5807
5808 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId }, null).ConfigureAwait(false);
5809 SetActiveTabIndex(tabIndex);
5810 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
5811 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Switched to tab with targetId [{targetId}], windowIndex [{windowIndex}], tabIndex [{tabIndex}]", this, GPALObjectType.PuppeteerCommunicator);
5812 return targetId;
5813 }
5814
5815 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Invalid input for GoToTab [{tabIdOrUrlOrIndex}]", this, GPALObjectType.PuppeteerCommunicator);
5816 return null;
5817 }
5818
5819 // Implementation for "hide-element"
5826 public async Task<dynamic> HideElement(int backendNodeId, string sessionId = null)
5827 {
5828 return await SetAttribute(backendNodeId, "style", "display: none", sessionId).ConfigureAwait(false);
5829 }
5830
5831 // Implementation for "hover"
5835
5838 public async Task Hover(int backendNodeId, string sessionId = null)
5839 {
5840 sessionId ??= GetEffectiveSessionId();
5841 var x = await GetElementRandomCenterX(backendNodeId, sessionId).ConfigureAwait(false);
5842 var y = await GetElementRandomCenterY(backendNodeId, sessionId).ConfigureAwait(false);
5843 await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new { type = "mouseMoved", button = "none", x, y }, sessionId).ConfigureAwait(false);
5844 }
5845
5846 // Implementation for "is-clickable"
5854 /// <code>
5855 /// bool clickable = await communicator.IsClickable("submitButton");
5856 /// </code>
5857 /// </example>
5858 public async Task<bool> IsClickable(string elementId, string sessionId = null)
5859 {
5860 sessionId ??= GetEffectiveSessionId();
5861
5862 List<GPALElement> elems = await EvaluateSelector(elementId, sessionId).ConfigureAwait(false);
5863 List<dynamic> responses = new List<dynamic>();
5864
5865 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5866 new {
5867 contextId = CurrentContextId,
5868 expression = "document.getElementById('" + elementId + "').checkVisibility()"
5869 }, sessionId).ConfigureAwait(false);
5870 return (bool)result.value;
5871 }
5872
5873 // Implementation for "is-displayed"
5885 public async Task<bool> IsDisplayed(string elementId, string sessionId = null)
5886 {
5887 sessionId ??= GetEffectiveSessionId();
5888 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5889 new {
5890 contextId = CurrentContextId,
5891 expression = "document.getElementById('" + elementId + "').offsetParent !== null"
5892 }, sessionId).ConfigureAwait(false);
5893 return (bool)result.value;
5894 }
5895
5896 // Implementation for "is-enabled"
5897
5908 public async Task<bool> IsEnabled(string elementId, string sessionId = null)
5909 {
5910 sessionId ??= GetEffectiveSessionId();
5911 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
5912 new {
5913 contextId = CurrentContextId,
5914 expression = "!document.getElementById('" + elementId + "').disabled"
5915 }, sessionId).ConfigureAwait(false);
5916 return (bool)result.value;
5917 }
5918
5919 // Implementation for "is-end-of-page"
5926 public async Task<bool> IsEndOfPage(string sessionId = null)
5927 {
5928 sessionId ??= GetEffectiveSessionId();
5929 try
5930 {
5931 // JavaScript code to check if at end of page
5932 var result = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
5933 {
5934 expression = @"
5935 (function() {
5936 try {
5937 const overflow = getComputedStyle(document.documentElement).overflow;
5938 if (overflow !== 'hidden') {
5939 return (window.innerHeight + window.scrollY) >= document.body.scrollHeight;
5940 }
5941 const scrollable = Array.from(document.querySelectorAll('*')).find(
5942 el => el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
5943 ) || document.body;
5944 return (scrollable.clientHeight + scrollable.scrollTop) > scrollable.scrollHeight;
5945 } catch {
5946 return false;
5947 }
5948 })()",
5949 contextId = CurrentContextId,
5950 returnByValue = true
5951 }, sessionId).ConfigureAwait(false);
5952
5953 // Parse result
5954 return (bool)result?["result"]?["value"];
5955 }
5956 catch (Exception ex)
5958 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Error detecting end of page, returning [true]", this, GPALObjectType.PuppeteerCommunicator, ex);
5959 return true;
5960 }
5961 }
5962
5963 // Implementation for "is-visible-in-viewport"
5981 public async Task<bool> ElementFromPoint(GPALElement element, int x, int y, string sessionId = null)
5982 {
5983 sessionId ??= GetEffectiveSessionId();
5984
5985 // the node we already have, not a selector re-resolved inside the page. a css lookup here can find
5986 // a different element or none at all, and it runs in whatever context happens to be current, which is
5987 // two ways to answer this question about the wrong element
5988 var resolved = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, new
5989 {
5990 backendNodeId = element.ElementBackendNodeId
5991 }, sessionId).ConfigureAwait(false);
5992
5993 string objectId = resolved?.@object?.objectId;
5994
5995 if (true == string.IsNullOrEmpty(objectId))
5996 return false;
5997
5998 // "this" is the element, so the comparison cannot drift. a descendant counts, since clicking the text
5999 // inside a button is clicking the button.
6000 // it answers with what is there rather than just yes or no - "" means this element, anything else
6001 // names what is covering it, which is the only thing worth knowing when the answer is no
6002 var result = await SendCommand<dynamic>(DevToolsMethods.RuntimeCallFunctionOn, new
6003 {
6004 objectId = objectId,
6005 functionDeclaration = @"function (x, y) {
6006 const hit = document.elementFromPoint(x, y);
6007 if (hit === this || this.contains(hit)) { return ''; }
6008 if (!hit) { return 'nothing'; }
6009 return hit.tagName
6010 + (hit.id ? '#' + hit.id : '')
6011 + (hit.className ? '.' + String(hit.className).trim().split(/\s+/).join('.') : '');
6012 }",
6013 arguments = new object[] { new { value = x }, new { value = y } },
6014 returnByValue = true
6015 }, sessionId).ConfigureAwait(false);
6016
6017 string covering = result?.result?.value as string;
6018
6019 if (false == string.IsNullOrEmpty(covering))
6020 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{covering}] is what is at viewport [{x}, {y}], not [{element.TagName}]", null, GPALObjectType.Browser);
6021
6022 return true == string.IsNullOrEmpty(covering) && null != result?.result?.value;
6023 }
6024
6025 public async Task<bool> IsVisibleInViewport(dynamic elemsOrBackendNodeId, string sessionId = null)
6026 {
6027 sessionId ??= GetEffectiveSessionId();
6028 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
6029 {
6030 expression = @"({
6031 height: window.innerHeight || document.documentElement.clientHeight,
6032 width: window.innerWidth || document.documentElement.clientWidth
6033 })",
6034 contextId = CurrentContextId,
6035 returnByValue = true
6036 }, sessionId).ConfigureAwait(false);
6037
6038 int viewportHeight = 0;
6039 int viewportWidth = 0;
6040 bool isInViewport = true;
6041 if (result?.result?.value != null)
6042 {
6043 var dict = ((Newtonsoft.Json.Linq.JObject)result.result.value).ToObject<Dictionary<string, int>>();
6044 viewportHeight = dict["height"];
6045 viewportWidth = dict["width"];
6046 }
6047
6048 if (elemsOrBackendNodeId is List<GPALElement>)
6049 foreach (GPALElement element in elemsOrBackendNodeId)
6050 {
6051 Rectangle rect = await GetBoundingClientRect(element.ElementBackendNodeId, sessionId).ConfigureAwait(false);
6052 if (-1 == rect.X) // NOTE: CAVEAT: if we cannot compute the box model, the element is prolly not interactable/false positive, so fail-forward
6053 isInViewport = true;
6054 else
6055 isInViewport &= rect.Top >= 0 &&
6056 rect.Left >= 0 &&
6057 rect.Bottom <= viewportHeight &&
6058 rect.Right <= viewportWidth;
6059 }
6060 else
6061 {
6062 Rectangle rect = await GetBoundingClientRect(elemsOrBackendNodeId, sessionId).ConfigureAwait(false);
6063 if (-1 == rect.X) // NOTE: CAVEAT: if we cannot compute the box model, the element is prolly not interactable/false positive, so fail-forward
6064 isInViewport = true;
6065 else
6066 isInViewport &= rect.Top >= 0 &&
6067 rect.Left >= 0 &&
6068 rect.Bottom <= viewportHeight &&
6069 rect.Right <= viewportWidth;
6070 }
6071 return isInViewport;
6072 }
6073
6074 // Implementation for "left-click"
6084 public async Task ClickElement(GPALElement element, string sessionId, List<dynamic> responses,
6085 ClickType clickType = ClickType.LeftClick,
6086 int modifiers = 0)
6087 {
6088 bool cdpSuccess = false;
6089 bool elementNotRendered = false;
6090 bool forceJavscript = false;
6091 bool jsSuccess = false;
6092 bool isInFrame = CurrentContextId.HasValue;
6093 string effectiveSessionId = isInFrame && !string.IsNullOrEmpty(CurrentFrameSessionId)
6094 ? CurrentFrameSessionId
6095 : sessionId;
6096
6097 try
6098 {
6099 // NOTE: for headless which is not finding a box model on an A tag
6100 // Get fresh box model
6101 // Cheap reflow trigger via JS (very reliable)
6102 if (true == Browser.BrowserSettings.UseHeadless)
6103 {
6104 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
6105 {
6106 expression = "document.body.offsetHeight;", // reads force layout
6107 contextId = CurrentContextId,
6108 returnByValue = true
6109 }, sessionId).ConfigureAwait(false);
6110
6111 await Task.Delay(80).ConfigureAwait(false); // extra breathing room
6112 }
6113
6114 var boxModel = await SendCommand<object>(DevToolsMethods.DOMGetBoxModel, new
6115 {
6116 objectId = element.ElementHandle
6117 }, effectiveSessionId).ConfigureAwait(false);
6118
6119 if (null != boxModel)
6120 {
6121 var contentQuad = boxModel?.model?.content;
6122 if (contentQuad == null || contentQuad.Count < 8)
6123 {
6124 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{element.TagName}][{clickType}] failed: Unable to get coordinates (boxmodel)");
6125 }
6126 else
6127 {
6128 float x = (float)contentQuad[0];
6129 float y = (float)contentQuad[1];
6130 float width = (float)(contentQuad[2] - contentQuad[0]);
6131 float height = (float)(contentQuad[7] - contentQuad[1]);
6132
6133 var random = new Random();
6134 var margin = 0.05f;
6135 var clickAreaWidth = width * (1f - 2 * margin);
6136 var clickAreaHeight = height * (1f - 2 * margin);
6137
6138 x = x + width * margin + (float)random.NextDouble() * clickAreaWidth;
6139 y = y + height * margin + (float)random.NextDouble() * clickAreaHeight;
6140
6141 if (0 < x && 0 < y)
6142 {
6143 // Force center for <a> tags (your existing behavior)
6144 if ("A".Equals(element.TagName, StringComparison.OrdinalIgnoreCase))
6145 {
6146 x = (float)contentQuad[0] + width / 2f;
6147 y = (float)contentQuad[1] + height / 2f;
6148 }
6149
6150 // Map ClickType -> CDP values
6151 string button = clickType switch
6152 {
6153 ClickType.LeftClick => "left",
6154 ClickType.MiddleClick => "middle",
6155 ClickType.RightClick => "right",
6156 ClickType.LeftDoubleClick => "left",
6157 _ => "left"
6158 };
6159
6160 int clickCount = clickType == ClickType.LeftDoubleClick ? 2 : 1;
6161
6162 // PRESS
6163 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6164 {
6165 type = "mousePressed",
6166 button,
6167 buttons = PressedButtons(button),
6168 clickCount,
6169 x,
6170 y,
6171 modifiers
6172 }, effectiveSessionId).ConfigureAwait(false));
6173
6174 await Task.Delay(random.Next(60, 140)).ConfigureAwait(false); // human-like
6175
6176 // RELEASE
6177 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6178 {
6179 type = "mouseReleased",
6180 button,
6181 clickCount,
6182 x = x + random.Next(-2, 3),
6183 y = y + random.Next(-2, 3),
6184 modifiers
6185 }, effectiveSessionId).ConfigureAwait(false));
6186 cdpSuccess = true;
6187 }
6188 else
6189 {
6190 // the box model is viewport relative, so a negative coordinate means the element sits
6191 // outside the viewport and a dispatched mouse event there would land on nothing
6192 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);
6193 forceJavscript = true;
6194 }
6195 }
6196 }
6197 else
6198 {
6199 // no box model and a rect with no size is the same answer arrived at the other way
6200 elementNotRendered = 0 >= element.BoundingRect.Width || 0 >= element.BoundingRect.Height;
6201
6202 float x = (float)element.BoundingRect.X;
6203 float y = (float)element.BoundingRect.Y;
6204 float width = (float)element.BoundingRect.Width;
6205 float height = (float)element.BoundingRect.Height;
6206
6207 var random = new Random();
6208 var margin = 0.05f;
6209 var clickAreaWidth = width * (1f - 2 * margin);
6210 var clickAreaHeight = height * (1f - 2 * margin);
6211
6212 x = x + width * margin + (float)random.NextDouble() * clickAreaWidth;
6213 y = y + height * margin + (float)random.NextDouble() * clickAreaHeight;
6214
6215 if (0 < x && 0 < y)
6216 {
6217 // Force center for <a> tags (your existing behavior)
6218 if ("A".Equals(element.TagName, StringComparison.OrdinalIgnoreCase))
6219 {
6220 x = (float)element.BoundingRect.X + width / 2f;
6221 y = (float)element.BoundingRect.Y + height / 2f;
6222 }
6223
6224 // Map ClickType -> CDP values
6225 string button = clickType switch
6226 {
6227 ClickType.LeftClick => "left",
6228 ClickType.MiddleClick => "middle",
6229 ClickType.RightClick => "right",
6230 ClickType.LeftDoubleClick => "left",
6231 _ => "left"
6232 };
6233
6234 int clickCount = clickType == ClickType.LeftDoubleClick ? 2 : 1;
6235
6236 // PRESS
6237 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6238 {
6239 type = "mousePressed",
6240 button,
6241 buttons = PressedButtons(button),
6242 clickCount,
6243 x,
6244 y,
6245 modifiers
6246 }, effectiveSessionId).ConfigureAwait(false));
6247
6248 await Task.Delay(random.Next(60, 140)).ConfigureAwait(false); // human-like
6249
6250 // RELEASE
6251 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6252 {
6253 type = "mouseReleased",
6254 button,
6255 clickCount,
6256 x = x + random.Next(-2, 3),
6257 y = y + random.Next(-2, 3),
6258 modifiers
6259 }, effectiveSessionId).ConfigureAwait(false));
6260 cdpSuccess = true;
6261 }
6262 else if (true == elementNotRendered)
6263 {
6264 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"CDP [{clickType}] has no point to click for [{element.TagName}][{element.Css}]. Element is not rendered, so it has no position on the page. Falling back to JavaScript.", this, GPALObjectType.PuppeteerClient);
6265 forceJavscript = true;
6266 }
6267 else
6268 {
6269 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CDP [{clickType}] failed. Falling back to JavaScript.", this, GPALObjectType.PuppeteerClient);
6270 forceJavscript = true;
6271 }
6272 }
6273 }
6274 catch (Exception ex)
6275 {
6276 if (!GPAL.NoFallbackRecoveryActions)
6277 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"CDP [{clickType}] failed.{(false == GPAL.NoFallbackRecoveryActions ? " Falling back to JavaScript." : " Javascript fallback disallowed.")}", this, GPALObjectType.PuppeteerClient, ex);
6278 }
6279
6280 // Optional JS fallback (keep your existing one, or remove if CDP never fails anymore)
6281 if (false == GPAL.NoFallbackRecoveryActions && (false == cdpSuccess || true == forceJavscript))
6282 {
6283 bool byCss = false == string.IsNullOrEmpty(element.Css);
6284 string selector = true == byCss ? element.Css : element.Xpath;
6285
6286 if (string.IsNullOrEmpty(selector))
6287 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Fallback JavaScript failed: No valid XPath or CSS selector for [{element.TagName}]", this, GPALObjectType.PuppeteerClient);
6288
6289 var clickScript = $@"
6290 (() => {{
6291 try {{
6292 const sel = `{selector.Replace("'", "\\'")}`;
6293
6294 // looked up as the kind of selector that was actually passed. handing a css selector to
6295 // document.evaluate throws, and the throw skipped the lookup that would have worked
6296 const el = {(true == byCss ? "document.querySelector(sel)" : "document.evaluate(sel, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue")};
6297 if (!el) return false;
6299 // getBoundingClientRect is already viewport relative, which is what clientX and
6300 // clientY are defined as. adding the scroll offset turns them into document
6301 // coordinates and any handler that hit tests the event reads the wrong point
6302 const rect = el.getBoundingClientRect();
6303 const cx = rect.left + rect.width / 2;
6304 const cy = rect.top + rect.height / 2;
6305
6306 const opts = {{ bubbles: true, cancelable: true, clientX: cx, clientY: cy, view: window }};
6307
6308 // === LEFT CLICK (or double) ===
6309 if ({(clickType == ClickType.LeftClick || clickType == ClickType.LeftDoubleClick).ToString().ToLower()}) {{
6310 const count = {(clickType == ClickType.LeftDoubleClick ? 2 : 1)};
6311 for (let i = 0; i < count; i++) {{
6312 el.dispatchEvent(new MouseEvent('mousedown', {{...opts, button: 0, buttons: 1}}));
6313 el.dispatchEvent(new MouseEvent('mouseup', {{...opts, button: 0, buttons: 0}}));
6314 el.dispatchEvent(new MouseEvent('click', {{...opts, button: 0, detail: i+1}}));
6315 if (i === 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 80); // ~80ms between clicks
6316 }}
6317 return true;
6318 }}
6320 // === MIDDLE CLICK (eBay pop-under) ===
6321 if ({(clickType == ClickType.MiddleClick).ToString().ToLower()}) {{
6322 el.dispatchEvent(new MouseEvent('mousedown', {{...opts, button: 1, buttons: 4}}));
6323 el.dispatchEvent(new MouseEvent('mouseup', {{...opts, button: 1, buttons: 0}}));
6324 el.dispatchEvent(new MouseEvent('click', {{...opts, button: 1}}));
6325 return true;
6326 }}
6327
6328 // === RIGHT CLICK (context menu) ===
6329 if ({(clickType == ClickType.RightClick).ToString().ToLower()}) {{
6330 el.dispatchEvent(new MouseEvent('mousedown', {{...opts, button: 2, buttons: 2}}));
6331 el.dispatchEvent(new MouseEvent('mouseup', {{...opts, button: 2, buttons: 0}}));
6332 el.dispatchEvent(new MouseEvent('contextmenu', {{...opts, button: 2}}));
6333 return true;
6334 }}
6335
6336 return false;
6337 }} catch(e) {{
6338 return false;
6339 }}
6340 }})();
6341 ";
6342
6343 if (false == string.IsNullOrEmpty(selector))
6344 {
6345 dynamic jsResult = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
6346 {
6347 expression = clickScript,
6348 objectGroup = "node",
6349 contextId = CurrentContextId,
6350 returnByValue = true
6351 }, sessionId).ConfigureAwait(false);
6352
6353 responses.Add(jsResult);
6354
6355 // the script returns false when it cannot find the element or the click type has no branch
6356 if (jsResult?.result?.value is bool clicked && clicked)
6357 jsSuccess = true;
6358 }
6359 }
6360
6361 // name the path that actually dispatched. a click that reached neither is the one that leaves the page
6362 // unchanged and scrapes the same results twice, so it cannot read the same as a click that landed
6363 string clickPath = true == cdpSuccess ? "CDP" : true == jsSuccess ? "JavaScript" : "nothing";
6364
6365 GPAL.PublishSimpleEvent(true == cdpSuccess || true == jsSuccess ? GPALEventType.INFO : GPALEventType.WARNING,
6366 $"Puppeteer [{clickType}] [{element.TagName}][{Browser.CurrentUOW.CurrentSelector.Name}] w/modifiers [{((PuppeteerClient)PuppeteerClient).GetModifiersString(modifiers)}] dispatched by [{clickPath}]",
6367 this, GPALObjectType.PuppeteerCommunicator);
6368 }
6377 /// <param name="deltaX">Horizontal drag distance in pixels from the element's center.</param>
6378 /// <param name="deltaY">Vertical drag distance in pixels from the element's center.</param>
6379 public async Task DragAndDrop(GPALElement element, string sessionId, List<dynamic> responses, int deltaX, int deltaY, int offsetX = 0, int offsetY = 0)
6380 {
6381 var startX = (float)element.BoundingRect.X + (float)element.BoundingRect.Width / 2 + offsetX;
6382 var startY = (float)element.BoundingRect.Y + (float)element.BoundingRect.Height / 2 + offsetY;
6383 var endX = startX + deltaX;
6384 var endY = startY + deltaY;
6385
6386 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6387 {
6388 type = "mousePressed",
6389 button = "left",
6390 clickCount = 1,
6391 x = startX,
6392 y = startY,
6393 modifiers = 0
6394 }, sessionId).ConfigureAwait(false));
6395
6396 const int STEPS = 10;
6397 for (int i = 1; i <= STEPS; i++)
6398 {
6399 var x = startX + (endX - startX) * i / STEPS;
6400 var y = startY + (endY - startY) * i / STEPS;
6401
6402 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6403 {
6404 type = "mouseMoved",
6405 button = "left",
6406 buttons = 1,
6407 x,
6408 y,
6409 modifiers = 0
6410 }, sessionId).ConfigureAwait(false));
6411
6412 await Task.Delay(15).ConfigureAwait(false);
6413 }
6414
6415 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6416 {
6417 type = "mouseReleased",
6418 button = "left",
6419 clickCount = 1,
6420 x = endX,
6421 y = endY,
6422 modifiers = 0
6423 }, sessionId).ConfigureAwait(false));
6424 }
6438 public async Task LeftClick(string elementId, string sessionId = null)
6439 {
6440 sessionId ??= GetEffectiveSessionId();
6441 List<GPALElement> elems = await EvaluateSelector(elementId, sessionId).ConfigureAwait(false);
6442 List<dynamic> responses = new List<dynamic>();
6443
6444 foreach (GPALElement elem in elems)
6445 {
6446 await ScrollIntoView(elem, sessionId).ConfigureAwait(false);
6447 await ClickElement(elem, sessionId, responses, ClickType.LeftClick).ConfigureAwait(false);
6448 }
6449 }
6450
6462 public async Task SetDownloadBehavior(string downloadPath, string sessionId = null)
6463 {
6464 sessionId ??= GetEffectiveSessionId();
6465
6466 await SendCommand<object>(DevToolsMethods.BrowserSetDownloadBehavior, new { behavior = "allow", downloadPath = downloadPath }, sessionId).ConfigureAwait(false);
6467 }
6468
6475 /// <param name="sessionId">CDP session ID; falls back to the current effective session if null</param>
6476 /// <returns>Task representing the asynchronous call</returns>
6477 public async Task RestoreDownloadBehavior(string sessionId = null)
6478 {
6479 sessionId ??= GetEffectiveSessionId();
6480
6481 await SendCommand<object>(DevToolsMethods.BrowserSetDownloadBehavior, new { behavior = "default" }, sessionId).ConfigureAwait(false);
6482 }
6483
6484 // Implementation for "left-click-download"
6485 // NOTE: HEADLESS ONLY
6487
6496 /// <param name="responses">List collecting the raw CDP responses from each click.</param>
6497 /// <returns>Task representing the asynchronous click-and-download operation.</returns>
6498 public async Task LeftClickAndDownload(List<GPALElement> elems, string downloadPath, int modifiers, string sessionId, List<dynamic> responses)
6499 {
6500 sessionId ??= GetEffectiveSessionId();
6501
6502 string saveDirectory = Path.GetDirectoryName(downloadPath);
6503
6504 // NOTE: we can set the download directory, but not the filename, so we still require the downloadwatcher
6505 await SetDownloadBehavior(saveDirectory, sessionId).ConfigureAwait(false);
6506
6507 foreach (GPALElement elem in elems)
6508 {
6509 await ScrollIntoView(elem, sessionId).ConfigureAwait(false);
6510 await ClickElement(elem, sessionId, responses, ClickType.LeftClick, modifiers).ConfigureAwait(false);
6511 }
6512 }
6513 // Implementation for "left-click-and-upload"
6514 // NOTE: HEADLESS ONLY
6515 // NOTE: CAV#EAT: in order to call leftclickandupload repeatedly, we must track the files ourselves and clear the list...
6516 private static readonly Dictionary<string, List<string>> _attachedFilesPerSession = new Dictionary<string, List<string>>();
6517
6519 /// Attaches one or more files to file input element(s) using CDP.
6520 /// Supports single file path (string) or multiple paths (List&lt;string&gt; / IEnumerable&lt;string&gt;).
6521 /// Accumulates files across calls by tracking attached paths per session/input and re-attaching the full list each time.
6522 /// Uses DOM.setFileInputFiles to set files directly (headless-friendly, no dialog).
6523 /// Dispatches change event after attachment to trigger page/widget processing.
6524 /// </summary>
6525 /// <param name="elems">List of GPALElement instances representing the file input(s). Usually one, but supports multiple.</param>
6526 /// <param name="uploadFiles">
6527 /// File path(s) to attach.
6528 /// - string: single file path (e.g. @"C:\temp\report.pdf")
6529 /// - IEnumerable&lt;string&gt; or List&lt;string&gt;: multiple file paths (e.g. new[] { @"C:\file1.pdf", @"C:\file2.jpg" })
6530 /// </param>
6531 /// <param name="modifiers">Keyboard modifiers (not used in current implementation)</param>
6532 /// <param name="sessionId">CDP session ID (optional; falls back to current)</param>
6533 /// <param name="responses">Optional list to collect status/info (e.g. for logging or fluent chaining)</param>
6534 /// <returns>Task (async operation)</returns>
6535 /// <example>
6536 /// Single file:
6537 /// <code>
6538 /// await LeftClickAndUpload(elems, @"C:\temp\doc.pdf", 0, sessionId, responses);
6539 /// </code>
6540 ///
6541 /// Multiple files (additive across calls):
6542 /// <code>
6543 /// var files = new List&lt;string&gt; { @"C:\doc1.pdf" };
6544 /// await LeftClickAndUpload(elems, files, 0, sessionId, responses);
6545 ///
6546 /// files.Add(@"C:\photo.jpg");
6547 /// await LeftClickAndUpload(elems, files, 0, sessionId, responses); // now attaches both
6548 /// </code>
6549 ///
6550 /// Mixed:
6551 /// <code>
6552 /// await LeftClickAndUpload(elems, @"C:\invoice.docx", 0, sessionId, responses); // single
6553 /// await LeftClickAndUpload(elems, new[] { @"C:\old1.pdf", @"C:\old2.pdf" }, 0, sessionId, responses); // full list
6554 /// </code>
6555 /// </example>
6556 public async Task LeftClickAndUpload(
6557 List<GPALElement> elems,
6558 object uploadFiles,
6559 int modifiers,
6560 string sessionId,
6561 List<dynamic> responses)
6562 {
6563 sessionId ??= GetEffectiveSessionId();
6564
6565 // Normalize uploadFiles to List<string>
6566 var newPaths = new List<string>();
6567
6568 if (uploadFiles is string singlePath)
6569 {
6570 if ("" != singlePath?.Trim())
6571 {
6572 newPaths.Add(Path.GetFullPath(singlePath));
6573 }
6574 }
6575 else if (uploadFiles is GPALFile pathEnumerable)
6576 {
6577 foreach (var p in pathEnumerable.Filenames)
6578 {
6579 if ("" != p?.Trim())
6580 {
6581 newPaths.Add(Path.GetFullPath(p));
6582 }
6583 }
6584 }
6585 else
6586 {
6587 // Invalid type – silent skip (fluent style)
6588 return;
6590
6591 if (0 == newPaths.Count)
6592 {
6593 return; // nothing to attach
6594 }
6595
6596 foreach (GPALElement fileInput in elems)
6597 {
6598 string nodeKey = $"{sessionId}:{fileInput.ElementBackendNodeId}";
6599
6600 // Get browser-reported file count via attribute
6601 string fileCountStr = fileInput.GetAttribute("length");
6602 int browserFileCount = 0;
6603
6604 if ("" != fileCountStr)
6605 {
6606 Int32.TryParse(fileCountStr, out browserFileCount);
6607 }
6608
6609 // Get or create tracked list
6610 if (false == _attachedFilesPerSession.TryGetValue(nodeKey, out var currentFiles))
6611 {
6612 currentFiles = new List<string>();
6613 _attachedFilesPerSession[nodeKey] = currentFiles;
6614 }
6615
6616 // Staleness check: browser count != tracked count > reset
6617 if (browserFileCount != currentFiles.Count)
6618 {
6619 currentFiles.Clear();
6620 _attachedFilesPerSession[nodeKey] = currentFiles;
6621 }
6622
6623 // Add new paths
6624 currentFiles.AddRange(newPaths);
6625
6626 var setFilesParams = new
6627 {
6628 nodeId = fileInput.ElementNodeId,
6629 files = currentFiles.ToArray()
6630 };
6631
6632 await SendCommand<object>(DevToolsMethods.DOMSetFileInputFiles, setFilesParams, sessionId).ConfigureAwait(false);
6633 await FireChangeEvent(fileInput.ElementHandle, sessionId).ConfigureAwait(false);
6634
6635 responses?.Add(new
6636 {
6637 status = "upload_attached",
6638 pathsAdded = newPaths,
6639 totalFilesTracked = currentFiles.Count,
6640 browserReportedCount = browserFileCount
6641 });
6642 }
6643 }
6644
6645 // Implementation for "left-double-click"
6654 public async Task LeftDoubleClick(string elementId, string sessionId = null)
6655 {
6656 sessionId ??= GetEffectiveSessionId();
6657 List<GPALElement> elems = await EvaluateSelector(elementId, sessionId).ConfigureAwait(false);
6658 List<dynamic> responses = new List<dynamic>();
6659
6660 foreach (GPALElement elem in elems)
6661 {
6662 await ScrollIntoView(elem, sessionId).ConfigureAwait(false);
6663 await ClickElement(elem, sessionId, responses, ClickType.LeftDoubleClick).ConfigureAwait(false);
6664 }
6665 }
6666
6667
6668 // Implementation for "maximize"
6675 public async Task<bool> Maximize(string sessionId = null)
6676 {
6677 sessionId ??= GetEffectiveSessionId();
6678
6679 var windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
6680
6681 // window state, not bounds - chromium reads a bounds set as a normal window at that size
6682 await SendCommand(DevToolsMethods.BrowserSetWindowBounds, new
6683 {
6684 windowId,
6685 bounds = new { windowState = "maximized" }
6686 }, sessionId).ConfigureAwait(false);
6687
6688 return true;
6689 }
6690
6691 // Implementation for "minimize"
6698 public async Task<bool> Minimize(string sessionId = null)
6699 {
6700 sessionId ??= GetEffectiveSessionId();
6701
6702 var windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
6703 await SendCommand(DevToolsMethods.BrowserSetWindowBounds, new
6704 {
6705 windowId = windowId,
6706 bounds = new { windowState = "minimized" }
6707 }, sessionId).ConfigureAwait(false);
6708
6709 return true;
6710 }
6720 public async Task<bool> MoveTo(List<GPALElement> elems, string sessionId, List<dynamic> responses)
6721 {
6722 bool retVal = true;
6723
6724 foreach (GPALElement element in elems)
6725 {
6726 try
6727 {
6728 await ScrollIntoView(element, sessionId).ConfigureAwait(false);
6729
6730 // Randomize move within 90% of the element's border
6731 var random = new Random();
6732 var margin = 0.05f; // 5% margin on each side (90% clickable area)
6733 var clickAreaWidth = (float)element.BoundingRect.Width * (1f - 2 * margin);
6734 var clickAreaHeight = (float)element.BoundingRect.Height * (1f - 2 * margin);
6735 var x = (float)element.BoundingRect.X + (float)element.BoundingRect.Width * margin + (float)random.NextDouble() * clickAreaWidth;
6736 var y = (float)element.BoundingRect.Y + (float)element.BoundingRect.Height * margin + (float)random.NextDouble() * clickAreaHeight;
6737
6738 if (true == "A".Equals(element.TagName)) // click in dead center, don't miss it, altho width could be a bit more tolerant
6739 {
6740 x = (float)element.BoundingRect.X + (float)element.BoundingRect.Width / 2;
6741 y = (float)element.BoundingRect.Y + (float)element.BoundingRect.Height / 2;
6742 }
6743
6744 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6745 {
6746 type = "mouseMoved",
6747 x,
6748 y,
6749 modifiers = 0
6750 }, sessionId).ConfigureAwait(false));
6751 }
6752 catch (Exception ex)
6753 {
6754 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Error moving to element [{element.ElementHandle}][{element.Css}]", this, GPALObjectType.Puppeteer, ex);
6755 retVal = false;
6756 }
6757 }
6758 return retVal;
6759 }
6760 // Implementation for "move-to"
6768 public async Task MoveTo(int backendNodeId, string sessionId = null)
6769 {
6770 sessionId ??= GetEffectiveSessionId();
6771 var x = await GetElementRandomCenterX(backendNodeId, sessionId).ConfigureAwait(false);
6772 var y = await GetElementRandomCenterY(backendNodeId, sessionId).ConfigureAwait(false);
6773 await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new { type = "mouseMoved", x, y }, sessionId).ConfigureAwait(false);
6774 }
6775
6776 // Implementation for "click-point"
6795 private static int PressedButtons(string button)
6796 {
6797 int retVal = "middle" == button ? 4 : "right" == button ? 2 : 1;
6799 return retVal;
6800 }
6801
6802 public async Task ClickElement(int x, int y, string sessionId, List<dynamic> responses,
6803 ClickType clickType = ClickType.LeftClick,
6804 int modifiers = 0)
6805 {
6806 sessionId ??= GetEffectiveSessionId();
6807
6808 string button = clickType switch
6809 {
6810 ClickType.LeftClick => "left",
6811 ClickType.MiddleClick => "middle",
6812 ClickType.RightClick => "right",
6813 ClickType.LeftDoubleClick => "left",
6814 _ => "left"
6815 };
6816
6817 int clickCount = ClickType.LeftDoubleClick == clickType ? 2 : 1;
6818 var random = new Random();
6819
6820 // land the pointer first so :hover resolves before the press, same as a real mouse would
6821 await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6822 {
6823 type = "mouseMoved",
6824 button = "none",
6825 x,
6826 y
6827 }, sessionId).ConfigureAwait(false);
6828
6829 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6830 {
6831 type = "mousePressed",
6832 button,
6833 buttons = PressedButtons(button),
6834 clickCount,
6835 x,
6836 y,
6837 modifiers
6838 }, sessionId).ConfigureAwait(false));
6839
6840 await Task.Delay(random.Next(60, 140)).ConfigureAwait(false); // human-like
6841
6842 responses.Add(await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new
6843 {
6844 type = "mouseReleased",
6845 button,
6846 clickCount,
6847 x,
6848 y,
6849 modifiers
6850 }, sessionId).ConfigureAwait(false));
6851 }
6852
6853 // Implementation for "move-to-point"
6863 public async Task MoveTo(int x, int y, string sessionId = null)
6864 {
6865 sessionId ??= GetEffectiveSessionId();
6866 await SendCommand<object>(DevToolsMethods.InputDispatchMouseEvent, new { type = "mouseMoved", button = "none", x, y }, sessionId).ConfigureAwait(false);
6867 }
6868
6869 // Implementation for "new-tab"
6872
6884 public async Task<string> NewTab(GPALUrl url = null, string sessionId = null)
6885 {
6886 if (null == url)
6887 url = new GPALUrl();
6888
6889 sessionId ??= GetEffectiveSessionId();
6890 url.ForUrl(MagicHelper.GetFullUrl(url.Url, Browser, out Browser._areRobotsAllowed));
6891
6892 if (false == Browser.AreRobotsAllowed && true == Browser.BrowserSettings.ObeyRobotsTxt)
6893 {
6894 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);
6895 url.ForUrl("https://google.com");
6896 }
6897
6898 var browserContextId = GetActiveWindowId();
6899 if (string.IsNullOrEmpty(browserContextId))
6900 {
6901 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
6902 "NewTab: No active window found to attach tab",
6903 this, GPALObjectType.PuppeteerCommunicator);
6904 return null;
6905 }
6906
6907 // Create new target in the current active window (browserContextId).
6908 // not background: NewTab means the workflow moves to the tab, the same as it does on selenium and
6909 // ottomagic. a background tab left the browser showing the old page while GPAL drove the new one
6910 var result = await SendCommand<object>(DevToolsMethods.TargetCreateTarget,
6911 new { url = url.Url, browserContextId }, null).ConfigureAwait(false);
6912
6913 string targetId = result.targetId.ToString();
6914
6915 // brought to the front, then attached, so the session every later command goes through belongs to
6916 // the tab the workflow is now looking at
6917 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId }, null).ConfigureAwait(false);
6918
6919 var attached = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget,
6920 new { targetId, flatten = true }, null).ConfigureAwait(false);
6921
6922 string tabSessionId = attached?.sessionId?.ToString();
6923
6924 if (true == string.IsNullOrEmpty(tabSessionId))
6925 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Opened tab [{targetId}] but could not attach to it, so commands still go to the tab before it", this, GPALObjectType.PuppeteerCommunicator);
6926
6927 // Handle attachment and queuing
6928 await HandleNewTarget(targetId, browserContextId, tabSessionId, false, true).ConfigureAwait(false);
6929
6930 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
6931 $"Opened new tab TargetId [{targetId}] in Window [{browserContextId}]",
6932 this, GPALObjectType.PuppeteerCommunicator);
6933
6934 return targetId;
6935 }
6936
6937 // helper for next/previoustab
6938 // Implementation for getCurrentTabIndex
6946 internal int GetCurrentTabIndex(string currentTargetId)
6947 {
6948 var windowArray = _windowSessionsQueue.ToArray();
6949 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
6950 {
6951 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No active window for NextTab", this, GPALObjectType.PuppeteerCommunicator);
6952 return 0;
6953 }
6954
6955 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
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 0;
6961 }
6962
6963 var currentIndex = Array.FindIndex(queueArray, kvp => kvp.Key == currentTargetId);
6964 if (currentIndex == -1)
6965 currentIndex = windowArray[_activeWindowIndex].ActiveTabIndex;
6966
6967 return currentIndex;
6968 }
6969 // Implementation for "next-tab"
6973
6977 public async Task<string> NextTab(string currentTargetId)
6978 {
6979 var windowArray = _windowSessionsQueue.ToArray();
6980 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
6981 {
6982 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No active window for NextTab", this, GPALObjectType.PuppeteerCommunicator);
6983 return null;
6984 }
6985
6986 var session = windowArray[_activeWindowIndex];
6987 var tabQueue = session.TabQueue;
6988 var browserContextId = session.BrowserContextId; // Use stored BrowserContextId
6989
6990 // Poll all targets via CDP to discover unknown pages
6991 var targetsResponse = await SendCommand<dynamic>(
6992 DevToolsMethods.TargetGetTargets,
6993 new { },
6994 null // Browser-level command, no sessionId
6995 ).ConfigureAwait(false);
6996
6997 if (targetsResponse?.targetInfos == null)
6998 {
6999 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Failed to get targets list in NextTab sync", this, GPALObjectType.PuppeteerCommunicator);
7000 // Proceed anyway with existing queue
7001 }
7002 else
7004 var knownTargetIds = new HashSet<string>(tabQueue.Select(kv => kv.Key));
7005
7006 foreach (var targetInfo in targetsResponse.targetInfos)
7007 {
7008 string targetId = targetInfo.targetId?.ToString();
7009 string type = targetInfo.type?.ToString();
7010 string contextId = targetInfo.browserContextId?.ToString();
7011
7012 if (string.IsNullOrEmpty(targetId) || type != "page" || contextId != browserContextId)
7013 continue;
7014
7015 if (!knownTargetIds.Contains(targetId))
7016 {
7017 // Add with doNotGetSendSemaphore=true to avoid reentrancy/locking issues
7018 await AddTabToQueue(targetId, null, false, true).ConfigureAwait(false);
7019 knownTargetIds.Add(targetId);
7020 }
7021 }
7022 }
7023
7024 var queueArray = tabQueue.ToArray();
7025 if (queueArray.Length == 0)
7026 {
7027 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No tabs for NextTab", this, GPALObjectType.PuppeteerCommunicator);
7028 return null;
7029 }
7030
7031 // find where we are by target id, the way PreviousTab already does. The stored ActiveTabIndex is a
7032 // position, and a position points at the wrong tab as soon as a tab is added or removed. It stays as
7033 // the fallback for the case where the id is not in the queue at all
7034 var currentIndex = Array.FindIndex(queueArray, kvp => kvp.Key == currentTargetId);
7035 if (currentIndex == -1)
7036 {
7037 currentIndex = session.ActiveTabIndex;
7038 }
7039
7040 var nextIndex = (currentIndex + 1) % queueArray.Length;
7041 var nextTargetId = await GoToTab(nextIndex).ConfigureAwait(false);
7042
7043 session.ActiveTabIndex = nextIndex;
7044
7045 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
7046 $"Switched to index [{nextIndex}], ID [{nextTargetId}], Active window tab queue size [{queueArray.Length}]",
7047 this, GPALObjectType.PuppeteerCommunicator);
7048
7049 return nextTargetId;
7050 }
7051 /*
7052 public async Task<string> NextTab(string currentTargetId)
7053 {
7054 var windowArray = _windowSessionsQueue.ToArray();
7055 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
7056 {
7057 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No active window for NextTab", this, GPALObjectType.PuppeteerCommunicator);
7058 return null;
7059 }
7060
7061 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
7062 var queueArray = tabQueue.ToArray();
7063 if (queueArray.Length == 0)
7064 {
7065 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No tabs for NextTab", this, GPALObjectType.PuppeteerCommunicator);
7066 return null;
7067 }
7068
7069 var currentIndex = windowArray[_activeWindowIndex].ActiveTabIndex;
7070
7071 var nextIndex = (currentIndex + 1) % queueArray.Length;
7072 var nextTargetId = await GoToTab(nextIndex);
7073
7074 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Switched to index [{nextIndex}], ID [{nextTargetId}], Active window tab queue size [{queueArray.Length}]", this, GPALObjectType.PuppeteerCommunicator);
7075
7076 return nextTargetId;
7077 }
7079 // Implementation for "next-window"
7086
7087 public async Task<string> NextWindow(string currentTargetId)
7088 {
7089 var windowArray = _windowSessionsQueue.ToArray();
7090 if (windowArray.Length == 0)
7091 {
7092 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No windows for NextWindow", this, GPALObjectType.PuppeteerCommunicator);
7093 return null;
7095
7096 var currentWindowIndex = -1;
7097 for (int i = 0; i < windowArray.Length; i++)
7098 {
7099 var tabArray = windowArray[i].TabQueue.ToArray();
7100 if (Array.Exists(tabArray, kvp => kvp.Key == currentTargetId))
7101 {
7102 currentWindowIndex = i;
7103 break;
7104 }
7105 }
7106 if (currentWindowIndex == -1)
7108 currentWindowIndex = _activeWindowIndex;
7109 }
7110
7111 var nextWindowIndex = (currentWindowIndex + 1) % windowArray.Length;
7112 SetActiveWindowIndex(nextWindowIndex);
7113
7114 var nextWindowContextId = windowArray[nextWindowIndex].BrowserContextId;
7115 var nextTabQueue = windowArray[nextWindowIndex].TabQueue;
7116 var nextTabArray = nextTabQueue.ToArray();
7117 if (nextTabArray.Length == 0)
7118 {
7119 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "No tabs in next window for NextWindow", this, GPALObjectType.PuppeteerCommunicator);
7120 return null;
7122
7123 var nextTargetId = nextTabArray[windowArray[nextWindowIndex].ActiveTabIndex].Key;
7124 var nextSessionId = nextTabArray[windowArray[nextWindowIndex].ActiveTabIndex].Value;
7125
7126 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId = nextTargetId }, null).ConfigureAwait(false);
7127
7128 if (await SendCommand<object>(DevToolsMethods.PageEnable, new { }, nextSessionId).ConfigureAwait(false) == null)
7129 {
7130 var newSession = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new { targetId = nextTargetId, flatten = true }, null).ConfigureAwait(false);
7131 if (newSession?.sessionId != null)
7132 {
7133 nextSessionId = (string)newSession.sessionId;
7134 RemoveTabFromQueue(nextTargetId);
7135 await AddTabToQueue(nextTargetId, nextSessionId).ConfigureAwait(false);
7136 }
7137 else
7138 {
7139 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to re-attach NextWindow tab ID [{nextTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
7140 return null;
7141 }
7142 await SendCommand<object>(DevToolsMethods.PageDisable, new { }, nextSessionId).ConfigureAwait(false);
7143 }
7144 else
7145 {
7146 await SendCommand<object>(DevToolsMethods.PageDisable, new { }, nextSessionId).ConfigureAwait(false);
7147 }
7148
7149 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"NextWindow: Switched to window index [{nextWindowIndex}], Context [{nextWindowContextId}], Tab ID [{nextTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
7150 return nextTargetId;
7151 }
7152 // Implementation for "normal"
7154 /// Restores the browser window to its normal (non-maximized, non-minimized) state.
7155 /// Implementation for the "normal" workflow action.
7156 /// </summary>
7157 /// <param name="sessionId">CDP session ID; falls back to the current effective session if null.</param>
7158 /// <returns>Task that resolves to true once the window state has been set to normal.</returns>
7159 public async Task<bool> Normal(string sessionId = null)
7160 {
7161 sessionId ??= GetEffectiveSessionId();
7162
7163 var windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
7164 await SendCommand(DevToolsMethods.BrowserSetWindowBounds, new
7165 {
7166 windowId = windowId,
7167 bounds = new { windowState = "normal" }
7168 }, sessionId).ConfigureAwait(false);
7169
7170 return true;
7171 }
7172
7173 // Implementation for "open-window"
7182 public async Task<string> OpenWindow(string url = "https://google.com", string sessionId = null)
7183 {
7184 sessionId ??= GetEffectiveSessionId();
7185
7186 // Create the new target in its own browser context
7187 var context = await SendCommand<object>(DevToolsMethods.TargetCreateBrowserContext, new { }, sessionId).ConfigureAwait(false);
7188 string browserContextId = context.browserContextId.ToString();
7189
7190 var result = await SendCommand<object>(DevToolsMethods.TargetCreateTarget,
7191 new { url, browserContextId }, null).ConfigureAwait(false);
7192
7193 string targetId = result.targetId.ToString();
7194
7195 // Activate and attach
7196 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId }, null).ConfigureAwait(false);
7197 var session = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget,
7198 new { targetId, flatten = true }, null).ConfigureAwait(false);
7199
7200 string newSessionId = session.sessionId.ToString();
7201
7202 // Build tab queue for this window
7203 var tabQueue = new ConcurrentQueue<KeyValuePair<string, string>>();
7204 tabQueue.Enqueue(new KeyValuePair<string, string>(targetId, newSessionId));
7205
7206 // Add new window with its tab queue into _windowSessionsQueue
7207 _windowSessionsQueue.Enqueue(new WindowSession(browserContextId, tabQueue, 0));
7208
7209 // Update active window index to the newest one
7210 SetActiveWindowIndex(_windowSessionsQueue.Count - 1);
7211
7212 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
7213
7214 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
7215 $"Opened new window ContextId [{browserContextId}], TargetId [{targetId}], SessionId [{newSessionId}]",
7216 this, GPALObjectType.PuppeteerCommunicator);
7217
7218 return targetId;
7219 }
7220 // Implementation for "override-referrer"
7228 public async Task OverrideReferrer(string url, string sessionId = null)
7229 {
7230 sessionId ??= GetEffectiveSessionId();
7231 await SendCommand<object>(DevToolsMethods.NetworkSetExtraHTTPHeaders, new { headers = new { Referer = url } }, sessionId).ConfigureAwait(false);
7232 }
7233
7234 // Implementation for "set-useragent"
7242 public async Task<dynamic> SetUserAgent(string userAgent, string sessionId = null)
7243 {
7244 sessionId ??= GetEffectiveSessionId();
7245 return await SendCommand<object>(DevToolsMethods.NetworkSetUserAgentOverride, new { userAgent }, sessionId).ConfigureAwait(false);
7246 }
7247
7248 // Implementation for "page-down"
7249 // PageDown: Scrolls down by one or more pages
7257 public async Task PageDown(int pagesToScroll = 1, string sessionId = null)
7258 {
7259 sessionId ??= GetEffectiveSessionId();
7260 for (int i = 0; i < pagesToScroll; i++)
7261 {
7262 await ScrollPageAsync("down", pagesToScroll, sessionId).ConfigureAwait(false);
7263 }
7264 }
7265
7266 // PageEnd: Scrolls to the bottom of the page
7268 /// Scrolls to the very bottom of the page.
7269 /// Implementation for the "page-end" workflow action.
7270 /// </summary>
7271 /// <param name="sessionId">CDP session ID; falls back to the current effective session if null.</param>
7272 /// <returns>Task representing the asynchronous scroll operation.</returns>
7273 public async Task PageEnd(string sessionId = null)
7274 {
7275 sessionId ??= GetEffectiveSessionId();
7276 await ScrollToPositionAsync("end", sessionId).ConfigureAwait(false);
7277 }
7278
7279 // PageTop: Scrolls to the top of the page
7286 public async Task PageTop(string sessionId = null)
7287 {
7288 sessionId ??= GetEffectiveSessionId();
7289 await ScrollToPositionAsync("top", sessionId).ConfigureAwait(false);
7290 }
7291
7292 // PageUp: Scrolls up by one or more pages
7300 public async Task PageUp(int pagesToScroll = 1, string sessionId = null)
7301 {
7302 sessionId ??= GetEffectiveSessionId();
7303 for (int i = 0; i < pagesToScroll; i++)
7304 {
7305 await ScrollPageAsync("up", pagesToScroll, sessionId).ConfigureAwait(false);
7306 }
7307 }
7308
7309 // Implementation for "press-modifier-key"
7318 public async Task PressModifierKey(int modifierKeys, string sessionId = null)
7319 {
7320 sessionId ??= GetEffectiveSessionId();
7321 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, new { type = "keyDown", modifiers = modifierKeys }, sessionId).ConfigureAwait(false);
7322 }
7323
7324 // Implementation for "previous-tab"
7333 public async Task<string> PreviousTab(string currentTargetId)
7334 {
7335 var windowArray = _windowSessionsQueue.ToArray();
7336 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
7337 {
7338 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No active window for PreviousTab", this, GPALObjectType.PuppeteerCommunicator);
7339 return null;
7341
7342 var session = windowArray[_activeWindowIndex];
7343 var tabQueue = session.TabQueue;
7344 var browserContextId = session.BrowserContextId; // Use stored BrowserContextId
7345
7346 // Poll all targets via CDP to discover unknown pages
7347 var targetsResponse = await SendCommand<dynamic>(
7348 DevToolsMethods.TargetGetTargets,
7349 new { },
7350 null // Browser-level command, no sessionId
7351 ).ConfigureAwait(false);
7352
7353 if (targetsResponse?.targetInfos == null)
7354 {
7355 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Failed to get targets list in NextTab sync", this, GPALObjectType.PuppeteerCommunicator);
7356 // Proceed anyway with existing queue
7357 }
7358 else
7359 {
7360 var knownTargetIds = new HashSet<string>(tabQueue.Select(kv => kv.Key));
7361
7362 foreach (var targetInfo in targetsResponse.targetInfos)
7363 {
7364 string targetId = targetInfo.targetId?.ToString();
7365 string type = targetInfo.type?.ToString();
7366 string contextId = targetInfo.browserContextId?.ToString();
7367
7368 if (string.IsNullOrEmpty(targetId) || type != "page" || contextId != browserContextId)
7369 continue;
7370
7371 if (!knownTargetIds.Contains(targetId))
7372 {
7373 // Add with doNotGetSendSemaphore=true to avoid reentrancy/locking issues
7374 await AddTabToQueue(targetId, null, false, true).ConfigureAwait(false);
7375 knownTargetIds.Add(targetId);
7376 }
7377 }
7378 }
7379
7380 var queueArray = tabQueue.ToArray();
7381 if (queueArray.Length == 0)
7382 {
7383 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No tabs for PreviousTab", this, GPALObjectType.PuppeteerCommunicator);
7384 return null;
7385 }
7386
7387 var currentIndex = Array.FindIndex(queueArray, kvp => kvp.Key == currentTargetId);
7388 if (currentIndex == -1)
7389 {
7390 currentIndex = windowArray[_activeWindowIndex].ActiveTabIndex;
7391 }
7392
7393 var prevIndex = (currentIndex - 1 + queueArray.Length) % queueArray.Length;
7394 var prevTargetId = queueArray[prevIndex].Key;
7395 var prevSessionId = queueArray[prevIndex].Value;
7396
7397 // Update ActiveTabIndex in WindowSession
7398 var windowList = _windowSessionsQueue.ToList();
7399 windowList[_activeWindowIndex] = new WindowSession(
7400 windowList[_activeWindowIndex].BrowserContextId,
7401 tabQueue,
7402 prevIndex
7403 );
7404 _windowSessionsQueue = new ConcurrentQueue<WindowSession>();
7405 foreach (var w in windowList)
7406 {
7407 _windowSessionsQueue.Enqueue(w);
7408 }
7409
7410 ((PuppeteerClient)PuppeteerClient)._currentTargetId = prevTargetId;
7411
7412 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId = prevTargetId }, null).ConfigureAwait(false);
7413
7414 if (await SendCommand<object>(DevToolsMethods.PageEnable, new { }, prevSessionId).ConfigureAwait(false) == null)
7416 var newSession = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new { targetId = prevTargetId, flatten = true }, null).ConfigureAwait(false);
7417 if (newSession?.sessionId != null)
7418 {
7419 prevSessionId = (string)newSession.sessionId;
7420 RemoveTabFromQueue(prevTargetId);
7421 await AddTabToQueue(prevTargetId, prevSessionId).ConfigureAwait(false);
7422 }
7423 else
7424 {
7425 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to re-attach PreviousTab ID [{prevTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
7426 return null;
7427 }
7428 await SendCommand<object>(DevToolsMethods.PageDisable, new { }, prevSessionId).ConfigureAwait(false);
7429 }
7430 else
7431 {
7432 await SendCommand<object>(DevToolsMethods.PageDisable, new { }, prevSessionId).ConfigureAwait(false);
7433 }
7434
7435 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"PreviousTab: Switched to index [{prevIndex}], ID [{prevTargetId}], Active window tab queue size [{queueArray.Length}]", this, GPALObjectType.PuppeteerCommunicator);
7436 return prevTargetId;
7437 }
7438
7439 // Implementation for "previous-window"
7443 /// Implementation for the "previous-window" workflow action.
7444 /// </summary>
7445 /// <param name="currentTargetId">Target ID of the currently active tab, used to determine which window is currently active.</param>
7446 /// <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>
7447 public async Task<string> PreviousWindow(string currentTargetId)
7448 {
7449 var windowArray = _windowSessionsQueue.ToArray();
7450 if (windowArray.Length == 0)
7451 {
7452 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No windows for PreviousWindow", this, GPALObjectType.PuppeteerCommunicator);
7453 return null;
7454 }
7455
7456 var currentWindowIndex = -1;
7457 for (int i = 0; i < windowArray.Length; i++)
7458 {
7459 var tabArray = windowArray[i].TabQueue.ToArray();
7460 if (Array.Exists(tabArray, kvp => kvp.Key == currentTargetId))
7461 {
7462 currentWindowIndex = i;
7463 break;
7464 }
7465 }
7466 if (currentWindowIndex == -1)
7467 {
7468 currentWindowIndex = _activeWindowIndex;
7469 }
7471 var prevWindowIndex = (currentWindowIndex - 1 + windowArray.Length) % windowArray.Length;
7472 SetActiveWindowIndex(prevWindowIndex);
7473
7474 var prevWindowContextId = windowArray[prevWindowIndex].BrowserContextId;
7475 var prevTabQueue = windowArray[prevWindowIndex].TabQueue;
7476 var prevTabArray = prevTabQueue.ToArray();
7477 if (prevTabArray.Length == 0)
7478 {
7479 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"No tabs in previous window for PreviousWindow (context [{prevWindowContextId}])", this, GPALObjectType.PuppeteerCommunicator);
7480 return null;
7481 }
7482
7483 var prevTargetId = prevTabArray[windowArray[prevWindowIndex].ActiveTabIndex].Key;
7484 var prevSessionId = prevTabArray[windowArray[prevWindowIndex].ActiveTabIndex].Value;
7485
7486 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId = prevTargetId }, null).ConfigureAwait(false);
7487
7488 if (await SendCommand<object>(DevToolsMethods.PageEnable, new { }, prevSessionId).ConfigureAwait(false) == null)
7489 {
7490 var newSession = await SendCommand<object>(DevToolsMethods.TargetAttachToTarget, new { targetId = prevTargetId, flatten = true }, null).ConfigureAwait(false);
7491 if (newSession?.sessionId != null)
7492 {
7493 prevSessionId = (string)newSession.sessionId;
7494 RemoveTabFromQueue(prevTargetId);
7495 await AddTabToQueue(prevTargetId, prevSessionId).ConfigureAwait(false);
7496 }
7497 else
7498 {
7499 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to re-attach PreviousWindow tab ID [{prevTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
7500 return null;
7501 }
7502 await SendCommand<object>(DevToolsMethods.PageDisable, new { }, prevSessionId).ConfigureAwait(false);
7503 }
7504 else
7505 {
7506 await SendCommand<object>(DevToolsMethods.PageDisable, new { }, prevSessionId).ConfigureAwait(false);
7507 }
7508
7509 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"PreviousWindow: Switched to window index [{prevWindowIndex}], Context [{prevWindowContextId}], Tab ID [{prevTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
7510 return prevTargetId;
7511 } // Implementation for "query-selector"
7519 public async Task<dynamic> QuerySelector(string css, string sessionId = null)
7520 {
7521 sessionId ??= GetEffectiveSessionId();
7522 return await QueryByCss(css, sessionId).ConfigureAwait(false);
7523 }
7524
7532 internal async Task<long> DocumentMark(string sessionId = null)
7533 {
7534 sessionId ??= GetEffectiveSessionId();
7535
7536 var doc = await SendCommand<object>(DevToolsMethods.DOMGetDocument, new { depth = 0 }, sessionId).ConfigureAwait(false);
7537 long mark = null == doc || null == doc.root ? 0 : (long)doc.root.backendNodeId;
7538
7539 return mark;
7540 }
7541
7542 // Implementation for "query-selectors"
7550 public async Task<dynamic> QuerySelectors(string css, string sessionId = null)
7551 {
7552 sessionId ??= GetEffectiveSessionId();
7553
7554 var doc = await SendCommand<object>(DevToolsMethods.DOMGetDocument, new { depth = 0 }, sessionId).ConfigureAwait(false);
7555
7556 return await SendCommand<object>(DevToolsMethods.DOMQuerySelectorAll, new { nodeId = (int)doc.root.nodeId, selector = css }, sessionId).ConfigureAwait(false);
7557 }
7558
7559 // Implementation for "refresh"
7566 public async Task Refresh(string sessionId = null)
7567 {
7568 sessionId ??= GetEffectiveSessionId();
7569 await SendCommand<object>(DevToolsMethods.PageReload, new { }, sessionId).ConfigureAwait(false);
7570 }
7571
7572 // Implementation for "release-modifier-key"
7581 public async Task ReleaseModifierKey(int modifierKeys, string sessionId = null)
7582 {
7583 sessionId ??= GetEffectiveSessionId();
7584 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, new { type = "keyUp", modifiers = modifierKeys }, sessionId).ConfigureAwait(false);
7585 }
7586
7587 // Implementation for "restore"
7594 public async Task<bool> Restore(string sessionId = null)
7595 {
7596 sessionId ??= GetEffectiveSessionId();
7597
7598 var windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
7599 await SendCommand(DevToolsMethods.BrowserSetWindowBounds, new
7600 {
7601 windowId = windowId,
7602 bounds = new { windowState = "normal" }
7603 }, sessionId).ConfigureAwait(false);
7604
7605 return true;
7606 }
7607
7608 // Implementation for "middle-click"
7618
7622 public async Task MiddleClick(string elementId, string sessionId = null)
7623 {
7624 sessionId ??= GetEffectiveSessionId();
7625 List<GPALElement> elems = await EvaluateSelector(elementId, sessionId).ConfigureAwait(false);
7626 List<dynamic> responses = new List<dynamic>();
7627
7628 foreach (GPALElement elem in elems)
7629 {
7630 await ScrollIntoView(elem, sessionId).ConfigureAwait(false);
7631 await ClickElement(elem, sessionId, responses, ClickType.MiddleClick).ConfigureAwait(false);
7632 }
7633 }
7634
7635 // Implementation for "right-click"
7649 public async Task RightClick(string elementId, string sessionId = null)
7650 {
7651 sessionId ??= GetEffectiveSessionId();
7652 List<GPALElement> elems = await EvaluateSelector(elementId, sessionId).ConfigureAwait(false);
7653 List<dynamic> responses = new List<dynamic>();
7654
7655 foreach (GPALElement elem in elems)
7656 {
7657 await ScrollIntoView(elem, sessionId).ConfigureAwait(false);
7658 await ClickElement(elem, sessionId, responses, ClickType.RightClick).ConfigureAwait(false);
7659 }
7660 }
7661
7662
7663 // Implementation for "scroll-element"
7674 public async Task ScrollElement(string elementId, int hPixels, int vPixels, string sessionId = null)
7675 {
7676 sessionId ??= GetEffectiveSessionId();
7677 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
7678 new {
7679 contextId = CurrentContextId,
7680 expression = "document.getElementById('" + elementId + "').scrollBy(" + hPixels + ", " + vPixels + ")"
7681 }, sessionId).ConfigureAwait(false);
7682 }
7683
7684 // Implementation for "scroll-into-view"
7701 public async Task<bool> ScrollIntoView(GPALElement element, string sessionId = null)
7702 {
7703 int backendNodeId = element.ElementBackendNodeId;
7704
7705 sessionId ??= GetEffectiveSessionId();
7706
7707 try
7708 {
7709 if ("GPALElement".Equals(element.TagName))
7710 return true; // it's already in view if it's a matched image
7712 // force layout engine to run before we interact
7713 if (true == Browser.BrowserSettings.UseHeadless)
7714 {
7715 // force layout
7716 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
7717 new {
7718 contextId = CurrentContextId,
7719 expression = @"
7720 document.body.style.zoom = '100%'; // minor reflow trigger
7721 window.dispatchEvent(new Event('resize'));
7722 document.documentElement.scrollTop += 1; document.documentElement.scrollTop -= 1;"
7723 }, sessionId).ConfigureAwait(false);
7724 }
7725
7726 // keep the items from bouncing around if they are already in view
7727 await SendCommand(DevToolsMethods.RuntimeEvaluate, new
7728 {
7729 expression = @"
7730 if (!document.querySelector('style[data-gpal-overscroll]')) {
7731 const style = document.createElement('style');
7732 style.setAttribute('data-gpal-overscroll', 'true');
7733 style.textContent = `
7734 html, body {
7735 overscroll-behavior: none !important;
7736 overscroll-behavior-x: none !important;
7737 overscroll-behavior-y: none !important;
7738 }
7739 `;
7740 (document.head || document.documentElement).appendChild(style);
7741 }
7742 ",
7743 returnByValue = true
7744 }, sessionId).ConfigureAwait(false);
7745
7746 // Try native CDP scroll first (quick & usually sufficient when element is renderable)
7747 var scrollResult = await SendCommand<object>(
7748 DevToolsMethods.DOMScrollIntoViewIfNeeded,
7749 new { backendNodeId },
7750 sessionId
7751 ).ConfigureAwait(false);
7752
7753 if (true == Browser.BrowserSettings.UseHeadless)
7754 await Task.Delay(250).ConfigureAwait(false); // Give layout/paint time to settle
7755
7756 if (null == scrollResult)
7757 {
7758 // Escape properly for JS string literal (handles quotes, backslashes)
7759 var escapedSelector = element.Css.Replace(@"\", @"\\").Replace("'", @"\'");
7760
7761 var callResult = await SendCommand<dynamic>(
7762 DevToolsMethods.RuntimeEvaluate,
7763 new
7764 {
7765 expression = $@"
7766 (function() {{
7767 const el = document.querySelector(""{escapedSelector}"");
7768 if (!el) {{
7769 console.warn('Element not found by selector: {escapedSelector}');
7770 return false;
7771 }}
7772
7773 el.scrollIntoView({{
7774 behavior: 'instant',
7775 block: 'center',
7776 inline: 'center'
7777 }});
7778
7779 // Force reflow/layout
7780 void el.getBoundingClientRect();
7781 void el.offsetHeight;
7782
7783 return new Promise(r => requestAnimationFrame(() => r(true)));
7784 }})()",
7785 awaitPromise = true,
7786 contextId = CurrentContextId,
7787 returnByValue = true
7788 },
7789 sessionId
7790 ).ConfigureAwait(false);
7791
7792 //var success = callResult?.result?.value as bool? ?? false;
7793
7794 //if (!success)
7795 //{
7796 // GPAL.PublishSimpleEvent(GPALEventType.WARNING,
7797 // $"JS scroll fallback failedfor element [{element.Css}]",
7798 // this, GPALObjectType.PuppeteerCommunicator);
7799 // // still return true if you want fail-forward, or false if stricter
7800 //}
7801
7802 // Assume recovery succeeded after JS — we don't re-check box model
7803 // we don't care about the return value, what else are we going to do, we have nothing to do
7804 }
7805
7806 return true;
7807 }
7808 catch (Exception ex)
7809 {
7810 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
7811 $"ScrollIntoView failed",
7812 this, GPALObjectType.PuppeteerCommunicator, ex);
7813 return false;
7814 }
7815 }
7816 /*
7817 public async Task<dynamic> ScrollIntoView(int backendNodeId, string sessionId = null)
7818 {
7819 sessionId ??= GetEffectiveSessionId();
7820
7821 try
7822 {
7823 // === STEP 0: Let Chromium handle ALL scroll containers ===
7824 await SendCommand<object>(
7825 DevToolsMethods.DOMScrollIntoViewIfNeeded,
7826 new { backendNodeId },
7827 sessionId
7828 );
7829
7830 await Task.Delay(75); // allow paint tree to settle
7831
7832 // === STEP 1: Get fresh box model AFTER scrolling ===
7833 var boxResult = await SendCommand<object>(
7834 DevToolsMethods.DOMGetBoxModel,
7835 new { backendNodeId },
7836 sessionId
7837 );
7838
7839 if (boxResult?.model?.content == null)
7840 {
7841 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
7842 $"No box model for node [{backendNodeId}]",
7843 this, GPALObjectType.PuppeteerCommunicator);
7844 return new { success = false };
7845 }
7846
7847 var content = boxResult.model.content;
7848
7849 double elX = content[0];
7850 double elY = content[1];
7851 double elW = content[2] - content[0];
7852 double elH = content[7] - content[1];
7853
7854 // === STEP 2: Get viewport ===
7855 var vpResult = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
7856 {
7857 expression = "({ height: window.innerHeight, width: window.innerWidth })",
7858 returnByValue = true
7859 }, sessionId);
7860
7861 int vpH = vpResult?.result?.value?.height ?? 0;
7862 int vpW = vpResult?.result?.value?.width ?? 0;
7863
7864 if (vpH == 0 || vpW == 0)
7865 return new { success = false };
7866
7867 // === STEP 3: Clamp to viewport (now actually meaningful) ===
7868 double visLeft = Math.Max(elX, 0);
7869 double visTop = Math.Max(elY, 0);
7870 double visRight = Math.Min(elX + elW, vpW);
7871 double visBottom = Math.Min(elY + elH, vpH);
7872
7873 if (visLeft >= visRight || visTop >= visBottom)
7874 {
7875 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
7876 $"Element [{backendNodeId}] not visible after CDP scroll",
7877 this, GPALObjectType.PuppeteerCommunicator);
7878 return new { success = false };
7879 }
7880
7881 int clickX = (int)((visLeft + visRight) / 2);
7882 int clickY = (int)((visTop + visBottom) / 2);
7883
7884 return new
7885 {
7886 success = true,
7887 point = new { x = clickX, y = clickY }
7888 };
7889 }
7890 catch (Exception ex)
7891 {
7892 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
7893 $"ScrollIntoView failed for node [{backendNodeId}]",
7894 this, GPALObjectType.PuppeteerCommunicator, ex);
7895 return new { success = false };
7896 }
7897 }
7898 */
7899 // Implementation for "scroll-window"
7909 public async Task ScrollWindow(int hPixels, int vPixels, string sessionId = null)
7910 {
7911 sessionId ??= GetEffectiveSessionId();
7912 await ScrollWindowByPixelsAsync(hPixels, vPixels, sessionId).ConfigureAwait(false);
7913 await Task.Delay(150).ConfigureAwait(false); // NOTE: for headless
7914 }
7915
7916 // Implementation for "send-key"
7928 public async Task<dynamic> SendKey(string key, string code, int vkCode, string sessionId = null)
7929 {
7930 sessionId ??= GetEffectiveSessionId();
7931
7932 try
7933 {
7934 // For forms to submit on Enter, Chrome requires the text property to be "\r".
7935 // Otherwise, printable characters use their literal string value.
7936 string textPayload = (code == "Enter") ? "\r" : (key.Length == 1 ? key : null);
7937
7938 // 1. Dispatch Key Down
7939 var keyDownParams = new
7940 {
7941 type = "rawKeyDown",
7942 key = key,
7943 code = code,
7944 windowsVirtualKeyCode = vkCode,
7945 nativeVirtualKeyCode = vkCode
7947 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, keyDownParams, sessionId).ConfigureAwait(false);
7948
7949 // 2. Dispatch Char (Only if the key sequence produces an explicit input character)
7950 if (textPayload != null)
7951 {
7952 var charParams = new
7953 {
7954 type = "char",
7955 text = textPayload,
7956 unmodifiedText = textPayload,
7957 key = key,
7958 code = code,
7959 windowsVirtualKeyCode = vkCode,
7960 nativeVirtualKeyCode = vkCode
7961 };
7962 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, charParams, sessionId).ConfigureAwait(false);
7963 }
7964
7965 // 3. Dispatch Key Up
7966 var keyUpParams = new
7967 {
7968 type = "keyUp",
7969 key = key,
7970 code = code,
7971 windowsVirtualKeyCode = vkCode,
7972 nativeVirtualKeyCode = vkCode
7973 };
7974 await SendCommand<dynamic>(DevToolsMethods.InputDispatchKeyEvent, keyUpParams, sessionId).ConfigureAwait(false);
7975 }
7976 catch (Exception ex)
7977 {
7978 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"SendKey execution failed for Code [{code}]", this, GPALObjectType.PuppeteerCommunicator, ex);
7979 return false;
7980 }
7981 return true;
7982 }
7983
7988
7990 private int GetVirtualKeyCode(string key)
7991 {
7992 if (string.IsNullOrEmpty(key))
7993 return 0;
7994
7995 string k = key.ToLowerInvariant();
7997 switch (k)
7998 {
7999 case "enter":
8000 case "return":
8001 return 0x0D;
8002
8003 case "tab":
8004 return 0x09;
8005
8006 case "backspace":
8007 return 0x08;
8008
8009 case "delete":
8010 case "del":
8011 return 0x2E;
8012
8013 case "escape":
8014 case "esc":
8015 return 0x1B;
8017 case "space":
8018 return 0x20;
8019
8020 case "arrowup":
8021 case "up":
8022 return 0x26;
8023
8024 case "arrowdown":
8025 case "down":
8026 return 0x28;
8027
8028 case "arrowleft":
8029 case "left":
8030 return 0x25;
8031
8032 case "arrowright":
8033 case "right":
8034 return 0x27;
8035
8036 case "home":
8037 return 0x24;
8038
8039 case "end":
8040 return 0x23;
8041
8042 case "pageup":
8043 return 0x21;
8044
8045 case "pagedown":
8046 return 0x22;
8047
8048 case "insert":
8049 return 0x2D;
8050
8051 case "f1": return 0x70;
8052 case "f2": return 0x71;
8053 case "f3": return 0x72;
8054 case "f4": return 0x73;
8055 case "f5": return 0x74;
8056 case "f6": return 0x75;
8057 case "f7": return 0x76;
8058 case "f8": return 0x77;
8059 case "f9": return 0x78;
8060 case "f10": return 0x79;
8061 case "f11": return 0x7A;
8062 case "f12": return 0x7B;
8063
8064 default:
8065 return 0; // letters, numbers, symbols etc.
8066 }
8067 }
8068
8069 // Implementation for "send-string"
8070 // NOTE: same as fillin
8082 public async Task SendString(string text, int delayMs = 0, string sessionId = null)
8083 {
8084 sessionId ??= GetEffectiveSessionId();
8085 if (0 == delayMs)
8086 await SendCommand<object>(DevToolsMethods.InputInsertText, new { text }, sessionId).ConfigureAwait(false);
8087 else
8088 await DispatchTextCharByChar(text, delayMs, sessionId).ConfigureAwait(false);
8089 }
8090
8100 private async Task DispatchTextCharByChar(string text, int delayMs, string sessionId)
8101 {
8102 bool first = true;
8103 foreach (char c in text)
8104 {
8105 if (false == first && 0 < delayMs)
8106 await Task.Delay(HardwareHelper.GetTypingDelay(delayMs)).ConfigureAwait(false);
8107
8108 string key = c.ToString();
8109 int vkCode = (int)c; // Basic; improve with real VK map for specials/shifts
8110
8111 await SendCommand(DevToolsMethods.InputDispatchKeyEvent, new
8112 {
8113 type = "keyDown",
8114 text = key, // for printable chars
8115 unmodifiedtext = key,
8116 key = key,
8117 code = GetKeyCode(c), // e.g. "KeyA" for 'a'/'A'
8118 windowsVirtualKeyCode = vkCode,
8119 nativeVirtualKeyCode = vkCode
8120 }, sessionId).ConfigureAwait(false);
8121
8122 await SendCommand(DevToolsMethods.InputDispatchKeyEvent, new
8123 {
8124 type = "keyUp",
8125 key = key,
8126 code = GetKeyCode(c),
8127 windowsVirtualKeyCode = vkCode
8128 }, sessionId).ConfigureAwait(false);
8129
8130 first = false;
8131 }
8132 }
8133
8134 // Implementation for "set-attribute"
8144 public async Task<dynamic> SetAttribute(int backendNodeId, string attribute, string value, string sessionId = null)
8145 {
8146 sessionId ??= GetEffectiveSessionId();
8147 var nodeId = await GetNodeIdFromBackendNodeId(backendNodeId, sessionId).ConfigureAwait(false);
8148 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Set attribute [{attribute}] to [{value}] on backendNodId [{backendNodeId}]", this, GPALObjectType.PuppeteerCommunicator);
8149 return await SendCommand<object>(DevToolsMethods.DOMSetAttributeValue, new { nodeId, name = attribute, value }, sessionId).ConfigureAwait(false);
8150 }
8151
8152 // Implementation for "set-download-filename"
8160 public async Task SetDownloadFilename(string downloadPath, string sessionId = null)
8161 {
8162 sessionId ??= GetEffectiveSessionId();
8163 await SendCommand<object>(DevToolsMethods.PageSetDownloadBehavior, new { behavior = "allow", downloadPath }, sessionId).ConfigureAwait(false);
8164 }
8165
8166 // Implementation for "scroll-element"
8176 public async Task<dynamic> SetRange(string elementId, string rangeValue, string sessionId = null)
8177 {
8178 sessionId ??= GetEffectiveSessionId();
8179 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8180 new
8181 {
8182 contextId = CurrentContextId,
8183 expression = $@"var s = document.querySelector('{elementId}'); s.value = {rangeValue}; s.dispatchEvent(new Event('input', {{bubbles: true}})); s.dispatchEvent(new Event('change', {{bubbles: true}}));"
8184 }, sessionId).ConfigureAwait(false);
8185 }
8186
8187 // Implementation for "top-browser"
8194 public async Task TopBrowser(string sessionId = null)
8195 {
8196 // Page.bringToFront is top-level only, so this must never go to an iframe session - once we are inside
8197 // a cross-origin frame the effective session is that frame's and CDP rejects the command outright.
8198 // sessionId is deliberately ignored: any session that is not the top-level page target is wrong here
8199 await SendCommand<object>(DevToolsMethods.PageBringToFront, new { }, GetCurrentSessionId()).ConfigureAwait(false);
8200 }
8201
8214 public async Task<bool> SetStorage(
8215 string storageType,
8216 string sessionId,
8217 string domain,
8218 string key,
8219 string data,
8220 string storeName = null,
8221 string path = null)
8222 {
8223 bool retVal = false;
8224 sessionId ??= GetEffectiveSessionId();
8225
8226 try
8227 {
8228 switch (storageType)
8229 {
8230 case "cookie":
8231 {
8232 IDictionary<string, object> cookieToSet;
8233
8234 path ??= "/";
8235 domain ??= ""; // or handle wildcard logic as before
8236
8237 // 1. Get current cookies via CDP
8238 dynamic cookiesResp = await GetStorage(storageType, sessionId, domain, storeName, path, key).ConfigureAwait(false);
8239
8240 // 2. Find the existing cookie (exact match on name + domain + path)
8241 dynamic existing = null;
8242
8243 if (cookiesResp != null)
8244 {
8245 var cookies = JsonConvert.DeserializeObject<List<dynamic>>(cookiesResp) ?? new List<dynamic>();
8246 foreach (dynamic c in cookies)
8248 string cDomain = ((dynamic)c).Domain ?? "";
8249 string cPath = ((dynamic)c).Path ?? "/";
8250 string cName = ((dynamic)c).Key ?? "";
8251
8252 if (cName == key &&
8253 UrlHelper.CookieDomainMatches(cDomain, domain) &&
8254 cPath == path)
8255 {
8256 existing = c;
8257
8258 cookieToSet = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
8259
8260 cookieToSet["name"] = key;
8261 cookieToSet["value"] = data;
8262 cookieToSet["domain"] = ((dynamic)existing)?.Domain?.ToString();
8263 cookieToSet["path"] = path;
8264
8265 cookieToSet["expires"] = ((dynamic)existing)?.Expires != null
8266 ? (double)existing.Expires
8267 : DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (180L * 24 * 60 * 60); // ~6 months
8268
8269 cookieToSet["secure"] = ((dynamic)existing)?.Secure ?? true;
8270 cookieToSet["httpOnly"] = ((dynamic)existing)?.HttpOnly ?? true;
8271 cookieToSet["sameSite"] = ((dynamic)existing)?.SameSite?.ToString() ?? "None";
8272
8273 cookieToSet["session"] = ((dynamic)existing)?.Session ?? false;
8274 cookieToSet["priority"] = ((dynamic)existing)?.Priority?.ToString() ?? "Medium";
8275
8276 cookieToSet["sourceScheme"] = ((dynamic)existing)?.SourceScheme?.ToString() ?? "Secure";
8277 cookieToSet["sourcePort"] = ((dynamic)existing)?.SourcePort ?? 443;
8278
8279 // Only add partitionKey if it actually exists and is not null
8280 if (((dynamic)existing)?.PartitionKey != null)
8281 {
8282 cookieToSet["partitionKey"] = existing.PartitionKey; // keep the original object
8283 }
8284
8285 // 4. Set the cookie
8286 await SendCommand<dynamic>(DevToolsMethods.StorageSetCookies,
8287 new { cookies = new[] { cookieToSet } },
8288 sessionId).ConfigureAwait(false);
8289 }
8290 }
8291 }
8292 else
8293 {
8294 // 3. Build the cookie to set (merge existing + supplied values)
8295 cookieToSet = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
8296
8297 cookieToSet["name"] = key;
8298 cookieToSet["value"] = data;
8299 cookieToSet["domain"] = ((dynamic)existing)?.Domain?.ToString();
8300 cookieToSet["path"] = path;
8301
8302 cookieToSet["expires"] = ((dynamic)existing)?.Expires != null
8303 ? (double)existing.Expires
8304 : DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (180L * 24 * 60 * 60); // ~6 months
8305
8306 cookieToSet["secure"] = ((dynamic)existing)?.Secure ?? true;
8307 cookieToSet["httpOnly"] = ((dynamic)existing)?.HttpOnly ?? true;
8308 cookieToSet["sameSite"] = ((dynamic)existing)?.SameSite?.ToString() ?? "None";
8309
8310 cookieToSet["session"] = ((dynamic)existing)?.Session ?? false;
8311 cookieToSet["priority"] = ((dynamic)existing)?.Priority?.ToString() ?? "Medium";
8312
8313 cookieToSet["sourceScheme"] = ((dynamic)existing)?.SourceScheme?.ToString() ?? "Secure";
8314 cookieToSet["sourcePort"] = ((dynamic)existing)?.SourcePort ?? 443;
8315
8316 // Only add partitionKey if it actually exists and is not null
8317 if (((dynamic)existing)?.PartitionKey != null)
8318 {
8319 cookieToSet["partitionKey"] = existing.PartitionKey; // keep the original object
8320 }
8321
8322 // 4. Set the cookie
8323 await SendCommand<dynamic>(DevToolsMethods.StorageSetCookies,
8324 new { cookies = new[] { cookieToSet } },
8325 sessionId).ConfigureAwait(false);
8326 }
8327
8328 // Optional: return the cookie we just set (or the old list)
8329 //resultJson = JsonConvert.SerializeObject(new[] { cookieToSet });
8330 retVal = true;
8331 break;
8332 }
8333
8334 case "localStorage":
8335 case "sessionStorage":
8336 string jsStorage = $@"{storageType}.setItem('{EscapeJsString(key)}','{EscapeJsString(data)}');";
8337 await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate,
8338 new {
8339 expression = jsStorage,
8340 awaitPromise = false,
8341 contextId = CurrentContextId,
8342 returnByValue = true
8343 }, sessionId).ConfigureAwait(false);
8344 retVal = true;
8345 break;
8346
8347 case "indexedDb":
8348 // Runtime.evaluate has no arguments array and injects no callback, so taking one the way
8349 // selenium's executeAsyncScript hands it over threw a ReferenceError before the script ran.
8350 // a promise is what awaitPromise on the evaluate call waits for
8351 string js = $@"
8352 new Promise((callback) => {{
8353
8354 (function() {{
8355 const dbName = '{EscapeJsString(path)}';
8356 const storeName = '{EscapeJsString(storeName)}';
8357 const inputKey = '{EscapeJsString(key)}';
8358 let rawValue = '{EscapeJsString(data)}';
8359
8360 function resolve(val) {{
8361 callback(val);
8362 }}
8363
8364 function fail(msg, err, db) {{
8365 console.error(msg, err?.name || err || '');
8366 try {{ db?.close(); }} catch {{ }}
8367 resolve(false);
8368 }}
8369
8370 function parseValue(v) {{
8371 try {{
8372 return JSON.parse(v);
8373 }} catch {{
8374 return v;
8375 }}
8376 }}
8377
8378 function isValidKey(k) {{
8379 if (k === undefined) return false;
8380 if (typeof k === 'string' || typeof k === 'number') return true;
8381 if (k instanceof Date) return true;
8382 if (Array.isArray(k)) return true;
8383 return false;
8385
8386 function ensureInlineKey(value, keyPath, key, autoIncrement) {{
8387 if (keyPath === null) return {{ value, usedKey: key }};
8388
8389 if (typeof value !== 'object' || value === null) {{
8390 if (keyPath && key !== undefined) {{
8391 const wrapped = {{}};
8392
8393 if (typeof keyPath === 'string') {{
8394 wrapped[keyPath] = key;
8395 }} else if (Array.isArray(keyPath)) {{
8396 if (!Array.isArray(key)) {{
8397 throw new Error('Compound key requires array key');
8398 }}
8399 for (let i = 0; i < keyPath.length; i++) {{
8400 wrapped[keyPath[i]] = key[i];
8401 }}
8402 }}
8403
8404 wrapped.value = value;
8405 return {{ value: wrapped, usedKey: undefined }};
8406 }}
8407
8408 if (autoIncrement) {{
8409 return {{ value, usedKey: undefined }};
8411
8412 throw new Error('Inline keyPath requires object value');
8413 }}
8414
8415 if (typeof keyPath === 'string') {{
8416 if (!(keyPath in value)) {{
8417 if (key !== undefined) {{
8418 value[keyPath] = key;
8419 }} else if (!autoIncrement) {{
8420 throw new Error('Missing required inline keyPath property');
8421 }}
8422 }}
8423 }} else if (Array.isArray(keyPath)) {{
8424 for (let i = 0; i < keyPath.length; i++) {{
8425 const kp = keyPath[i];
8426 if (!(kp in value)) {{
8427 if (Array.isArray(key) && key[i] !== undefined) {{
8428 value[kp] = key[i];
8429 }} else if (!autoIncrement) {{
8430 throw new Error('Missing compound keyPath property: ' + kp);
8431 }}
8432 }}
8433 }}
8434 }}
8435
8436 return {{ value, usedKey: undefined }};
8437 }}
8438
8439 const openReq = indexedDB.open(dbName);
8440
8441 openReq.onerror = () => {{
8442 fail('Failed to open database', openReq.error);
8443 }};
8445 openReq.onsuccess = () => {{
8446 const db = openReq.result;
8447
8448 let tx;
8449 try {{
8450 tx = db.transaction(storeName, 'readwrite');
8451 }} catch (e) {{
8452 fail('Transaction creation failed', e, db);
8453 return;
8454 }}
8455
8456 const store = tx.objectStore(storeName);
8457
8458 let value = parseValue(rawValue);
8459 let key = inputKey;
8460
8461 if (key === '') key = undefined;
8463 if (key !== undefined && !isValidKey(key)) {{
8464 fail('Invalid key type', key, db);
8465 return;
8466 }}
8467
8468 let finalValue, finalKey;
8469
8470 try {{
8471 const result = ensureInlineKey(
8472 value,
8473 store.keyPath,
8474 key,
8475 store.autoIncrement
8476 );
8477
8478 finalValue = result.value;
8479 finalKey = result.usedKey;
8480 }} catch (e) {{
8481 fail('Key handling failed', e, db);
8482 return;
8483 }}
8484
8485 let putReq;
8486
8487 try {{
8488 if (store.keyPath === null) {{
8489 if (finalKey === undefined && !store.autoIncrement) {{
8490 fail('Missing key for out-of-line store', null, db);
8491 return;
8492 }}
8493 putReq = store.put(finalValue, finalKey);
8494 }} else {{
8495 putReq = store.put(finalValue);
8496 }}
8497 }} catch (e) {{
8498 fail('Put setup error', e, db);
8499 return;
8500 }}
8501
8502 // wait for transaction complete (not just put)
8503 tx.oncomplete = () => {{
8504 try {{ db.close(); }} catch {{ }}
8505 resolve(true);
8506 }};
8507
8508 tx.onerror = (event) => {{
8509 fail('Transaction error', event.target.error, db);
8510 }};
8511
8512 tx.onabort = (event) => {{
8513 fail('Transaction aborted', event.target.error, db);
8514 }};
8515 }};
8516 }})();
8517
8518 }});
8519 ";
8520
8521 string indexedDbSaid = SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
8522 {
8523 expression = js,
8524 awaitPromise = true,
8525 contextId = CurrentContextId,
8526 returnByValue = true
8527 }, sessionId).GetAwaiter().GetResult()?.ToString();
8528
8529 retVal = true == indexedDbSaid?.Contains("true");
8530
8531 // the script says why it could not write, and reducing its answer to a bool threw that
8532 // away, so a store that refused the record looked the same as one that took it
8533 if (false == retVal)
8534 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
8535 $"IndexedDB [{path}] store [{storeName}] did not take key [{key}], the page answered [{indexedDbSaid}]",
8536 null, GPALObjectType.None);
8537
8538 break;
8539
8540 case "cache":
8541 string cacheName = EscapeJsString(storeName);
8542 string requestUrl = EscapeJsString(key); // Key = the cache key (URL)
8544 js = $@"
8545 (async () => {{
8546 try {{
8547 const cache = await caches.open('{cacheName}');
8548
8549 const request = new Request('{requestUrl}', {{
8550 method: 'GET'
8551 }});
8552
8553 const response = new Response('{EscapeJsString(data)}', {{
8554 status: 200,
8555 statusText: 'OK',
8556 headers: {{
8557 'Content-Type': 'text/plain;charset=utf-8',
8558 'Cache-Control': 'max-age=31536000' // or make this configurable
8559 }}
8560 }});
8561
8562 await cache.put(request, response);
8563 return {{ success: true, cacheName: '{cacheName}', key: '{requestUrl}' }};
8564 }} catch (err) {{
8565 console.error('Cache set failed:', err);
8566 return {{ success: false, error: err.message }};
8567 }}
8568 }})();
8569 ";
8570
8571 var evalResult = await SendCommand<dynamic>(
8572 DevToolsMethods.RuntimeEvaluate,
8573 new
8574 {
8575 expression = js,
8576 awaitPromise = true,
8577 contextId = CurrentContextId,
8578 returnByValue = true
8579 },
8580 sessionId).ConfigureAwait(false);
8581
8582 // Check result
8583 retVal = evalResult?.result?.value?.success == true;
8584 break;
8585
8586 default:
8588 GPALEventType.ERROR,
8589 $"Unsupported storage type: [{storageType}]",
8590 this,
8591 GPALObjectType.PuppeteerCommunicator);
8592 break;
8593 }
8594 }
8595 catch (Exception ex)
8596 {
8598 GPALEventType.EXCEPTION,
8599 $"SetStorage failed for [{storageType}] domain=[{domain}] storeName=[{storeName}] path=[{path}] key=[{key}]",
8600 this,
8601 GPALObjectType.PuppeteerCommunicator,
8602 ex);
8603 }
8604
8605 return retVal;
8606 }
8607
8608 // Implementation for "set-value-from-element"
8619 public async Task<dynamic> SetValueFromElement(string srcCss, string destCss, string sessionId = null)
8620 {
8621 sessionId ??= GetEffectiveSessionId();
8622 return await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8623 new
8624 {
8625 contextId = CurrentContextId,
8626 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}}));"
8627 }, sessionId).ConfigureAwait(false);
8628 }
8629
8630 // Implementation for "stealth-override-referrer"
8638 public async Task SetGoogleReferrer(string sessionId = null)
8639 {
8640 sessionId ??= GetEffectiveSessionId();
8641 await SendCommand<object>(DevToolsMethods.NetworkSetExtraHTTPHeaders, new { headers = new { Referer = "https://www.google.com" } }, sessionId).ConfigureAwait(false);
8642 }
8643
8644
8645 // Implementation for "switch-to-default-content"
8651 public Task SwitchToDefaultContent(string sessionId = null)
8652 {
8653 // dropping the frame state is the whole of the work, and none of it goes over the wire. window
8654 // ordering is not frame context and belongs to TopBrowser, so nothing is sent from here
8655 CurrentContextId = null;
8656 CurrentFrameId = null;
8657 CurrentFrameSessionId = null;
8658 CurrentRootObjectId = null;
8659
8660 return Task.CompletedTask;
8661 }
8662
8663 /// <summary>
8664 /// Switches the active tab/window to the one identified by a tab index or page URL, activating the corresponding
8665 /// CDP target and updating the active window/tab indices.
8666 /// </summary>
8667 /// <param name="tabIdOrUrl">Either an <see cref="int"/> index into the current tab queue, or a <see cref="string"/> URL to search for among open tabs.</param>
8668 /// <param name="sessionId">Session ID used when looking up the target by URL, or null to use the effective session.</param>
8669 /// <returns>A Task that resolves to the targetId of the activated tab, or null if no matching target was found.</returns>
8670 public async Task<string> GoToWindow(object tabIdOrUrl, string sessionId = null)
8671 {
8672 string targetId = null;
8673
8674 if (tabIdOrUrl is int tabId)
8675 {
8676 var queueArray = CurrentSessions.ToArray();
8677 if (tabId >= 0 && tabId < queueArray.Length)
8678 {
8679 targetId = queueArray[tabId].Key;
8680 // Update active tab index
8681 SetActiveTabIndex(tabId);
8682 }
8683 }
8684 else if (tabIdOrUrl is string url && !string.IsNullOrWhiteSpace(url))
8685 {
8686 targetId = await GetTargetTabIdByUrl(url, sessionId).ConfigureAwait(false);
8687 if (!string.IsNullOrEmpty(targetId))
8688 {
8689 // Find and set active window and tab
8690 int windowIndex = GetCurrentWindowIndex(targetId);
8691 if (windowIndex >= 0)
8692 {
8693 SetActiveWindowIndex(windowIndex);
8694 var windowArray = _windowSessionsQueue.ToArray();
8695 var tabQueue = windowArray[windowIndex].TabQueue.ToArray();
8696 int tabIndex = Array.FindIndex(tabQueue, kvp => kvp.Key == targetId);
8697 if (tabIndex >= 0)
8698 {
8699 SetActiveTabIndex(tabIndex);
8700 }
8701 }
8702 }
8703 }
8704
8705 if (string.IsNullOrEmpty(targetId))
8706 {
8707 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No target found for tabIdOrUrl [{tabIdOrUrl}]", this, GPALObjectType.PuppeteerCommunicator);
8708 return null;
8709 }
8710
8711 await SendCommand<object>(DevToolsMethods.TargetActivateTarget, new { targetId }, null).ConfigureAwait(false);
8712 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
8713 return targetId;
8714 }
8715 // Implementation for "window-inner-height"
8721 public async Task<int> WindowInnerHeight(string sessionId = null)
8722 {
8723 sessionId ??= GetEffectiveSessionId();
8724 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8725 new {
8726 expression = "window.innerHeight",
8727 contextId = CurrentContextId,
8728 returnByValue = true
8729 }, sessionId).ConfigureAwait(false);
8730 return (int)result.value;
8731 }
8732
8733 // Implementation for "window-inner-width"
8739 public async Task<int> WindowInnerWidth(string sessionId = null)
8740 {
8741 sessionId ??= GetEffectiveSessionId();
8742 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8743 new {
8744 expression = "window.innerWidth",
8745 contextId = CurrentContextId,
8746 returnByValue = true
8747 }, sessionId).ConfigureAwait(false);
8748 return (int)result.value;
8749 }
8750
8751 // Implementation for "window-outer-height"
8755
8757 public async Task<int> WindowOuterHeight(string sessionId = null)
8758 {
8759 sessionId ??= GetEffectiveSessionId();
8760 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8761 new {
8762 expression = "window.outerHeight",
8763 contextId = CurrentContextId,
8764 returnByValue = true
8765 }, sessionId).ConfigureAwait(false);
8766 return (int)result.value;
8767 }
8768
8769 // Implementation for "window-outer-width"
8775 public async Task<int> WindowOuterWidth(string sessionId = null)
8776 {
8777 sessionId ??= GetEffectiveSessionId();
8778 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8779 new {
8780 expression = "window.outerWidth",
8781 contextId = CurrentContextId,
8782 returnByValue = true
8783 }, sessionId).ConfigureAwait(false);
8784 return (int)result.value;
8785 }
8786
8787 // Implementation for "fetch"
8801 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)
8802 {
8803 sessionId ??= GetEffectiveSessionId();
8804
8805 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8806 new
8807 {
8808 expression = $@"
8809 (async () => {{
8810 {BrowserHelper.FetchSetupScript(url, method, body, contentType, headers, asBytes)}
8811
8812 try {{
8813 var response = await fetch(target.toString(), options);
8814 return await gpalEnvelope(response);
8815 }} catch (error) {{
8816 return gpalFailure(error);
8817 }}
8818 }})()",
8819 contextId = CurrentContextId,
8820 returnByValue = true,
8821 awaitPromise = true
8822 }, sessionId).ConfigureAwait(false);
8823
8824 // the response comes back deserialized by Newtonsoft, so it is indexed as a JObject rather than
8825 // reached through dynamic member access, which does not resolve to the string.
8826 // read step by step: a CDP error answers no "result" at all, and indexing straight through that
8827 // threw a null reference instead of saying what came back
8828 Newtonsoft.Json.Linq.JObject evaluated = result as Newtonsoft.Json.Linq.JObject;
8829 Newtonsoft.Json.Linq.JToken value = evaluated?["result"]?["value"];
8830
8831 if (null == value)
8832 // SerializeObject, not JToken.ToString(Formatting), whose overload binds against whichever
8833 // Newtonsoft the host app loaded and throws MissingMethodException when that is not ours
8834 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"The page did not answer the fetch. Runtime.evaluate returned [{(null == evaluated ? "nothing" : Newtonsoft.Json.JsonConvert.SerializeObject(evaluated))}]", null, GPALObjectType.PuppeteerCommunicator);
8835
8836 return value?.ToString();
8837 }
8838
8839 // Implementation for "window-page-xoffset"
8845 public async Task<int> WindowPageOffsetX(string sessionId = null)
8846 {
8847 sessionId ??= GetEffectiveSessionId();
8848 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8849 new {
8850 expression = "window.pageXOffset",
8851 contextId = CurrentContextId,
8852 returnByValue = true
8853 }, sessionId).ConfigureAwait(false);
8854 return (int)result.value;
8855 }
8856
8857 // Implementation for "window-page-yoffset"
8863 public async Task<int> WindowPageOffsetY(string sessionId = null)
8864 {
8865 sessionId ??= GetEffectiveSessionId();
8866 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8867 new {
8868 expression = "window.pageYOffset",
8869 contextId = CurrentContextId,
8870 returnByValue = true
8871 }, sessionId).ConfigureAwait(false);
8872 return (int)result.value;
8873 }
8874
8875 // Implementation for "window-screen-left"
8881 public async Task<int> WindowScreenLeft(string sessionId = null)
8882 {
8883 sessionId ??= GetEffectiveSessionId();
8884 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8885 new {
8886 expression = "window.screenLeft",
8887 contextId = CurrentContextId,
8888 returnByValue = true
8889 }, sessionId).ConfigureAwait(false);
8890 return (int)result.value;
8891 }
8892
8893 // Implementation for "window-screen-top"
8899 public async Task<int> WindowScreenTop(string sessionId = null)
8900 {
8901 sessionId ??= GetEffectiveSessionId();
8902 var result = await SendCommand<object>(DevToolsMethods.RuntimeEvaluate,
8903 new {
8904 expression = "window.screenTop",
8905 contextId = CurrentContextId,
8906 returnByValue = true
8907 }, sessionId).ConfigureAwait(false);
8908 return (int)result.value;
8909 }
8910
8911 // Helper for getting nodeId by elementId
8919 private async Task<int> GetNodeIdFromBackendNodeId(int backendNodeId, string sessionId = null)
8920 {
8921 sessionId ??= GetEffectiveSessionId();
8922
8923 // Ensure we have a DOM loaded
8924 await SendCommand<dynamic>(DevToolsMethods.DOMGetDocument, new { depth = 1 }, sessionId).ConfigureAwait(false);
8925
8926 // Push the backend node ID to get a current, valid node ID
8927 var result = await SendCommand<dynamic>(
8928 DevToolsMethods.DOMPushNodesByBackendIdsToFrontend,
8929 new { backendNodeIds = new[] { backendNodeId } },
8930 sessionId
8931 ).ConfigureAwait(false);
8932
8933 // The result has nodeIds as an array of integers
8934 if (result?.nodeIds is Newtonsoft.Json.Linq.JArray nodeIdsArray && nodeIdsArray.Count > 0)
8935 {
8936 return nodeIdsArray[0].Value<int>();
8937 }
8938
8939 return 0;
8940 }
8942
8944 public async Task<string> GetObjectIdFromBackendNodeId(int backendNodeId, string sessionId = null, long? contextId = null)
8945 {
8946 sessionId ??= GetEffectiveSessionId();
8947
8948 // Use iframe session if we're currently in an iframe
8949 string effectiveSessionId = !string.IsNullOrEmpty(CurrentFrameSessionId)
8950 ? CurrentFrameSessionId
8951 : sessionId;
8952
8953 // Use provided contextId or fall back to current iframe context
8954 long? effectiveContextId = contextId ?? CurrentContextId;
8955
8956 try
8957 {
8958 // Step 1: Resolve backendNodeId > nodeId (in the correct session)
8959 var resolveNodeParams = new Dictionary<string, object>
8960 {
8961 ["backendNodeId"] = backendNodeId
8962 };
8963
8964 // Add contextId only if we have one (important for iframes)
8965 if (effectiveContextId.HasValue)
8966 {
8967 resolveNodeParams["executionContextId"] = effectiveContextId.Value;
8968 }
8969
8970 var resolveResult = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, resolveNodeParams, effectiveSessionId).ConfigureAwait(false);
8971
8972 string objectId = resolveResult?.@object?.objectId?.ToString();
8973
8974 if (!string.IsNullOrEmpty(objectId))
8975 return objectId;
8976
8977 // Fallback: Try without executionContextId
8978 var fallbackResult = await SendCommand<dynamic>(DevToolsMethods.DOMResolveNode, new
8979 {
8980 backendNodeId = backendNodeId
8981 }, effectiveSessionId).ConfigureAwait(false);
8982
8983 return fallbackResult?.@object?.objectId?.ToString() ?? string.Empty;
8984 }
8985 catch (Exception ex)
8986 {
8987 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
8988 $"GetObjectIdFromBackendNodeId failed for backendNodeId [{backendNodeId}]",
8989 this, GPALObjectType.PuppeteerCommunicator, ex);
8990
8991 return string.Empty;
8992 }
8993 }
8994
8995 // Helper for getting element X coordinate
9003 private async Task<int> GetElementRandomCenterX(int backendNodeId, string sessionId = null)
9004 {
9005 sessionId ??= GetEffectiveSessionId();
9006 var rect = await GetBoundingClientRect(backendNodeId, sessionId).ConfigureAwait(false);
9007 return (int)rect.X + rect.Width / new Random().Next(2, 3);
9008 }
9009
9010 // Helper for getting element Y coordinate
9018 private async Task<int> GetElementRandomCenterY(int backendNodeId, string sessionId = null)
9019 {
9020 sessionId ??= GetEffectiveSessionId();
9021 var rect = await GetBoundingClientRect(backendNodeId, sessionId).ConfigureAwait(false);
9022 return (int)rect.Y + rect.Width / new Random().Next(2, 3);
9023 }
9024 // get the json protocol to dynamically build a list of extraction variable for sendcommand to return useful data
9030 internal string GetProtocolJSON()
9031 {
9032 using var http = new System.Net.Http.HttpClient();
9033 var json = http.GetStringAsync($"{_puppeteerUrl}/json/protocol").Result;
9034 return Newtonsoft.Json.Linq.JObject.Parse(json).ToString();
9035 }
9040 internal string GetActiveWindowId()
9041 {
9042 var windows = _windowSessionsQueue.ToArray();
9043 if (_activeWindowIndex < 0 || _activeWindowIndex >= windows.Length)
9044 return null;
9045 return windows[_activeWindowIndex].BrowserContextId;
9046 }
9047 // Get active window's tab queue (embedded in windows queue)
9052 private ConcurrentQueue<KeyValuePair<string, string>> GetActiveWindowTabQueue()
9053 {
9054 var windowArray = _windowSessionsQueue.ToArray();
9055 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length) return null;
9056 return windowArray[_activeWindowIndex].TabQueue;
9057 }
9058
9064 public string GetEffectiveSessionId()
9065 {
9066 return !string.IsNullOrEmpty(CurrentFrameSessionId)
9067 ? CurrentFrameSessionId
9068 : GetCurrentSessionId();
9069 }
9070
9071 // Get current sessionId (active window -> active tab)
9084 internal void ClearCurrentSessionId()
9085 {
9086 var windowArray = _windowSessionsQueue.ToArray();
9087 if (0 == windowArray.Length || _activeWindowIndex >= windowArray.Length)
9088 return;
9089
9090 var window = windowArray[_activeWindowIndex];
9091 var tabArray = window.TabQueue.ToArray();
9092 if (0 == tabArray.Length || window.ActiveTabIndex >= tabArray.Length)
9093 return;
9094
9095 string targetId = tabArray[window.ActiveTabIndex].Key;
9096 var newQueue = new ConcurrentQueue<KeyValuePair<string, string>>();
9097
9098 foreach (var kvp in tabArray)
9099 newQueue.Enqueue(kvp.Key == targetId ? new KeyValuePair<string, string>(kvp.Key, null) : kvp);
9100
9101 window.TabQueue = newQueue;
9102 }
9103 public string GetCurrentSessionId()
9104 {
9105 var windowArray = _windowSessionsQueue.ToArray();
9106 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length) return null;
9107
9108 var window = windowArray[_activeWindowIndex];
9109 var tabQueue = window.TabQueue;
9110 var tabArray = tabQueue.ToArray();
9111 var activeTabIndex = window.ActiveTabIndex;
9112 if (tabArray.Length == 0 || activeTabIndex >= tabArray.Length) return null;
9113
9114 var targetId = tabArray[activeTabIndex].Key;
9115 var sessionId = tabArray[activeTabIndex].Value;
9116
9117 if (sessionId == null)
9118 {
9119 var attachResult = SendCommand<dynamic>(DevToolsMethods.TargetAttachToTarget, new
9120 {
9121 targetId,
9122 flatten = true
9123 }, null).GetAwaiter().GetResult();
9124
9125 string newSessionId = attachResult.sessionId;
9126
9127 // rebuild queue with updated value
9128 var newQueue = new ConcurrentQueue<KeyValuePair<string, string>>();
9129 foreach (var kvp in tabArray)
9130 newQueue.Enqueue(kvp.Key == targetId
9131 ? new KeyValuePair<string, string>(kvp.Key, newSessionId)
9132 : kvp);
9133
9134 window.TabQueue = newQueue;
9135 sessionId = newSessionId;
9136 }
9137
9138 return sessionId;
9139 }
9140
9141 // Get current targetId (active window -> active tab)
9146 public string GetCurrentTargetId()
9147 {
9148 var windowArray = _windowSessionsQueue.ToArray();
9149 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length) return null;
9150 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
9151 var tabArray = tabQueue.ToArray();
9152 var activeTabIndex = windowArray[_activeWindowIndex].ActiveTabIndex;
9153 if (tabArray.Length == 0 || activeTabIndex >= tabArray.Length) return null;
9154 return tabArray[activeTabIndex].Key;
9155 }
9156 // Get current window targetId (active window)
9161 public string GetCurrentWindowTargetId()
9162 {
9163 var windowArray = _windowSessionsQueue.ToArray();
9164
9165 return windowArray[_activeWindowIndex].BrowserContextId;
9166 }
9167 // Add tab to active window's embedded tab queue, set as active tab
9177 internal async Task AddTabToQueue(string targetId, string sessionId, bool isActive = true, bool doNotGetSendSemaphore = false)
9178 {
9179 var windowArray = _windowSessionsQueue.ToArray();
9180 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
9181 {
9182 // TODO: this needs to be pushed onto a queue, then the next time send
9183 var targets = await SendCommand<object>(DevToolsMethods.TargetGetTargets, new { }, null, 3, doNotGetSendSemaphore).ConfigureAwait(false);
9184 var target = ((IEnumerable<dynamic>)targets.targetInfos)
9185 .FirstOrDefault(t => t.targetId == targetId && t.type == "page");
9186
9187 if (target == null)
9188 {
9189 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Target ID [{targetId}] not found in CDP", this, GPALObjectType.PuppeteerCommunicator);
9190 return;
9191 }
9192
9193 string browserContextId = target.browserContextId?.ToString() ?? "";
9194
9195 var newTabQueue = new ConcurrentQueue<KeyValuePair<string, string>>();
9196 newTabQueue.Enqueue(new KeyValuePair<string, string>(targetId, sessionId));
9197 _windowSessionsQueue.Enqueue(new WindowSession(browserContextId, newTabQueue, 0));
9198 _activeWindowIndex = 0;
9199 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
9200 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Created window with browserContextId [{browserContextId}], added tab ID [{targetId}], Active window [0], tab [0]", this, GPALObjectType.PuppeteerCommunicator);
9201 return;
9202 }
9203
9204 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
9205 tabQueue.Enqueue(new KeyValuePair<string, string>(targetId, sessionId));
9206
9207 // Update ActiveTabIndex in the WindowSession
9208 var windowList = _windowSessionsQueue.ToList();
9209 windowList[_activeWindowIndex] = new WindowSession(
9210 windowList[_activeWindowIndex].BrowserContextId,
9211 tabQueue,
9212 true == isActive ? tabQueue.Count - 1 : windowList[_activeWindowIndex].ActiveTabIndex // we do not know if the new tab is active, just that it is being added
9213 );
9214 _windowSessionsQueue = new ConcurrentQueue<WindowSession>();
9215 foreach (var w in windowList)
9216 {
9217 _windowSessionsQueue.Enqueue(w);
9218 }
9219 var newTabIdx = tabQueue.Count - 1;
9220
9221 if (true == isActive)
9222 ((PuppeteerClient)PuppeteerClient)._currentTargetId = targetId;
9223
9224 // said separately, because the tab that was added and the tab that is active are only the same when
9225 // the tab was added as active. printing the added position as "Active tab index" read as a switch
9226 // that had not happened
9227 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Added tab to active window at index [{newTabIdx}], ID [{targetId}], Tab queue size [{tabQueue.Count}], active tab is now index [{windowList[_activeWindowIndex].ActiveTabIndex}]", this, GPALObjectType.PuppeteerCommunicator);
9228 } // Set active tab index in active window's embedded tab queue
9234 internal void SetActiveTabIndex(int index)
9235 {
9236 var windowArray = _windowSessionsQueue.ToArray();
9237 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
9238 {
9239 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No active window for SetActiveTabIndex", this, GPALObjectType.PuppeteerCommunicator);
9240 return;
9241 }
9242 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
9243 var tabArray = tabQueue.ToArray();
9244 index = index % tabArray.Length;
9245 if (index < 0) index += tabArray.Length;
9246
9247 // Update ActiveTabIndex in the WindowSession
9248 var windowList = _windowSessionsQueue.ToList();
9249 windowList[_activeWindowIndex] = new WindowSession(
9250 windowList[_activeWindowIndex].BrowserContextId,
9251 windowList[_activeWindowIndex].TabQueue,
9252 index
9253 );
9254 _windowSessionsQueue = new ConcurrentQueue<WindowSession>();
9255 foreach (var w in windowList)
9256 {
9257 _windowSessionsQueue.Enqueue(w);
9258 }
9259
9260 ((PuppeteerClient)PuppeteerClient)._currentTargetId = tabArray[index].Key;
9261 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Set active tab index [{index}] in active window, ID [{tabArray[index].Key}]", this, GPALObjectType.PuppeteerCommunicator);
9262 }
9263 // Remove tab from active window's embedded tab queue by targetId
9270 internal bool RemoveTabFromQueue(string targetId)
9271 {
9272 var windowArray = _windowSessionsQueue.ToArray();
9273 if (windowArray.Length == 0 || _activeWindowIndex >= windowArray.Length)
9274 {
9275 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No active window for RemoveTabFromQueue [{targetId}]", this, GPALObjectType.PuppeteerCommunicator);
9276 return false;
9277 }
9278
9279 var tabQueue = windowArray[_activeWindowIndex].TabQueue;
9280 var queueList = tabQueue.ToList();
9281 var indexToRemove = queueList.FindIndex(kvp => kvp.Key == targetId);
9282 if (indexToRemove == -1)
9283 {
9284 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Tab ID [{targetId}] not found in active window queue", this, GPALObjectType.PuppeteerCommunicator);
9285 return false;
9286 }
9287
9288 queueList.RemoveAt(indexToRemove);
9289 var newTabQueue = new ConcurrentQueue<KeyValuePair<string, string>>();
9290 foreach (var kvp in queueList)
9291 {
9292 newTabQueue.Enqueue(kvp);
9293 }
9294
9295 // Update ActiveTabIndex in WindowSession
9296 var newActiveTabIndex = windowArray[_activeWindowIndex].ActiveTabIndex;
9297 if (newTabQueue.IsEmpty)
9298 {
9299 newActiveTabIndex = 0;
9300 }
9301 else if (newActiveTabIndex > indexToRemove)
9302 {
9303 newActiveTabIndex--;
9304 }
9305 else if (newActiveTabIndex == indexToRemove)
9306 {
9307 newActiveTabIndex = Math.Max(0, newActiveTabIndex - 1);
9308 }
9309
9310 var windowList = _windowSessionsQueue.ToList();
9311 windowList[_activeWindowIndex] = new WindowSession(
9312 windowList[_activeWindowIndex].BrowserContextId,
9313 newTabQueue,
9314 newActiveTabIndex
9315 );
9316 _windowSessionsQueue = new ConcurrentQueue<WindowSession>();
9317 foreach (var w in windowList)
9318 {
9319 _windowSessionsQueue.Enqueue(w);
9320 }
9321
9322 ((PuppeteerClient)PuppeteerClient)._currentTargetId = newTabQueue.IsEmpty ? null : newTabQueue.ToArray()[newActiveTabIndex].Key;
9323 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Removed tab ID [{targetId}] from active window, Active tab index now [{newActiveTabIndex}], Tab queue size [{newTabQueue.Count}]", this, GPALObjectType.PuppeteerCommunicator);
9324 return true;
9325 }
9326
9332 public int GetCurrentWindowIndex(string targetId)
9333 {
9334 var windows = _windowSessionsQueue.ToArray(); // assuming you track sessions per window
9335 for (int i = 0; i < windows.Length; i++)
9336 {
9337 foreach (var tab in windows[i].TabQueue)
9338 if (tab.Key == targetId)
9339 return i;
9340 }
9341 return -1;
9342 }
9343
9345 /// Sets the active window index, wrapping the index into range, and updates the current target ID
9346 /// to the active tab of the newly active window.
9347 /// </summary>
9348 /// <param name="index">The desired window index, wrapped (modulo) into the valid range of the window queue.</param>
9349 internal void SetActiveWindowIndex(int index)
9350 {
9351 var windowArray = _windowSessionsQueue.ToArray();
9352 if (windowArray.Length == 0)
9353 {
9354 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "No windows for SetActiveWindowIndex", this, GPALObjectType.PuppeteerCommunicator);
9355 return;
9356 }
9357 index = index % windowArray.Length;
9358 if (index < 0) index += windowArray.Length;
9359 _activeWindowIndex = index;
9360
9361 ((PuppeteerClient)PuppeteerClient)._currentTargetId = GetCurrentTargetId();
9362 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Set active window index [{_activeWindowIndex}], Active tab index [{windowArray[_activeWindowIndex].ActiveTabIndex}], Target ID [{((PuppeteerClient)PuppeteerClient)._currentTargetId}]", this, GPALObjectType.PuppeteerCommunicator);
9363 }
9364 // Remove window from queue by browserContextId or targetId
9372 internal bool RemoveWindowFromQueue(string browserContextId = null, string targetId = null)
9373 {
9374 var windowArray = _windowSessionsQueue.ToArray();
9375 var indexToRemove = -1;
9376
9377 if (browserContextId != null)
9378 {
9379 indexToRemove = Array.FindIndex(windowArray, w => w.BrowserContextId == browserContextId);
9380 }
9381 else if (targetId != null)
9382 {
9383 for (int i = 0; i < windowArray.Length; i++)
9384 {
9385 var tabArray = windowArray[i].TabQueue.ToArray();
9386 if (Array.Exists(tabArray, kvp => kvp.Key == targetId))
9387 {
9388 indexToRemove = i;
9389 browserContextId = windowArray[i].BrowserContextId;
9390 break;
9391 }
9392 }
9393 }
9394
9395 if (indexToRemove == -1)
9396 {
9397 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Window/Context ID [{browserContextId ?? targetId}] not found", this, GPALObjectType.PuppeteerCommunicator);
9398 return false;
9399 }
9400
9401 var queueList = _windowSessionsQueue.ToList();
9402 queueList.RemoveAt(indexToRemove);
9403
9404 _windowSessionsQueue = new ConcurrentQueue<WindowSession>();
9405 foreach (var w in queueList)
9406 {
9407 _windowSessionsQueue.Enqueue(w);
9408 }
9409
9410 if (queueList.Count == 0)
9411 {
9412 _activeWindowIndex = 0;
9413 ((PuppeteerClient)PuppeteerClient)._currentTargetId = null;
9414 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"No windows remain after removing window with browserContextId [{browserContextId}]", this, GPALObjectType.PuppeteerCommunicator);
9415 }
9416 else
9417 {
9418 if (_activeWindowIndex > indexToRemove)
9419 {
9420 _activeWindowIndex--;
9421 }
9422 else if (_activeWindowIndex == indexToRemove)
9423 {
9424 _activeWindowIndex = Math.Max(0, _activeWindowIndex - 1);
9425 }
9426 ((PuppeteerClient)PuppeteerClient)._currentTargetId = GetCurrentTargetId();
9427 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);
9428 }
9429
9430 return true;
9431 }
9441 public async Task<bool> ScrollPageAsync(string direction, int pagesToScroll, string s)
9442 {
9443 double initialScrollY = 0;
9444 bool retVal = false;
9445
9446 try
9447 {
9448 // Validate direction
9449 if (direction != "up" && direction != "down")
9450 {
9451 return retVal;
9452 }
9453
9454 // Step 1: Check overflow property
9455 var overflowResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9456 {
9457 expression = @"getComputedStyle(document.documentElement).overflow",
9458 contextId = CurrentContextId,
9459 returnByValue = true
9460 }, s).ConfigureAwait(false);
9461 string overflow = overflowResult?["result"]?["value"].ToString();
9462
9463 // Step 2: Try window.scrollBy if overflow is not 'hidden' - which means we have scroll bars
9464 if (overflow != "hidden")
9465 {
9466 for (int count = 0; count < pagesToScroll; count++)
9467 {
9468 if (true == (retVal = await scrollPage().ConfigureAwait(false)))
9469 continue;
9470 else
9471 break;
9472 }
9473 }
9474 if (false == retVal)
9475 for (int count = 0; count < pagesToScroll; count++)
9476 {
9477
9478 if (true == (retVal = await scrollSinglePageApp().ConfigureAwait(false)))
9479 continue;
9480 else
9481 break;
9482 }
9483 }
9484 catch (Exception ex)
9485 {
9486 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to page [{direction}] for [{pagesToScroll}] pages.", this, GPALObjectType.PuppeteerClient, ex);
9487 }
9489 async Task<bool> scrollPage()
9490 {
9491 // Get initial scroll position
9492 var initialScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9493 {
9494 expression = @"window.scrollY",
9495 contextId = CurrentContextId,
9496 returnByValue = true
9497 }, s).ConfigureAwait(false);
9498 initialScrollY = Convert.ToDouble(initialScrollResult?["result"]?["value"]);
9499
9500 // Try window.scrollBy
9501 string scrollDirection = direction == "down" ? "window.innerHeight" : "-window.innerHeight";
9502 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
9503 {
9504 expression = $"window.scrollBy({{ left: 0, top: {scrollDirection}, behavior: 'instant' }})",
9505 contextId = CurrentContextId,
9506 returnByValue = true
9507 }, s).ConfigureAwait(false);
9508
9509 // Verify scroll
9510 var afterScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9511 {
9512 expression = @"window.scrollY",
9513 contextId = CurrentContextId,
9514 returnByValue = true
9515
9516 }, s).ConfigureAwait(false);
9517 double afterScrollY = Convert.ToDouble(afterScrollResult?["result"]?["value"]);
9518
9519 if (afterScrollY != initialScrollY)
9520 {
9521 return true; // Scroll worked
9522 }
9523 return false;
9524 }
9525
9526 async Task<bool> scrollSinglePageApp()
9527 {
9528 // Step 3: Try scrolling the scrollable element
9529 var elementScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9530 {
9531 expression = $@"(function() {{
9532 const isDown = '{direction}' === 'down';
9533 const scrollAmount = isDown ? window.innerHeight : -window.innerHeight;
9534 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9535 el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
9536 ) || document.body;
9537 const initialScrollTop = scrollable.scrollTop;
9538 scrollable.scrollBy({{ left: 0, top: scrollAmount, behavior: 'instant' }});
9539 return {{ initialScrollTop, scrollTop: scrollable.scrollTop, element: scrollable.className || 'body' }};
9540 }})()",
9541 contextId = CurrentContextId,
9542 returnByValue = true
9543 }, s).ConfigureAwait(false);
9544
9545 double initialScrollTop = Convert.ToDouble(elementScrollResult?["result"]?["value"]["initialScrollTop"]);
9546 double elementScrollTop = Convert.ToDouble(elementScrollResult?["result"]?["value"]["scrollTop"]);
9547
9548 if (elementScrollTop != initialScrollTop)
9549 {
9550 return true; // Scroll worked
9551 }
9552
9553 return await scrollByKeyPress().ConfigureAwait(false);
9554
9555 async Task<bool> scrollByKeyPress()
9556 {
9557 // Step 4: Fallback to Page Up/Down key press
9558 // Focus scrollable element
9559 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
9560 {
9561 expression = @"(function() {
9562 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9563 el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
9564 ) || document.body;
9565 scrollable.focus();
9566 return scrollable.className || 'body';
9567 })()",
9568 contextId = CurrentContextId,
9569 returnByValue = true
9570 }, s).ConfigureAwait(false);
9571
9572 // Send keyDown
9573 int keyCode = direction == "down" ? 34 : 33;
9574 string keyName = direction == "down" ? "PageDown" : "PageUp";
9575 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, new
9576 {
9577 type = "keyDown",
9578 windowsVirtualKeyCode = keyCode,
9579 key = keyName,
9580 code = keyName
9581 }, s).ConfigureAwait(false);
9582
9583 // Send keyUp
9584 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, new
9585 {
9586 type = "keyUp",
9587 windowsVirtualKeyCode = keyCode,
9588 key = keyName,
9589 code = keyName
9590 }, s).ConfigureAwait(false);
9591
9592 // Step 5: Verify scroll
9593 var keyScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9594 {
9595 expression = @"(function() {
9596 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9597 el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
9598 ) || document.body;
9599 return { scrollTop: scrollable.scrollTop, element: scrollable.className || 'body' };
9600 })()",
9601 contextId = CurrentContextId,
9602 returnByValue = true
9603 }, s).ConfigureAwait(false);
9604
9605 double keyScrollTop = Convert.ToDouble(keyScrollResult?["result"]?["value"]["scrollTop"]);
9606 return keyScrollTop != initialScrollTop || keyScrollTop > 0; // Return true if scrolled
9607 }
9608 }
9609
9610 return retVal;
9611 }
9619 public async Task<bool> ScrollToPositionAsync(string position, string s)
9620 {
9621 try
9622 {
9623 // Validate position
9624 if (position != "top" && position != "end")
9625 {
9626 return false;
9627 }
9628
9629 // Step 1: Check overflow property
9630 var overflowResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9631 {
9632 expression = $@"(function() {{
9633 return getComputedStyle(document.documentElement).overflow;
9634 }})()",
9635 contextId = CurrentContextId,
9636 returnByValue = true
9637 }, s).ConfigureAwait(false);
9638 string overflow = overflowResult?["result"]?["value"].ToString();
9639
9640 // Step 2: Try window.scrollTo if overflow is not 'hidden'
9641 double initialScrollY = 0;
9642 if (overflow != "hidden")
9643 {
9644 // Get initial scroll position
9645 var initialScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9646 {
9647 expression = @"window.scrollY",
9648 contextId = CurrentContextId,
9649 returnByValue = true
9650 }, s).ConfigureAwait(false);
9651 initialScrollY = Convert.ToDouble(initialScrollResult?["result"]?["value"]);
9652
9653 // Try window.scrollTo
9654 string scrollTarget = position == "end" ? "document.body.scrollHeight" : "0";
9655 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
9656 {
9657 expression = $"window.scrollTo(0, {scrollTarget})",
9658 contextId = CurrentContextId,
9659 returnByValue = true
9660 }, s).ConfigureAwait(false);
9661
9662 // Verify scroll
9663 var afterScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9664 {
9665 contextId = CurrentContextId,
9666 expression = @"window.scrollY"
9667 }, s).ConfigureAwait(false);
9668 double afterScrollY = Convert.ToDouble(afterScrollResult?["result"]?["value"]);
9669
9670 if (afterScrollY != initialScrollY)
9671 {
9672 return true; // Scroll worked
9673 }
9674 }
9675
9676 // Step 3: Try scrolling the scrollable element
9677 var elementScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9678 {
9679 expression = $@"(function() {{
9680 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9681 el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
9682 ) || document.body;
9683 const initialScrollTop = scrollable.scrollTop;
9684 const scrollTarget = {(position == "end" ? "scrollable.scrollHeight" : "0")};
9685 scrollable.scrollTo({{left: 0, top: scrollTarget, behavior: 'instant'}});
9686 return {{ initialScrollTop, scrollTop: scrollable.scrollTop, element: scrollable.className || 'body' }};
9687 }})()",
9688 contextId = CurrentContextId,
9689 returnByValue = true
9690 }, s).ConfigureAwait(false);
9691
9692 double initialScrollTop = Convert.ToDouble(elementScrollResult?["result"]?["value"]["initialScrollTop"]);
9693 double elementScrollTop = Convert.ToDouble(elementScrollResult?["result"]?["value"]["scrollTop"]);
9694
9695 if (elementScrollTop != initialScrollTop)
9696 {
9697 return true; // Scroll worked
9698 }
9699
9700 // Step 4: Fallback to Home/End key press
9701 // Focus scrollable element
9702 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
9703 {
9704 expression = @"(function() {
9705 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9706 el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
9707 ) || document.body;
9708 scrollable.focus();
9709 return scrollable.className || 'body';
9710 })()",
9711 contextId = CurrentContextId,
9712 returnByValue = true
9713 }, s).ConfigureAwait(false);
9714
9715 // Send keyDown
9716 int keyCode = position == "end" ? 35 : 36;
9717 string keyName = position == "end" ? "End" : "Home";
9718 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, new
9719 {
9720 type = "keyDown",
9721 windowsVirtualKeyCode = keyCode,
9722 key = keyName,
9723 code = keyName
9724 }, s).ConfigureAwait(false);
9726 // Send keyUp
9727 await SendCommand<object>(DevToolsMethods.InputDispatchKeyEvent, new
9728 {
9729 type = "keyUp",
9730 windowsVirtualKeyCode = keyCode,
9731 key = keyName,
9732 code = keyName
9733 }, s).ConfigureAwait(false);
9735 // Step 5: Verify scroll
9736 var keyScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9737 {
9738 expression = @"(function() {
9739 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9740 el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
9741 ) || document.body;
9742 return { scrollTop: scrollable.scrollTop, element: scrollable.className || 'body' };
9743 })()",
9744 contextId = CurrentContextId,
9745 returnByValue = true
9746 }, s).ConfigureAwait(false);
9747
9748 double keyScrollTop = Convert.ToDouble(keyScrollResult?["result"]?["value"]["scrollTop"]);
9749 return keyScrollTop != initialScrollTop || keyScrollTop > 0; // Return true if scrolled
9751 catch
9752 {
9753 return false;
9754 }
9755 }
9765 public async Task<bool> ScrollWindowByPixelsAsync(int hPixels, int vPixels, string sessionId)
9766 {
9767 try
9768 {
9769 // Check for main window scrollability
9770 var overflowResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9771 {
9772 expression = $@"(function() {{
9773 return getComputedStyle(document.documentElement).overflow;
9774 }})()",
9775 contextId = CurrentContextId,
9776 returnByValue = true
9777 }, sessionId).ConfigureAwait(false);
9778 string overflow = overflowResult?.result?.value?.ToString();
9779
9780 // Try window.scrollBy if overflow is not 'hidden'
9781 if (overflow != "hidden")
9782 {
9783 // Get initial scroll position
9784 var initialScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9785 {
9786 expression = @"[window.scrollX, window.scrollY]",
9787 contextId = CurrentContextId,
9788 returnByValue = true
9789 }, sessionId).ConfigureAwait(false);
9790 var initialScrollX = initialScrollResult?.result?.value?.First;
9791 var initialScrollY = initialScrollResult?.result?.value?.Last;
9792
9793 // Try window.scrollBy
9794 await SendCommand<object>(DevToolsMethods.RuntimeEvaluate, new
9795 {
9796 expression = $"window.scrollBy({hPixels}, {vPixels})",
9797 contextId = CurrentContextId,
9798 returnByValue = true
9799 }, sessionId).ConfigureAwait(false);
9800
9801 // Verify scroll
9802 var afterScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9803 {
9804 expression = @"[window.scrollX, window.scrollY]",
9805 contextId = CurrentContextId,
9806 returnByValue = true
9807 }, sessionId).ConfigureAwait(false);
9808 var afterScrollX = afterScrollResult?.result?.value?.First;
9809 var afterScrollY = afterScrollResult?.result?.value?.Last;
9810
9811 if (afterScrollX != initialScrollX || afterScrollY != initialScrollY)
9812 {
9813 return true; // Scroll worked
9814 }
9815 }
9816
9817 // Try scrolling the scrollable element
9818 var elementScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9819 {
9820 expression = $@"(function() {{
9821 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9822 (el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden') ||
9823 (el.scrollWidth > el.clientWidth && getComputedStyle(el).overflowX !== 'hidden')
9824 ) || document.body;
9825 const initialScrollLeft = scrollable.scrollLeft;
9826 const initialScrollTop = scrollable.scrollTop;
9827 scrollable.scrollBy({hPixels}, {vPixels});
9828 return {{ initialScrollLeft, initialScrollTop, scrollLeft: scrollable.scrollLeft, scrollTop: scrollable.scrollTop, element: scrollable.className || 'body' }};
9829 }})()",
9830 contextId = CurrentContextId,
9831 returnByValue = true
9832 }, sessionId).ConfigureAwait(false);
9833
9834 double initialScrollLeft = Convert.ToDouble(elementScrollResult?.result?.value?.initialScrollLeft ?? 0);
9835 double initialScrollTop = Convert.ToDouble(elementScrollResult?.result?.value?.initialScrollTop ?? 0);
9836 double elementScrollLeft = Convert.ToDouble(elementScrollResult?.result?.value?.scrollLeft ?? 0);
9837 double elementScrollTop = Convert.ToDouble(elementScrollResult?.result?.value?.scrollTop ?? 0);
9838
9839 if (elementScrollLeft != initialScrollLeft || elementScrollTop != initialScrollTop)
9840 {
9841 return true; // Scroll worked
9842 }
9843
9844 // Fallback to Input.synthesizeScrollGesture (touch-based scrolling)
9845 // This is a more robust alternative to keyboard keys for relative pixel scrolling
9846 await SendCommand<object>(DevToolsMethods.InputSynthesizeScrollGesture, new
9847 {
9848 x = 100, // Arbitrary start position
9849 y = 100, // Arbitrary start position
9850 xDistance = -hPixels, // Note: xDistance is positive for scrolling left
9851 yDistance = -vPixels, // Note: yDistance is positive for scrolling up
9852 speed = 800 // Optional speed control
9853 }, sessionId).ConfigureAwait(false);
9854
9855 // Verify scroll again after the gesture
9856 var finalScrollResult = await SendCommand<dynamic>(DevToolsMethods.RuntimeEvaluate, new
9857 {
9858 expression = @"(function() {
9859 const scrollable = Array.from(document.querySelectorAll('*')).find(el =>
9860 (el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden') ||
9861 (el.scrollWidth > el.clientWidth && getComputedStyle(el).overflowX !== 'hidden')
9862 ) || document.body;
9863 return { scrollLeft: scrollable.scrollLeft, scrollTop: scrollable.scrollTop };
9864 })()",
9865 contextId = CurrentContextId,
9866 returnByValue = true
9867 }, sessionId).ConfigureAwait(false);
9868
9869 double finalScrollLeft = Convert.ToDouble(finalScrollResult?.result?.value?.scrollLeft ?? 0);
9870 double finalScrollTop = Convert.ToDouble(finalScrollResult?.result?.value?.scrollTop ?? 0);
9871
9872 // This verification is simpler; just check that a change occurred.
9873 return (finalScrollLeft != initialScrollLeft || finalScrollTop != initialScrollTop);
9874 }
9875 catch
9876 {
9877 return false;
9878 }
9879 }
9888 public async Task<bool> SelectByValue(string ElementHandle, string value, string sessionId = null)
9889 {
9890 var result = await SendCommand<dynamic>(
9891 DevToolsMethods.RuntimeCallFunctionOn,
9892 new
9893 {
9894 objectId = ElementHandle,
9895 contextId = CurrentContextId,
9896 functionDeclaration = @"
9897 function(value) {
9898 const oldValue = this.value;
9899 this.value = value;
9900 const changed = this.value !== oldValue;
9901 this.dispatchEvent(new Event('input', { bubbles: true }));
9902 this.dispatchEvent(new Event('change', { bubbles: true }));
9903 return changed;
9904 }",
9905 arguments = new[] { new { value = value } }
9906 },
9907 sessionId ?? GetEffectiveSessionId()).ConfigureAwait(false);
9908
9909 return result?.result?.value == true;
9910 }
9919 public async Task<bool> SelectByIndex(string ElementHandle, int index, string sessionId = null)
9920 {
9921 var result = await SendCommand<dynamic>(
9922 DevToolsMethods.RuntimeCallFunctionOn,
9923 new
9924 {
9925 objectId = ElementHandle,
9926 contextId = CurrentContextId,
9927 functionDeclaration = @"
9928 function(idx) {
9929 if (idx < 0 || idx >= this.options.length) {
9930 return false;
9931 }
9932 const oldIndex = this.selectedIndex;
9933 this.selectedIndex = idx;
9934 const changed = this.selectedIndex !== oldIndex;
9935 this.dispatchEvent(new Event('input', { bubbles: true }));
9936 this.dispatchEvent(new Event('change', { bubbles: true }));
9937 return changed;
9938 }",
9939 arguments = new[] { new { value = index } }
9940 },
9941 sessionId ?? GetEffectiveSessionId()).ConfigureAwait(false);
9942
9943 return result?.result?.value == true;
9944 }
9945 #region HELPERS
9952 internal async Task<bool> SetWindowSize(Rectangle windowSize, string sessionId = null)
9953 {
9954 sessionId ??= GetEffectiveSessionId();
9955
9956 var screen = System.Windows.Forms.Screen.PrimaryScreen.Bounds;
9957
9958 var windowId = await GetCurrentWindow(sessionId).ConfigureAwait(false);
9959
9960 await SendCommand(DevToolsMethods.BrowserSetWindowBounds, new
9961 {
9962 windowId,
9963 bounds = new
9964 {
9965 left = windowSize.X,
9966 top = windowSize.Y,
9967 width = windowSize.Width,
9968 height = windowSize.Height
9969 }
9970 }, sessionId).ConfigureAwait(false);
9971
9972 return true;
9973 }
9974 #endregion HELPERS
9975 }
9976 internal static class DevToolsCommandBuilder
9977 {
9978 // Complete list of CDP DOM methods that require a nodeId parameter
9979 // Based on Chrome DevTools Protocol (version 1.3, stable as of Chrome 126+)
9980 internal static readonly HashSet<string> MethodsRequiringNodeId = new HashSet<string>
9981 {
9982 // DOM domain methods that require nodeId
9983 "DOM.copyTo",
9984 "DOM.describeNode",
9985 "DOM.focus",
9986 "DOM.getAttributes",
9987 "DOM.getBoxModel",
9988 "DOM.getContentQuads",
9989 "DOM.getFlattenedDocument",
9990 "DOM.getNodeForLocation",
9991 "DOM.getOuterHTML",
9992 "DOM.getRelayoutBoundary",
9993 "DOM.moveTo",
9994 "DOM.querySelector",
9995 "DOM.querySelectorAll",
9996 "DOM.removeAttribute",
9997 "DOM.removeNode",
9998 "DOM.requestNode",
9999 "DOM.setAttributeValue",
10000 "DOM.setAttributesAsText",
10001 "DOM.setNodeName",
10002 "DOM.setNodeValue",
10003 "DOM.setOuterHTML",
10004 "DOM.collectClassNames",
10005 "DOM.getNodeStackTraces",
10006 "DOM.scrollIntoViewIfNeeded",
10007 // Accessibility domain methods that require nodeId
10008 "Accessibility.getPartialAXTree",
10009 "Accessibility.getFullAXTree",
10010 // CSS domain methods that require nodeId
10011 "CSS.getComputedStyleForNode",
10012 "CSS.getInlineStylesForNode",
10013 "CSS.getMatchedStylesForNode",
10014 "CSS.getBackgroundColors",
10015 // Add any experimental or newer methods if needed (none currently require nodeId in stable CDP)
10016 };
10017
10018 public static string BuildCommand<TParameters>(
10019 DevToolsMethods method,
10020 TParameters parameters,
10021 long commandId,
10022 string sessionId = null,
10023 int docRootId = 1)
10024 {
10025 string methodString = GetMethodString(method);
10026 object finalParameters = parameters;
10027
10028 // Check if the method requires a nodeId and parameters are provided
10029 if (MethodsRequiringNodeId.Contains(methodString) && parameters != null)
10030 {
10031 // Convert parameters to dictionary for manipulation
10032 IDictionary<string, object> paramDict;
10033 if (parameters is IDictionary<string, object> dict)
10034 {
10035 paramDict = dict;
10036 }
10037 else
10038 {
10039 // Handle anonymous objects
10040 paramDict = parameters.GetType()
10041 .GetProperties()
10042 .ToDictionary(p => p.Name, p => p.GetValue(parameters));
10043 }
10044
10045 finalParameters = paramDict;
10046 }
10047
10048 string paramsString = "";
10049 if (finalParameters != null)
10050 {
10051 IDictionary<string, object> paramDict;
10052 if (finalParameters is IDictionary<string, object> dict)
10053 {
10054 paramDict = dict;
10055 }
10056 else
10057 {
10058 paramDict = finalParameters.GetType()
10059 .GetProperties()
10060 .ToDictionary(p => p.Name, p => p.GetValue(finalParameters));
10061 }
10062
10063 var filteredDict = paramDict.Where(kvp => kvp.Value != null)
10064 .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
10065 if (filteredDict.Count > 0)
10066 {
10067 paramsString = $", \"params\": {JsonConvert.SerializeObject(filteredDict)}";
10068 }
10069 }
10070
10071 string sessionString = sessionId != null ? $"\"sessionId\": \"{sessionId}\"," : "";
10072 string command = $"{{\"id\": {commandId}, {sessionString} \"method\": \"{methodString}\"{paramsString}}}";
10073
10074 // NOTE: this can be quite excessive
10075 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"Generated command: [{command}]");
10076
10077 return command;
10078 }
10079
10086 internal static string GetMethodString(DevToolsMethods method)
10087 {
10088 var attr = method.GetAttributeOfType<CommandAttribute>();
10089 return attr?.CommandString ?? method.ToString().Replace('_', '.');
10090 }
10091 }
10092
10093 public static class EnumExtensions
10094 {
10095 public static TAttribute GetAttributeOfType<TAttribute>(this Enum enumVal) where TAttribute : Attribute
10096 {
10097 var type = enumVal.GetType();
10098 var memInfo = type.GetMember(enumVal.ToString());
10099 var attributes = memInfo[0].GetCustomAttributes(typeof(TAttribute), false);
10100 return (attributes.Length > 0) ? (TAttribute)attributes[0] : null;
10101 }
10102 }
10103 public static class TaskExtensions
10104 {
10105 public static async Task<T> TimeoutAfter<T>(this Task<T> task, TimeSpan timeout, string what = null)
10106 {
10107 Task completedTask = null;
10108 using (var cts = new CancellationTokenSource())
10109 {
10110 var delayTask = Task.Delay(timeout, cts.Token);
10111 try
10112 {
10113 completedTask = await Task.WhenAny(task, delayTask).ConfigureAwait(false);
10114 }
10115 catch (Exception ex)
10116 {
10117 var ex2 = ex;
10118 }
10119
10120 if (completedTask == delayTask)
10121 {
10122 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{what ?? "Operation"}] timed out after [{timeout.TotalSeconds}]s", null, GPALObjectType.Puppeteer);
10123 return default;
10124 }
10125
10126 // Cancel the delay task immediately so it stops consuming system resources
10127 cts.Cancel();
10128
10129 if (task.IsFaulted)
10130 {
10131 if (false == task.Exception?.InnerException?.Message.Contains("wasn't found"))
10132 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Task faulted: [{task.Exception?.InnerException?.Message ?? "Unknown error"}]", null, GPALObjectType.Puppeteer);
10133 return default;
10134 }
10135
10136 if (task.IsCanceled)
10137 {
10138 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Task canceled", null, GPALObjectType.Puppeteer);
10139 return default;
10140 }
10141
10142 // using task.Result avoids spinning up another await context state machine.
10143 return task.Result;
10144 }
10145 }
10146 }
10147
10148 public class BoxModelResponse
10149 {
10153 public Model Model { get; set; }
10154 }
10155
10156 public class Model
10157 {
10161 public double[] Content { get; set; } // Use double[] for the array of numbers
10165 public double[] Padding { get; set; }
10169 public double[] Border { get; set; }
10173 public double[] Margin { get; set; }
10177 public int Width { get; set; }
10181 public int Height { get; set; }
10182 }
10183}
10184
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:7486
int ServerResponseCode
HTTP status code of the most recent page navigation/response.
Definition Browser.cs:7356
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 ScrollWindow(int hPixels, int vPixels, string sessionId=null)
Scrolls the whole window/viewport by the specified number of pixels horizontally and vertically,...
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....
int GetCurrentWindowIndex(string targetId)
Finds the index of the window whose tab queue contains the given target ID.
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< 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< string > GoToWindow(object tabIdOrUrl, string sessionId=null)
Switches the active tab/window to the one identified by a tab index or page URL, activating the corre...
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:2568