GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
BrowserHelper.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.Collections.ObjectModel;
21using System.Diagnostics;
22using System.Drawing;
23using System.Drawing.Imaging;
24using System.IO;
25using System.Linq;
26using System.Net.Http;
27using System.Net.Sockets;
28using System.Net.WebSockets;
29using System.Runtime.InteropServices;
30using System.Text;
31using System.Text.Json;
32using System.Threading;
33using System.Threading.Tasks;
34using System.Windows.Controls.Primitives;
35using System.Windows.Forms;
36using DocumentFormat.OpenXml.Bibliography;
37using Microsoft.Win32;
38using Newtonsoft.Json.Linq;
39using OpenQA.Selenium;
40using OpenQA.Selenium.Chrome;
41using OpenQA.Selenium.Chromium;
42using OpenQA.Selenium.DevTools;
43using OpenQA.Selenium.Edge;
44using OpenQA.Selenium.Firefox;
45using OpenQA.Selenium.Support.UI;
47using static GenerallyPositive.Enums;
48using System.Net.NetworkInformation;
49using System.Net;
50using System.Text.RegularExpressions;
51
52
54{
55 public class BrowserHelper
56 {
57 static bool alreadyUpdating = false;
58
59 static List<string> lastErrorMessage = new List<string>();
60 static bool supressedMessage = false;
61 static string lastSessionToken;
62
70 public static IWebDriver GetBrowserDriver(BrowserSettings browserSettings)
71 {
72 IWebDriver tmpDriver = null;
73 ChromiumOptions options = null;
74 bool waitForDebugger = false;
75 string userAgent = GPAL.GPALSettings.UserAgent;
76
77 if (null != browserSettings.BrowserDriver)
78 return browserSettings.BrowserDriver;
79
80 // every launch, not only the ones that ask for a temp profile, so a run that supplies its own profile
81 // still clears what a killed run left behind
82 ChromeProfileManager.SweepAbandonedTempProfiles();
83
84 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Running workflow for Automation Engine [{browserSettings.AutomationEngine}]", null, GPALObjectType.None);
85
86 if (null == browserSettings.DriverLocation)
87 // driver is located with executable
88 browserSettings.DriverLocation = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().GetName().CodeBase).Substring(6); // skip file:\\
89
90 if (true == GPAL.GPALSettings.AutoUpdateWebDriver && false == alreadyUpdating)
91 {
92 alreadyUpdating = true;
93 DriverHelper.UpdateDriver(browserSettings);
94 // NOTE: no more port overlap, can have multiple running, each one exits when its own browser closes
95 // its stdin, no need to kill any longer, we'll just get the next available port
96 // BrowserHelper.KillProcess("GPALRestAPI"); // could be started by browser that has ottomagic installed
97 }
98
99 try
100 {
101 if (BrowserType.Chrome == browserSettings.BrowserType || BrowserType.Edge == browserSettings.BrowserType)
102 {
103 if (BrowserType.Chrome == browserSettings.BrowserType)
104 options = new ChromeOptions();
105 else
106 options = new EdgeOptions();
107
108 // the driver answers alert, confirm and prompt itself, which is a webdriver capability and
109 // not something the page can see. nothing is overridden and nothing is injected
110 if (null != browserSettings.DialogsAccepted)
111 options.UnhandledPromptBehavior = true == browserSettings.DialogsAccepted
112 ? UnhandledPromptBehavior.Accept
113 : UnhandledPromptBehavior.Dismiss;
114
115 if (true == browserSettings.UseExistingBrowser)
116 {
117 options.DebuggerAddress = "127.0.0.1:" + browserSettings.ExistingBrowserPort.ToString();
118 waitForDebugger = true;
119 }
120 else
121 {
122 if (null == browserSettings.DebugPort && true == browserSettings.UsePuppeteer && false == browserSettings.DebugPipe) // make sure puppeteerURL is properly set - if debugport is set, it will update url, if not, set the debugport to default
123 {
124 // the first free port from 0xdead up, so this is still 57005 on a quiet machine and
125 // moves along when a browser from another run or another bot is already on it
126 browserSettings.DebugPort = FindFreePort();
127 browserSettings.PuppeteerUrl = $"http://127.0.0.1:{browserSettings.DebugPort}";
128 }
129
131 if (false == string.IsNullOrEmpty(browserSettings.DownloadLocation))
132 options.AddUserProfilePreference("download.default_directory", browserSettings.DownloadLocation);
133
134 // NOTE: chrome 136 requires a user-data-dir for security reasons
135 // Profile settings
136 if (false == string.IsNullOrEmpty(browserSettings.ProfileUserName) || false == string.IsNullOrEmpty(browserSettings.ProfileName))
137 {
138 browserSettings.ProfileDataDirectory = GetBrowserProfileDirectory(browserSettings);
139
140 if (false == string.IsNullOrEmpty(browserSettings.ProfileName))
141 {
142 // the profile name might not be what we expect, which is a directory name.
143 // this will iterate thru all directories under user-data-dir, open the Preferences file and try to match token profile.name
144 // if it matches, then the directory containing that Preferences file is returned
145 // FindProfileDirectory returns a directory or throws
146 options.AddArguments($"--profile-directory={FindProfileDirectory(browserSettings.ProfileDataDirectory, browserSettings.ProfileName)}");
147 }
148 }
149 else if (true == string.IsNullOrEmpty(browserSettings.ProfileDataDirectory))
150 {
151 // Create temporary profile if none specified
152 browserSettings.ProfileDataDirectory = ChromeProfileManager.CreateTempUserProfile(
153 browserSettings.DownloadLocation ?? FileHelper.GetDefaultDownloadDirectory(browserSettings.Browser),
154 browserSettings.PromptForDownload,
155 browserSettings.OpenPDFExternally,
156 browserSettings.LoadImages,
157 browserSettings.UseOttoMagic
158 );
159 browserSettings.TempProfileCreated = true;
160 }
161
162 // a supplied profile is the user's own. selenium writes the download preferences into it at
163 // launch and chrome keeps them, so read what is there now and Browser.Close puts it back.
164 // a temp profile is deleted on close, so there is nothing to preserve there
165 if (false == browserSettings.TempProfileCreated && false == string.IsNullOrEmpty(browserSettings.ProfileDataDirectory))
166 browserSettings.PreviousDownloadPreferences = ChromeProfileManager.ApplyDownloadPreferences(
167 browserSettings.ProfileDataDirectory,
168 browserSettings.DownloadLocation,
169 browserSettings.PromptForDownload,
170 browserSettings.OpenPDFExternally);
171
172 // a profile can only be owned by one browser. launching onto one that is already open hands
173 // the command line to that browser and exits, so the debug port never opens and the run
174 // waits for a browser that was never started. said here, where it can still be acted on
175 if (false == browserSettings.TempProfileCreated)
176 RefuseIfProfileIsOpen(browserSettings);
177
178 if (false == string.IsNullOrEmpty(browserSettings.ProfileDataDirectory))
179 options.AddArgument($"--user-data-dir={browserSettings.ProfileDataDirectory.Replace("\\", "/")}");
180 /* prolly no longer a use case for this - edge has some annoying popups that block selenium unless logged in as guest
181 else if (true == string.IsNullOrEmpty(browserSettings.ProfileDataDirectory) && BrowserType.Edge == browserSettings.BrowserType)
182 options.AddArgument("--guest"); // in msedge, an annoyting 'personalize your experience' nag and options bar pops up blocking automation, this prevents that
183 */
184
185 // *******************************************************************
186 // NOTE: CAVEAT: suppose a time comes to run with some sort of extension, this isn't causing any harm, just annoying...
187 // not keen on disabling extensions, they make the bot look alive, but somehow we score low on recaptcha with ottomagic, no idea why, puppeteer is awesome
188 // *******************************************************************
189 options.AddArguments("--disable-extensions"); // no ottomagic - selenium and gpalrestapi/ottomagic DO NOT get along at all
190 // *******************************************************************
191 // *******************************************************************
192 // one --disable-features switch only, chromium takes the last one rather than merging them.
193 // edge's shopping content scripts mutate retail pages constantly, which is what wedges
194 // DOM.getDocument on an item page that chrome handles fine
195 string disableFeatures = "TranslateUI,Translate";
196
197 if (BrowserType.Edge == browserSettings.BrowserType)
198 {
199 disableFeatures += ",msShoppingTrigger,msShopping,msEdgeShoppingUI,msEdgeShoppingList";
200
201 // Edge implicit sign-in, which --disable-sync does not cover
202 if (true == browserSettings.TempProfileCreated)
203 disableFeatures += ",msImplicitSignin,msEdgeImplicitSignin,msIdentityImplicitSignin";
204 }
205
206 options.AddArgument($"--disable-features={disableFeatures}");
207
208 // Edge signs a new profile in with the Windows account and syncs favourites, settings
209 // and extensions down onto it. Applied to temp profiles only; a supplied profile keeps
210 // whatever sync settings it has
211 if (true == browserSettings.TempProfileCreated)
212 {
213 options.AddArgument("--disable-sync");
214 options.AddArgument("--disable-signin-promo");
215 }
216
217 // Disable infobars and any chrome overlays
218 options.AddArgument("--disable-infobars");
219 options.AddArgument("--disable-session-crashed-bubble");
220 options.AddArgument("--hide-crash-restore-bubble");
221 options.AddArgument("--noerrdialogs");
222
223 if (true == browserSettings.UseHeadless)
224 {
225 // Set headless mode
226 options.AddArgument("--headless=new");
227 // Disable GPU
228 //options.AddArgument("--disable-gpu");
229 // Disable software rasterizer
230 //options.AddArgument("--disable-software-rasterizer");
231 // Disable sandbox
232 //options.AddArgument("--no-sandbox");
233
234 // Disable notifications
235 options.AddArgument("--disable-notifications");
236 // Disable popup blocking
237 options.AddArgument("--disable-popup-blocking");
238 // Disable setuid sandbox
239 //options.AddArgument("--disable-setuid-sandbox");
240
241 // a headless browser says so in its user agent, so the word has to come out of it.
242 // Reading it from a real browser costs a whole launch, so that only happens when the
243 // workflow asked for it; otherwise the answer comes from what is already known
244 string currUserAgent;
245
246 if (false == browserSettings.UserAgentFromBrowser)
247 currUserAgent = ResolveUserAgent(browserSettings);
248 else
249 {
250 if (BrowserType.Chrome == browserSettings.BrowserType)
251 tmpDriver = new ChromeDriver(browserSettings.DriverLocation, (ChromeOptions)options);
252 else
253 tmpDriver = new EdgeDriver(browserSettings.DriverLocation, (EdgeOptions)options);
254
255 IJavaScriptExecutor js = (IJavaScriptExecutor)tmpDriver;
256 currUserAgent = (String)js.ExecuteScript("return navigator.userAgent");
257 tmpDriver.Quit();
258 }
259
260 options.AddArgument($"--user-agent={currUserAgent.Replace("Headless", "")}");
261
262 options.AddUserProfilePreference("download.prompt_for_download", false);
263 }
264 else
265 {
266 if (true == browserSettings.PromptForDownload)
267 options.AddUserProfilePreference("download.prompt_for_download", true);
268 else
269 options.AddUserProfilePreference("download.prompt_for_download", false);
270 }
271
272 // default for chrome is false - so turn on this safeguard
273 // Purpose: When set to true, it allows Chrome to automatically handle cases where the specified download directory
274 // (set via "download.default_directory") is invalid, inaccessible, or doesn't exist. Chrome may attempt to upgrade
275 // or fall back to a valid directory, ensuring downloads proceed without errors.
276 options.AddUserProfilePreference("download.directory_upgrade", true);
277
278 options.AddUserProfilePreference("plugins.always_open_pdf_externally", browserSettings.OpenPDFExternally);
279 options.AddArguments($"--blink-settings=imagesEnabled={browserSettings.LoadImages.ToString().ToLower()}");
280 options.AddUserProfilePreference("disable-popup-blocking", $"{!browserSettings.BlockPopUps}");
281
282 // turn on dark mode
283 if (browserSettings.StealthType.HasFlag(StealthType.DarkMode))
284 options.AddArgument("--force-dark-mode");
285
286 // Set remote debugging pipe or portt
287 if (true == browserSettings.DebugPipe)
288 options.AddArgument($"--remote-debugging-pipe --enable-unsafe-extension-debugging");
289 else if (null != browserSettings.DebugPort)
290 options.AddArgument($"--remote-debugging-port={browserSettings.DebugPort.Value}");
291
292 //options.AddUserProfilePreference("safebrowsing.enabled", true); // CAVEAT: should this be configurable? disables warnings while downloading files... 'this may harm' seems we don't want that during automation
293
294 // obscure anti-bot detections
295 // Adding argument to disable the AutomationControlled flag
296 // no longer supported, throws infobar message 5/12/25
297 options.AddArguments("--disable-blink-features=AutomationControlled");
298
299 // Exclude the collection of enable-automation switches
300 options.AddExcludedArgument("enable-automation");
301 if (options is ChromeOptions chromeOpts)
302 chromeOpts.AddAdditionalChromeOption("useAutomationExtension", false);
303 else if (options is EdgeOptions edgeOpts)
304 edgeOpts.AddAdditionalEdgeOption("useAutomationExtension", false);
305 }
306
307 if (browserSettings.StealthType.HasFlag(StealthType.PatchDriver))
308 {
309 if (BrowserType.Edge == browserSettings.BrowserType)
310 EdgePatcher.PatchEdgeDriver(browserSettings.Browser);
311 else
312 ChromePatcher.PatchChromeDriver(browserSettings.Browser);
313 }
314
315 // selenium will not accept a url as a commandline option, adds -- in front, there is no
316 // options.AddArgument(browserSettings.CurrentURL); // doesn't always work, some enterprise browsers have a mandatory first page that bypasses commandline url
317
318 // Use this to query the logs to get the server response code for the goto
319 // NOTE: CAVEAT: this might make us more detectable in selenium??
320 // this is not a required feature, to return the navigation status code in SeleniumGoto
321 options.SetLoggingPreference(OpenQA.Selenium.LogType.Performance, LogLevel.All);
322
323 tmpDriver = GetWebDriver(browserSettings, options);
324
325 //if (BrowserType.Chrome == browserSettings.BrowserType)
326 // tmpDriver = new ChromeDriver(browserSettings.DriverLocation, (ChromeOptions)options);
327 //else
328 // tmpDriver = new EdgeDriver(browserSettings.DriverLocation, (EdgeOptions)options);
329 }
330 else if (BrowserType.FireFox == browserSettings.BrowserType)
331 {
332 // read more: https://github.com/mozilla/geckodriver/issues/430
333 FirefoxDriverService firefoxDriverService = FirefoxDriverService.CreateDefaultService(browserSettings.DriverLocation);
334 FirefoxOptions ffOptions = new FirefoxOptions();
335
336 if (null != browserSettings.DialogsAccepted)
337 ffOptions.UnhandledPromptBehavior = true == browserSettings.DialogsAccepted
338 ? UnhandledPromptBehavior.Accept
339 : UnhandledPromptBehavior.Dismiss;
340 if (true == browserSettings.UseExistingBrowser)
341 {
342 firefoxDriverService.HideCommandPromptWindow = true;
343 firefoxDriverService.Port = 0; // let the driver take any free port for its own http server
344 firefoxDriverService.Host = "127.0.0.1";
345 firefoxDriverService.ConnectToRunningBrowser = true;
346 // marionette is Firefox's side of the connection, the same role the debugger port plays for
347 // Chrome and Edge above. 2828 is Firefox's own default when the workflow did not name one
348 firefoxDriverService.BrowserCommunicationPort = browserSettings.ExistingBrowserPort ?? 2828;
349
350 tmpDriver = new FirefoxDriver(firefoxDriverService, ffOptions);
351
352 waitForDebugger = true;
353 }
354 else
355 {
356 // Set window size
357 ffOptions.AddArgument($"-width={browserSettings.WindowSize.Width}");
358 ffOptions.AddArgument($"-height={browserSettings.WindowSize.Height}");
359
360 if (true == browserSettings.UseHeadless)
361 {
362
363 // Set headless mode
364 ffOptions.AddArgument("-headless");
365
366 // Disable notifications
367 ffOptions.AddArgument("-disable-notifications");
368
369 // Set user agent - this makes firefox not start byt gives error
370 // if (null != userAgent)
371 // ffOptions.AddArgument($"--user-agent={userAgent}");
372 }
373
374 if (null != browserSettings.DebugPort)
375 {
376 firefoxDriverService.Port = (int)browserSettings.DebugPort.Value;
377
378 if (true == browserSettings.UsePuppeteer)
379 browserSettings.PuppeteerUrl = $"http://127.0.0.1:{browserSettings.DebugPort}";
380
381 if (string.IsNullOrEmpty(browserSettings.ProfileDataDirectory) && string.IsNullOrEmpty(browserSettings.ProfileName) && string.IsNullOrEmpty(browserSettings.ProfileUserName))
382 ffOptions.Profile = new OpenQA.Selenium.Firefox.FirefoxProfileManager().GetProfile("default");
383 }
384 else if (true == browserSettings.UsePuppeteer) // make sure puppeteerURL is properly set - if debugport is set, it will update url, if not, set the debugport to default
385 {
386 // the first free port from 0xdead up, so this is still 57005 on a quiet machine and
387 // Skips ports already bound by another process. Called once per browser: a second
388 // call would reserve a port nothing uses
389 if (null == browserSettings.DebugPort)
390 browserSettings.DebugPort = FindFreePort();
391
392 browserSettings.PuppeteerUrl = $"http://127.0.0.1:{browserSettings.DebugPort}";
393 }
394
395 if (null != browserSettings.DebugPort)
396 ffOptions.AddArgument($"--remote-debugging-port={browserSettings.DebugPort.Value} ");
397
398 ffOptions.SetPreference("print.always_print_silent", true);
399 ffOptions.SetPreference("print.show_print_progress", false);
400
401
402 // headless turns the viewer off whatever the workflow asked. there is nobody to render a
403 // pdf for, and Get() sets OpenPDFExternally false for headless, which would otherwise
404 // switch pdfjs back on and leave every pdf displayed to no one instead of downloaded
405 ffOptions.SetPreference("pdfjs.disabled", browserSettings.OpenPDFExternally || browserSettings.UseHeadless);
406
407 if (false == browserSettings.PromptForDownload)
408 ffOptions.SetPreference("browser.helperApps.neverAsk.saveToDisk", GPAL.GPALSettings.FirefoxDirectDownloadMimeTypes);
409 else
410 ffOptions.SetPreference("browser.helperApps.neverAsk.saveToDisk", "");
411
412 // false is firefox for "ask where to save every file", which is the save dialog. the
413 // OttoMagic path writes this one too; without it here a prompted download on selenium
414 // firefox depended on whatever the profile already said
415 ffOptions.SetPreference("browser.download.useDownloadDir", false == browserSettings.PromptForDownload);
416
417 if (false == browserSettings.LoadImages)
418 ffOptions.SetPreference("permissions.default.image", 0);
419 else
420 ffOptions.SetPreference("permissions.default.image", 1);
421
422 // how many popups one event may open. zero is none, and twenty is what firefox ships
423 // with, so blocking is the low number and the sense of this reads backwards
424 ffOptions.SetPreference("dom.popup_maximum", true == browserSettings.BlockPopUps ? 0 : 20);
425
426 ffOptions.SetPreference("safebrowsing.enabled", false); // TODO: should this be configurable? disables warnings while downloading files... 'this may harm' seems we don't want that during automation
427
428 if (false == string.IsNullOrEmpty(browserSettings.DownloadLocation))
429 {
430 ffOptions.SetPreference("browser.download.dir", browserSettings.DownloadLocation);
431 ffOptions.SetPreference("browser.download.folderList", 2); // 2: Use the custom download directory
432 }
433
434 ffOptions.SetPreference("dom.webdriver.enabled", false);
435
436 // -profile and the path go in as two arguments. One argument holding both reads as an
437 // unknown flag to firefox, which then starts on a profile of its own while geckodriver
438 // watches the named one for the marionette port. A trailing separator escapes the closing
439 // quote windows puts around the argument, so it comes off first
440 if (false == string.IsNullOrEmpty(browserSettings.ProfileUserName) || false == string.IsNullOrEmpty(browserSettings.ProfileName))
441 // browserSettings.ProfileDataDirectory = GetBrowserProfileDirectory(browserSettings); // not sure this is applicable to firefox
442 ffOptions.AddArguments("-profile", (browserSettings.ProfileName ?? browserSettings.ProfileUserName).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
443 else if (false == string.IsNullOrEmpty(browserSettings.ProfileDataDirectory))
444 ffOptions.AddArguments("-profile", browserSettings.ProfileDataDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
445
446 // Ensure that debugging is enabled in Firefox, F12, ..., settings, enable browser chrome and bugging toolboxes checkbox, enable remote debugging checkbox
447 //options.AddArguments("-foreground");
448 //options.AddArguments("-start-debugger-server 2828");
449 //tmpDriver = new FirefoxDriver(browserSettings.driverLocation, options);
450
451 // kludge on opening first window
452 //Uri safe = RemoteWebDriverExtended.FirefoxURL;
453 //RemoteWebDriverExtended.FirefoxURL = new Uri(browserSettings.currentURL);
454 //tmpDriver = RemoteWebDriverExtended.GetFireFoxDriver(browserSettings.driverLocation);
455 //RemoteWebDriverExtended.FirefoxURL = safe;
456 }
457
458 ffOptions.BinaryLocation = FindFirefoxBinaryLocation();
459
460// tmpDriver = new FirefoxDriver(browserSettings.DriverLocation, ffOptions); // would ever start up a session with no options?
461 // Explicit service for Firefox to get geckodriver PID
462 if (true == browserSettings.HiddenDesktop)
463 {
464 // the driver is started by hand so it can be named a desktop, and Selenium is handed the
465 // port rather than a service. StartHiddenDriver sets ServiceDriverPid from the launch
466 int hiddenPort = StartHiddenDriver(browserSettings);
467
468 tmpDriver = new OpenQA.Selenium.Remote.RemoteWebDriver(new Uri($"http://127.0.0.1:{hiddenPort}"), ffOptions);
469 }
470 else
471 {
472 FirefoxDriverService firefoxService = FirefoxDriverService.CreateDefaultService(browserSettings.DriverLocation);
473 firefoxService.HideCommandPromptWindow = true; // hides the console window
474
475 tmpDriver = new FirefoxDriver(firefoxService, ffOptions);
476
477 // Capture geckodriver.exe PID — reuse the same property
478 browserSettings.ServiceDriverPid = firefoxService.ProcessId;
479 }
480
481 // and firefox's own, which it reports as a capability. the driver launches the browser, so
482 // nothing else here has a handle on it, and every fallback in the teardown is written around
483 // having one: with no handle there is no wait for it to go, no warning that it did not, and
484 // no kill. geckodriver is killed by pid at the end, and a firefox that outlived its driver is
485 // then orphaned and runs on
486 browserSettings.Process = SeleniumBrowserProcess(tmpDriver, "moz:processID");
487
488 //tmpDriver = new FirefoxDriver(firefoxDriverService, ffOptions);
489 }
490 }
491 catch (GPALException)
492 {
493 throw;
494 }
495 catch (Exception ex)
496 {
497 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to start browser driver for [{browserSettings.BrowserType}].", browserSettings.Browser, GPALObjectType.Browser, ex);
498 // unrecoverable error, the only time we rethrow
499 // return null;
500 throw;
501 }
502 if (null != tmpDriver)
503 {
504 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Browser [{browserSettings.BrowserType}] launched.", browserSettings, GPALObjectType.Other);
505
506 browserSettings.BrowserDriver = tmpDriver;
507
508 // Edge preloads its New Tab Page into a hidden background window that appears asynchronously as an
509 // extra WindowHandle and corrupts our sequential tab-index tracking. At this instant (before we
510 // navigate) the driver's url is that preload's url, so capture it as a runtime signature - later we
511 // positively match the phantom tab by this url, never by position (which races the async insert)
512 // nor a hardcoded string.
513 var edgeGhostBrowser = browserSettings.Browser as Browser;
514 if (BrowserType.Edge == browserSettings.BrowserType && null != edgeGhostBrowser)
515 try { edgeGhostBrowser.GhostTabUrl = tmpDriver.Url; }
516 catch { /* non-fatal: without a signature we simply never skip a ghost */ }
517
518 // pageload timeout to connect 30 seconds
519 tmpDriver.Manage().Timeouts().PageLoad.Add(System.TimeSpan.FromSeconds(30)); // NOTE: hardcoded value
520
521 //BrowserHelper.SeleniumGoToUrl(browserSettings.CurrentURL, browserSettings);
522
523 browserSettings.CurrentURL = "about:blank";
524
525 if (true == browserSettings.Maximize)
526 tmpDriver.Manage().Window.Maximize();
527 else if (true == browserSettings.Minimize)
528 tmpDriver.Manage().Window.Minimize();
529 else if (true == browserSettings.FullScreen)
530 tmpDriver.Manage().Window.FullScreen();
531 else if (false == browserSettings.WindowSize.IsEmpty)
532 {
533 // Set the desired window size
534 var windowSize = new Size(browserSettings.WindowSize.Width, browserSettings.WindowSize.Height);
535 tmpDriver.Manage().Window.Size = windowSize;
536
537 // Set the desired window position (top-left corner). Point is x then y, which is Left then Top
538 var windowPosition = new Point(browserSettings.WindowSize.Left, browserSettings.WindowSize.Top);
539 tmpDriver.Manage().Window.Position = windowPosition;
540 }
541
542 if (true == waitForDebugger)
543 Thread.Sleep(2000);
544
545 if (BrowserType.Chrome == browserSettings.BrowserType)
546 {
547 DriverHelper.GetChromeVersion(out browserSettings.Version);
548 }
549 else if (BrowserType.Edge == browserSettings.BrowserType)
550 {
551 DriverHelper.GetEdgeVersion(out browserSettings.Version);
552 }
553 else if (BrowserType.FireFox == browserSettings.BrowserType)
554 {
555 DriverHelper.GetFirefoxVersion(out browserSettings.Version);
556 }
557
558 CheckDocumentReady(((Browser)browserSettings.Browser), true);
559 }
560
561 return tmpDriver;
562 }
563
576 public static void AllowDownloads(BrowserSettings browserSettings, string directory)
577 {
578 var parameters = new Dictionary<string, object>
579 {
580 { "behavior", "allow" },
581 { "downloadPath", directory }
582 };
583
584 ((OpenQA.Selenium.Chromium.ChromiumDriver)browserSettings.BrowserDriver).ExecuteCdpCommand("Page.setDownloadBehavior", parameters);
585 }
586
597 internal static string ResolveUserAgent(BrowserSettings browserSettings)
598 {
599 string userAgent = browserSettings.OverrideUserAgent;
600
601 if (true == string.IsNullOrWhiteSpace(userAgent))
602 userAgent = GPAL.GPALSettings.UserAgent;
603
604 if (true == string.IsNullOrWhiteSpace(userAgent))
605 userAgent = MagicHelper.GetUserAgentString(browserSettings.BrowserType);
606
607 return userAgent;
608 }
622 internal static void PresentCredentials(Browser browser, GPALUrl url)
623 {
624 Credentials credential = (Credentials)browser.BrowserSettings.Credentials;
625 WebAuthType authType = credential.WebAuthType;
626 string header = AuthorizationHeader(credential, authType);
627
628 browser.BrowserSettings.CredentialsPresented = DateTime.UtcNow;
629
630 // a form is a page, and a page is the workflow's job. Holding the credential is all that was asked for
631 if (WebAuthType.Form == authType || WebAuthType.None == authType)
632 return;
633
634 if (true == string.IsNullOrEmpty(header) || BrowserType.FireFox == browser.BrowserSettings.BrowserType || true == browser.UseOttoMagic)
635 CredentialsInUrl(browser, url, credential);
636 else
637 {
638 string name = AuthorizationHeaderName(authType);
639 Dictionary<string, object> headers = new Dictionary<string, object> { { name, header } };
640
641 if (true == browser.UsePuppeteer)
642 browser.PuppeteerCommunicator.SetExtraHttpHeaders(headers).GetAwaiter().GetResult();
643 else
644 ((OpenQA.Selenium.Chromium.ChromiumDriver)browser.BrowserDriver).ExecuteCdpCommand("Network.setExtraHTTPHeaders",
645 new Dictionary<string, object> { { "headers", headers } });
646
647 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Credentials presented as [{authType}] for [{url?.Url}]", browser, GPALObjectType.Browser);
648 }
649 }
656 internal static string AuthorizationHeader(Credentials credential, WebAuthType authType)
657 {
658 string value = null;
659
660 if (WebAuthType.Basic == authType || WebAuthType.Proxy == authType)
661 value = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{credential.Username}:{credential.Password}"));
662 else if (WebAuthType.Bearer == authType)
663 value = "Bearer " + credential.AccessToken;
664 else if (WebAuthType.XAuthToken == authType || WebAuthType.XApiKey == authType || WebAuthType.ApiKey == authType)
665 value = credential.AccessToken; // their own header, so no scheme word in front of it
666
667 return value;
668 }
669
675 internal static string AuthorizationHeaderName(WebAuthType authType)
676 {
677 string name = "Authorization";
678
679 if (WebAuthType.Proxy == authType)
680 name = "Proxy-Authorization";
681 else if (WebAuthType.XAuthToken == authType)
682 name = "X-Auth-Token";
683 else if (WebAuthType.XApiKey == authType)
684 name = "X-API-Key";
685 else if (WebAuthType.ApiKey == authType)
686 name = "api-key";
687
688 return name;
689 }
698 static void CredentialsInUrl(Browser browser, GPALUrl url, Credentials credential)
699 {
700 UriBuilder builder = new UriBuilder(url?.Url)
701 {
702 UserName = Uri.EscapeDataString(credential.Username ?? string.Empty),
703 Password = Uri.EscapeDataString(credential.Password ?? string.Empty)
704 };
705 GPALUrl credentialed = new GPALUrl(builder.Uri.ToString());
706
707 // the engines are called directly rather than through .GoTo, because .GoTo announces where it is
708 // going and this url is a password
709 if (true == browser.UseOttoMagic)
710 browser.MagicHelper.GoTo(credentialed);
711 else if (true == browser.UsePuppeteer)
712 browser.PuppeteerClient.GoTo(credentialed).Execute();
713 else
714 SeleniumGoToUrl(credentialed, browser.BrowserSettings);
715
716 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Credentials presented to [{builder.Host}], answering its challenge", browser, GPALObjectType.Browser);
717 }
718
733 private static Process SeleniumBrowserProcess(IWebDriver driver, string capability)
734 {
735 Process retVal = null;
736
737 try
738 {
739 object pid = (driver as IHasCapabilities)?.Capabilities?.GetCapability(capability);
740
741 if (null != pid)
742 retVal = Process.GetProcessById(Convert.ToInt32(pid));
743 }
744 catch (Exception ex)
745 {
746 // a pid that names nothing is a browser that is already gone, which is not worth a warning here.
747 // the teardown treats a null the same as it always did
748 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"No browser process behind the driver for [{capability}]", null, GPALObjectType.Browser, ex);
749 }
750
751 return retVal;
752 }
753 internal static void WaitForBrowserToExit(BrowserSettings browserSettings, int timeoutMs)
754 {
755 try
756 {
757 browserSettings.Process?.WaitForExit(timeoutMs);
758 }
759 catch (Exception ex)
760 {
761 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Could not wait on the browser process before restoring [{browserSettings.ProfileDataDirectory}]", browserSettings.Browser, GPALObjectType.Browser, ex);
762 }
763 }
764
778 public static void SetHeadlessDownload(BrowserSettings browserSettings, string destination)
779 {
780 string directory = Path.GetDirectoryName(destination);
781 string filename = Path.GetFileName(destination);
782
783 var enableDownloadCommandParameters = new Dictionary<string, object>
784 {
785 { "behavior", "allow" },
786 { "downloadPath", directory },
787 { "filename", filename }
788 };
789
790 if (BrowserType.Chrome == browserSettings.BrowserType || BrowserType.Edge == browserSettings.BrowserType)
791 ((OpenQA.Selenium.Chromium.ChromiumDriver)browserSettings.BrowserDriver).ExecuteCdpCommand("Page.setDownloadBehavior", enableDownloadCommandParameters);
792 }
793
808 internal const string CallRecorderScript = @"
809 (function () {
810 if (window.__gpalCalls) return;
811
812 window.__gpalCalls = [];
813
814 var record = function (url, method, headers, body, type) {
815 var call = { url: String(url), method: method || 'GET', type: type, headers: headers || {}, postData: body || null, status: 0 };
816 window.__gpalCalls.push(call);
817 return call;
818 };
819
820 var nativeFetch = window.fetch;
821
822 if (nativeFetch)
823 window.fetch = function (input, init) {
824 var url = input && input.url ? input.url : input;
825 var headers = {};
826 var options = init || {};
827
828 // a Headers object, a plain object or an array of pairs, all of which fetch accepts
829 if (options.headers && options.headers.forEach)
830 options.headers.forEach(function (v, k) { headers[k] = v; });
831 else if (options.headers)
832 Object.keys(options.headers).forEach(function (k) { headers[k] = options.headers[k]; });
833
834 var call = record(url, options.method, headers, options.body, 'Fetch');
835
836 return nativeFetch.apply(this, arguments).then(function (response) {
837 call.status = response.status;
838 return response;
839 }, function (error) {
840 call.status = 0;
841 throw error;
842 });
843 };
844
845 var nativeOpen = XMLHttpRequest.prototype.open;
846 var nativeSend = XMLHttpRequest.prototype.send;
847 var nativeSetHeader = XMLHttpRequest.prototype.setRequestHeader;
848
849 XMLHttpRequest.prototype.open = function (method, url) {
850 this.__gpalCall = { method: method, url: url, headers: {} };
851 return nativeOpen.apply(this, arguments);
852 };
853
854 XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
855 if (this.__gpalCall) this.__gpalCall.headers[name] = value;
856 return nativeSetHeader.apply(this, arguments);
857 };
858
859 XMLHttpRequest.prototype.send = function (body) {
860 var pending = this.__gpalCall;
861
862 if (pending) {
863 var call = record(pending.url, pending.method, pending.headers, body, 'XHR');
864 this.addEventListener('loadend', function () { call.status = this.status; });
865 }
866
867 return nativeSend.apply(this, arguments);
868 };
869 })();
870 ";
871
872 public static string SeleniumAddScriptToEvaluateOnNewDocument(BrowserSettings browserSettings, string script)
873 {
874 if (BrowserType.Chrome != browserSettings.BrowserType && BrowserType.Edge != browserSettings.BrowserType)
875 {
876 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{browserSettings.BrowserType}] does not support Page.addScriptToEvaluateOnNewDocument", browserSettings.Browser, GPALObjectType.Browser);
877 return null;
878 }
879
880 var result = ((OpenQA.Selenium.Chromium.ChromiumDriver)browserSettings.BrowserDriver).ExecuteCdpCommand("Page.addScriptToEvaluateOnNewDocument", new Dictionary<string, object>
881 {
882 { "source", script }
883 }) as Dictionary<string, object>;
884
885 return result?["identifier"]?.ToString();
886 }
887
894 public static void SeleniumRemoveScriptToEvaluateOnNewDocument(BrowserSettings browserSettings, string identifier)
895 {
896 if (BrowserType.Chrome != browserSettings.BrowserType && BrowserType.Edge != browserSettings.BrowserType)
897 {
898 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{browserSettings.BrowserType}] does not support Page.removeScriptToEvaluateOnNewDocument", browserSettings.Browser, GPALObjectType.Browser);
899 return;
900 }
901
902 ((OpenQA.Selenium.Chromium.ChromiumDriver)browserSettings.BrowserDriver).ExecuteCdpCommand("Page.removeScriptToEvaluateOnNewDocument", new Dictionary<string, object>
903 {
904 { "identifier", identifier }
905 });
906 }
907
913 public static string GetBrowserProfileDirectory(BrowserSettings browserSettings, bool getDefaultDirectory = false)
914 {
915 // Chromium profiles live under LocalApplicationData, Firefox profiles under ApplicationData
916 // (Roaming). There is no single base for all three
917 string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
918 string roamingAppData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
919 string profilePath = string.Empty;
920
921 switch (browserSettings.BrowserType)
922 {
923 case BrowserType.Chrome:
924 profilePath = Path.Combine(localAppData, "Google", "Chrome", "User Data");
925 break;
926 case BrowserType.FireFox:
927 profilePath = Path.Combine(roamingAppData, "Mozilla", "Firefox", "Profiles");
928 break;
929 case BrowserType.Edge:
930 profilePath = Path.Combine(localAppData, "Microsoft", "Edge", "User Data");
931 break;
932 default:
933 Exception ex = new Exception("Unsupported Browser Type"); // let's force this as an exception to post to the exception channel
934 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unsupported browser type for loading user profile [{browserSettings.ProfileUserName}]: [{browserSettings.BrowserType.ToString()}]", ex);
935 break;
936 }
937
938 string newProfilePath = profilePath;
939 // chrome 136 will not let the default directory be specified as the user-data-dir if running via selenium (no access to user cookies)
940 // - copy to a new folder is a workaround, we will guard against defining them the same
941 if (false == string.IsNullOrEmpty(browserSettings.ProfileUserName) && false == getDefaultDirectory)
942 {
943 newProfilePath = Path.Combine(profilePath, browserSettings.ProfileUserName);
944 // Check if the profile directory exists
945 // Throws rather than falling back to the user data root, which would silently run the default
946 // profile instead of the one named
947 if (!Directory.Exists(newProfilePath))
948 RefuseProfile($"[{browserSettings.BrowserType}] has no profile directory [{newProfilePath}] for user name [{browserSettings.ProfileUserName}]");
949 }
950 return newProfilePath;
951 }
952
962 public static string FindProfileDirectory(string userDataDirectory, string profileName)
963 {
964 // Checked before GetDirectories, which throws DirectoryNotFoundException without naming the
965 // profile that was asked for
966 if (false == Directory.Exists(userDataDirectory))
967 RefuseProfile($"Looked for profile [{profileName}] in [{userDataDirectory}], which is not there");
968
969 string[] profileDirectories = Directory.GetDirectories(userDataDirectory);
970 List<string> matches = new List<string>();
971
972 foreach (string profileDirectory in profileDirectories)
973 {
974 string preferencesFile = Path.Combine(profileDirectory, "Preferences");
975
976 if (File.Exists(preferencesFile))
977 {
978 string profileNameFromFile = GetProfileNameFromPreferencesFile(preferencesFile);
979
980 if (null != profileNameFromFile && profileNameFromFile.Equals(profileName, StringComparison.OrdinalIgnoreCase))
981 matches.Add(new DirectoryInfo(profileDirectory).Name);
982 }
983 }
984
985 if (0 < matches.Count)
986 {
987 // Chrome allows duplicate display names. The first match is used; the warning names the
988 // alternatives and their sign-in state, which is usually what separates them
989 if (1 < matches.Count)
990 {
991 string choices = string.Join(", ", matches.Select(directory =>
992 $"[{directory}] ({SignedInSaid(Path.Combine(userDataDirectory, directory))})"));
993
994 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
995 $"[{matches.Count}] profiles are named [{profileName}]: {choices}. Using [{matches[0]}]. Name the directory instead of the display name to say which one you meant",
996 null, GPALObjectType.Browser);
997 }
998
999 return matches[0];
1000 }
1001 // Accepts a directory name as well as a display name. Chrome puts the directory name on
1002 // --profile-directory, so both forms are in circulation
1003 if (true == Directory.Exists(Path.Combine(userDataDirectory, profileName)))
1004 return profileName;
1005
1006 // Chrome creates an empty profile rather than refusing an unknown --profile-directory, so an
1007 // unmatched name is thrown rather than passed through
1008 RefuseProfile($"No profile named [{profileName}] in [{userDataDirectory}], by display name or directory name");
1009
1010 return null;
1011 }
1012
1013 // Sign-in state from account_info in the profile Preferences. Reported in the duplicate-name warning
1014 private static string SignedInSaid(string profileDirectory)
1015 {
1016 string retVal = "cannot tell";
1017
1018 try
1019 {
1020 string preferencesFile = Path.Combine(profileDirectory, "Preferences");
1021
1022 if (true == File.Exists(preferencesFile))
1023 {
1024 JObject preferences = JObject.Parse(File.ReadAllText(preferencesFile));
1025 JArray accounts = preferences.SelectToken("account_info") as JArray;
1026
1027 retVal = null != accounts && 0 < accounts.Count ? "signed in" : "not signed in";
1028 }
1029 }
1030 catch (Exception)
1031 {
1032 // An unparseable Preferences leaves the state unknown; the directory name is still reported
1033 }
1034
1035 return retVal;
1036 }
1037
1038 // Publishes the ERROR and throws, matching RefuseIfProfileIsOpen
1039 private static void RefuseProfile(string message)
1040 {
1041 GPAL.PublishSimpleEvent(GPALEventType.ERROR, message, null, GPALObjectType.Browser);
1042
1043 throw new GPALException($"{GPAL.MyMethodName()}: {message}");
1044 }
1045
1051 static string GetProfileNameFromPreferencesFile(string preferencesFilePath)
1052 {
1053 try
1054 {
1055 string preferencesContent = File.ReadAllText(preferencesFilePath);
1056 JObject preferencesJson = JObject.Parse(preferencesContent);
1057
1058 // Extract the given_name from the JSON structure
1059 JToken givenNameToken = preferencesJson.SelectToken("profile.name");
1060 if (givenNameToken != null)
1061 {
1062 return givenNameToken.ToString();
1063 }
1064
1065 return null; // given_name not found
1066 }
1067 catch
1068 {
1069 return null; // ERROR occurred while parsing JSON or accessing values
1070 }
1071 }
1072
1078 public static string FindFirefoxBinaryLocation()
1079 {
1080 // Open the registry key corresponding to Firefox installation
1081 using (RegistryKey key = Registry.LocalMachine.OpenSubKey(GPAL.GPALSettings.FirefoxBinaryPathRegistry))
1082 {
1083 if (key != null)
1084 {
1085 // Retrieve the version of Firefox installed
1086 string firefoxPath = key.GetValue(null)?.ToString(); // get (Default) value
1087 if (!string.IsNullOrEmpty(firefoxPath))
1088 return firefoxPath;
1089 }
1090 }
1091
1092 return null; // Firefox binary location not found
1093 }
1094
1101 public static string FindTab(BrowserSettings browserSettings, string URL)
1102 {
1103 var handles = browserSettings.BrowserDriver.WindowHandles;
1104 foreach (var handle in handles)
1105 {
1106 browserSettings.BrowserDriver.SwitchTo().Window(handle);
1107 if (URL.Contains(browserSettings.BrowserDriver.Url) || browserSettings.BrowserDriver.Url.Contains(URL))
1108 {
1109 return handle;
1110 }
1111 }
1112 return null;
1113 }
1114
1121 public static bool GotoNextPage(Browser browser)
1122 {
1123
1124 List<GPALElement> matchedElems = null;
1125 ReadOnlyCollection<GPALElement> elems = null;
1126 bool clicked = false;
1127 byte VK_END = 0x23;
1128 int withAllThatMatchSave = browser.CurrentUOW.WithAllThatMatch;
1129
1130 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Getting next page.", browser, GPALObjectType.Browser);
1131 browser.WithAllThatMatch(1);
1132
1133
1134 // CAVEAT: TODO: rework, scroll and scrape, scrolling might scroll elements off page or unrender them
1135 if (false == browser.CurrentUOW.InfiniteScroll)
1136 {
1137 // we cannot used cached results knowing we are going to a new page
1138 browser.CurrentUOW.NextPageButton.WebSelectorFoundResults = null;
1139 browser.CurrentUOW.NextPageButton.WebSelectorMatchedResults = null;
1140 foreach (var sel in browser.CurrentUOW.WithSelectorList)
1141 {
1142 sel.WebSelectorFoundResults = null;
1143 sel.WebSelectorMatchedResults = null;
1144 }
1145
1146 elems = ElementHelper.FindWebElements(browser, browser.CurrentUOW, browser.CurrentUOW.NextPageButton, out bool matchedAll, out matchedElems, null, false);
1147
1148 // can only be one match
1149 if (null != elems && 0 < elems.Count && true == matchedAll)
1150 {
1151 try
1152 {
1153
1154 if (false == string.IsNullOrEmpty(elems[0].Href))
1155 {
1156 string href = elems[0].Href;
1157 UnitOfWork saveUOW = browser.CurrentUOW; // keep this in
1158 bool obeySave = browser.BrowserSettings.ObeyRobotsTxt;
1159 browser.BrowserSettings.ObeyRobotsTxt = false;
1160 browser.GoTo(href); // checkdocumentready is in goto - do not perform robots check on navigation
1161
1162 // the site can put an interstitial in the way, a device check or similar. It is a page
1163 // of its own at a url of its own, and it is ready, so document.ready says nothing. The
1164 // page asked for is the one at the url asked for, so wait until that is where we are
1165 Stopwatch arriving = Stopwatch.StartNew();
1166 string here = GetCurrentUrl(browser.BrowserSettings);
1167
1168 if (false == href.Equals(here, StringComparison.OrdinalIgnoreCase))
1169 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Waiting for [{href}], the page showing is [{here}]", browser, GPALObjectType.Browser);
1170
1171 while (false == href.Equals(here, StringComparison.OrdinalIgnoreCase)
1172 && browser.BrowserSettings.WaitForDocumentReadyTimeoutMs > arriving.ElapsedMilliseconds)
1173 {
1174 System.Threading.Thread.Sleep(250);
1175
1176 here = GetCurrentUrl(browser.BrowserSettings);
1177 }
1178
1179 if (false == href.Equals(here, StringComparison.OrdinalIgnoreCase))
1180 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Gave up waiting for [{href}] after [{arriving.ElapsedMilliseconds}] ms, the page showing is [{here}]", browser, GPALObjectType.Browser);
1181
1182 browser.CurrentUOW = saveUOW;
1183 browser.BrowserSettings.ObeyRobotsTxt = obeySave;
1184 browser.BrowserSettings.CurrentURL = href;
1185 }
1186 else
1187 {
1188 // can be only one next page button elems[0] - on ebay, the dimensions are weird > 10k
1189 elems[0].Click();
1190 //CheckDocumentReady(browser);
1191 }
1192
1193 clicked = true;
1194 }
1195 catch (GPALException)
1196 {
1197 throw;
1198 }
1199 catch (Exception ex)
1200 {
1201 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Exception thrown", browser, GPALObjectType.Browser, ex);
1202 }
1203 }
1204 else
1205 {
1206 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Unable to find Next Page Button", browser, GPALObjectType.Browser);
1207 }
1208 }
1209 else // infinite scroll, go to page end and wait for load
1210 {
1211 if (false == browser.BrowserSettings.UseHeadless && (true == browser.BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware))
1212 HardwareHelper.SendKey(VK_END);
1213 else
1214 _ = browser.PageEnd;
1215
1216 CheckDocumentReady(browser);
1217
1218 clicked = true;
1219 }
1220
1221 browser.WithAllThatMatch(withAllThatMatchSave);
1222
1223 return clicked;
1224 }
1225
1236 public static void FillInWithTokens(Browser browser, IGPALGrid<string> tokens, WriteMode writeMode)
1237 {
1238 int tokenIdx = 0;
1239 List<ReadOnlyCollection<GPALElement>> rowsOfColumns = new List<ReadOnlyCollection<GPALElement>>();
1240 List<ReadOnlyCollection<GPALElement>> rowsOfElements = new List<ReadOnlyCollection<GPALElement>>();
1241 // a column-wise list of the selector info to match with the column of matched/returned elements to know how to interact with that column of elemnts
1242 // of course selectors are always a 'column'
1243 List<SmallSelectorNode> interactionInfo = new List<SmallSelectorNode>();
1244
1245 while (true)
1246 {
1247
1248 // waitfor did not match if returns false
1249 int rowCnt = 0;
1250 int lastRowCnt = 0;
1251 int elementIdx = 0;
1252
1253 interactionInfo.Clear(); // are we looping? of course, the CallAfterFillIn method workflow has returned for the next iteration.
1254 rowsOfElements.Clear();
1255 rowsOfColumns.Clear();
1256 browser.CurrentUOW.MatchedRowIndexes.Clear();
1257 browser.CurrentUOW.ColCount = 0;
1258 browser.CurrentUOW.RowCount = 0; // Math.Max below only ever raises it, so a pass that finds fewer elements than the last would size the grid for rows it has no elements to fill
1259 browser.CurrentUOW.PartialMatch = false; // only ever |= true below, so one pass that did not match everything would put every later pass down the partial-match path
1260
1261 foreach (Selector sel in browser.CurrentUOW.WithSelectorList)
1262 {
1263 if (SelectorType.Selector != sel.SelectorType)
1264 continue; // we don't look up data literals
1265
1266 // TODO: what to do on partial match?
1267 List<GPALElement> matchedElems = null;
1268 ReadOnlyCollection<GPALElement> tmpElems = ElementHelper.FindWebElements(browser, browser.CurrentUOW, sel, out bool matchedAll, out matchedElems);
1269 rowsOfElements.Add(tmpElems);
1270 browser.CurrentUOW.ColCount++;
1271
1272 if (false == matchedAll)
1273 browser.CurrentUOW.PartialMatch |= true;
1274
1275 if (0 < tmpElems?.Count && true == matchedAll)
1276 {
1277 // RowCount is the total count of elements found or just the WithAllThatMatch row count
1278 browser.CurrentUOW.RowCount = Math.Max(browser.CurrentUOW.RowCount, (int.MaxValue == browser.CurrentUOW.WithAllThatMatch ? tmpElems.Count : browser.CurrentUOW.WithAllThatMatch));
1279 }
1280 else if (0 < matchedElems?.Count && false == matchedAll)
1281 {
1282 // partial match
1283 int tmpIdx = 0;
1284 // add the indices of the matched items
1285 // when we build the grid, we will only get rows that matched
1286 // NO 'AND' on selector MATCH
1287 // only 'OR' operator, all those that match on different selectors
1288 foreach (GPALElement webElement in matchedElems)
1289 if (-1 != (tmpIdx = tmpElems.IndexOf(webElement)))
1290 if (false == browser.CurrentUOW.MatchedRowIndexes.Contains(new KeyValuePair<int, string>(tmpIdx, sel.AttributeName)))
1291 browser.CurrentUOW.MatchedRowIndexes.Add(tmpIdx, sel.AttributeName);
1292
1293 // matched row indexes is the row count we will extract data from on partial matches, so however many indices we have, that is our MAX row count
1294 // up to WithAllThatMatch row count if not int.MaxValue
1295 browser.CurrentUOW.RowCount = (int.MaxValue == browser.CurrentUOW.WithAllThatMatch ? browser.CurrentUOW.MatchedRowIndexes.Count : browser.CurrentUOW.WithAllThatMatch);
1296 }
1297
1298 // if we returned elements, add an entry for this selector in interactionInfo, we consume it below
1299 if (0 < matchedElems?.Count || 0 < tmpElems?.Count)
1300 interactionInfo.Add(new SmallSelectorNode() { InteractionType = sel.InteractionType, OffsetX = sel.OffsetX, OffsetY = sel.OffsetY, DeltaX = sel.DeltaX, DeltaY = sel.DeltaY });
1301 }
1302
1303 // we have partial matches, so construct the grid only from matched rows
1304 if (true == browser.CurrentUOW.PartialMatch)
1305 {
1306 List<GPALElement> newColumn = new List<GPALElement>();
1307
1308 browser.CurrentUOW.RowCount = browser.CurrentUOW.MatchedRowIndexes.Count;
1309 // get a list of all the elements in one column
1310 // iterate thru each column pulling out matched rows
1311 // create the return grid as only matched elements
1312 foreach (ReadOnlyCollection<GPALElement> webElements in rowsOfElements)
1313 {
1314 // create a newCOlumn from all elements via indexes where rows 'matched' on some selector value
1315 // we have multiple selectors contributing to matched rows based upon that selectors 'match' criteria
1316 foreach (KeyValuePair<int, string> idx in browser.CurrentUOW.MatchedRowIndexes)
1317 newColumn.Add(webElements[idx.Key]);
1318 rowsOfColumns.Add(new ReadOnlyCollection<GPALElement>(newColumn));
1319 newColumn = new List<GPALElement>();
1320 }
1321 }
1322 else
1323 {
1324 foreach (ReadOnlyCollection<GPALElement> webElements in rowsOfElements)
1325 rowsOfColumns.Add(webElements);
1326 }
1327
1328 // if we are looping, we have a new 'page' and new elements to interact with, our old list is stale
1329 browser.CurrentUOW.ElementGrid.Clear();
1330
1331 // we should have one column for each selector (that found elements)
1332 if (0 != browser.CurrentUOW.ColCount)
1333 {
1334 for (int cnt = 0; cnt < browser.CurrentUOW.RowCount; cnt++)
1335 browser.CurrentUOW.ElementGrid.AddRow(new List<UnitOfWork.ElementNode>(browser.CurrentUOW.ColCount)); // create a row of columns for the number of elements found
1336
1337 int columnIdx = 0; ; // start with the first column in the row
1338
1339 // grab a row of columns
1340 // construct our grid by pivoting the data from columns, to rows
1341 // iterate each column elements (rows) add an element to each row of the element grid
1342 foreach (ReadOnlyCollection<GPALElement> column in rowsOfColumns)
1343 {
1344 rowCnt = lastRowCnt; // if we are on subsequent pages, we have to add at the end of the list, up to WithAllThatMatch row count (or all rows)
1345 foreach (GPALElement elem in column)
1346 {
1347 if (true == ElementHelper.IsElementEditable(elem))
1348 browser.CurrentUOW.ElementGrid[rowCnt].Add(
1350 {
1351 GPALElement = elem,
1352 InteractionType = interactionInfo[columnIdx].InteractionType,
1353 OffsetX = interactionInfo[columnIdx].OffsetX,
1354 OffsetY = interactionInfo[columnIdx].OffsetY
1355 });
1356
1357 // only add as many rows as was asked for
1358 // all or only save up to withallthatmatch row count
1359 if (int.MaxValue != browser.CurrentUOW.WithAllThatMatch && rowCnt == browser.CurrentUOW.WithAllThatMatch) // only save the first one, we do not have the WithAll directive
1360 break;
1361
1362 rowCnt++;
1363 }
1364 }
1365 }
1366
1367 // TODO: CAVEAT: do we have concept of next page in apps? prolly? if we still have tokens and are done, shouldn't we get a next page or bail
1368 // BUG: IF WE consume all our tokens, why would we loop again (while true)
1369
1370 // keep looping while we have tokens
1371 // but after dealing with the first round of tokens, with more to go
1372 if (tokenIdx < tokens.Count())
1373 {
1374 // we now have our elements in rows and columns
1375 // iterate over the tokens, in rows and columns to fill in the text
1376 foreach (List<UnitOfWork.ElementNode> elementNode in browser.CurrentUOW.ElementGrid) // get one row of elements
1377 {
1378 elementIdx = 0;
1379 List<string> currentRow = tokens[tokenIdx];
1380
1381 foreach (string token in currentRow) // iterate over tokens, filling in the corresponding input/textarea webelement
1382 {
1383 if (elementIdx < elementNode.Count && null != elementNode[elementIdx])
1384 {
1385 ElementHelper.ScrollIntoView(browser, elementNode[elementIdx].GPALElement, elementNode[elementIdx].InteractionType);
1386
1387 if (true == browser.UseOttoMagic)
1388 {
1389 if (InteractionType.Hardware == elementNode[elementIdx].InteractionType)
1390 ElementHelper.HardwareFillInFrom(browser, elementNode[elementIdx].GPALElement, elementNode[elementIdx].OffsetX, elementNode[elementIdx].OffsetY, token.ToString(), writeMode);
1391 else
1392 switch (writeMode)
1393 {
1394 case WriteMode.Append:
1395 browser.BrowserSettings.MagicHelper.FillInAppend(elementNode[elementIdx].GPALElement.Css, token.ToString());
1396 break;
1397
1398 case WriteMode.Insert:
1399 browser.BrowserSettings.MagicHelper.FillInInsert(elementNode[elementIdx].GPALElement.Css, token.ToString());
1400 break;
1401
1402 case WriteMode.Overwrite:
1403 browser.BrowserSettings.MagicHelper.FillInOverwrite(elementNode[elementIdx].GPALElement.Css, token.ToString());
1404 break;
1405 }
1406 }
1407 else if (true == browser.UsePuppeteer)
1408 {
1409 if (InteractionType.Hardware == elementNode[elementIdx].InteractionType)
1410 ElementHelper.HardwareFillInFrom(browser, elementNode[elementIdx].GPALElement, elementNode[elementIdx].OffsetX, elementNode[elementIdx].OffsetY, token.ToString(), writeMode);
1411 else
1412 switch (writeMode)
1413 {
1414 case WriteMode.Append:
1415 browser.PuppeteerClient.FillInAppend(elementNode[elementIdx].GPALElement.Css).WithText(token.ToString()).Execute();
1416 break;
1417
1418 case WriteMode.Insert:
1419 browser.PuppeteerClient.FillInInsert(elementNode[elementIdx].GPALElement.Css).WithText(token.ToString()).Execute();
1420 break;
1421
1422 case WriteMode.Overwrite:
1423 browser.PuppeteerClient.FillInOverwrite(elementNode[elementIdx].GPALElement.Css).WithText(token.ToString()).Execute();
1424 break;
1425 }
1426 }
1427 else // selenium
1428 {
1429 if (InteractionType.Hardware == elementNode[elementIdx].InteractionType)
1430 ElementHelper.HardwareFillInFrom(browser, elementNode[elementIdx].GPALElement, elementNode[elementIdx].OffsetX, elementNode[elementIdx].OffsetY, token.ToString(), writeMode);
1431 else if (InteractionType.JavaScript == elementNode[elementIdx].InteractionType)
1432 ElementHelper.JavaScriptFillInFrom(browser, elementNode[elementIdx].GPALElement, token.ToString(), writeMode);
1433 else
1434 ElementHelper.SeleniumFillInFrom(browser, elementNode[elementIdx].GPALElement, token.ToString(), writeMode);
1435 }
1436
1437 elementIdx++; // increment the column [element] count for each token. if we have a token, we are expecting elements for it.
1438 }
1439 else
1440 // detect if we have too many tokens to consume and publish an information event [entirely possible this scenario is fine]
1441 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Unused token [{token}] from tokens [{string.Join(",", currentRow)}] in row [{tokenIdx + 1}]", browser, GPALObjectType.Browser);
1442
1443 try
1444 {
1445 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{writeMode}] text [{token.ToString()}] in element [{elementNode[elementIdx - 1].GPALElement.TagName}]", browser, GPALObjectType.Browser);
1446 }
1447 catch
1448 {
1449 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"NOT WRITTEN: [{writeMode}] text [{token.ToString()}] in element [NOT FOUND].", browser, GPALObjectType.Browser);
1450 }
1451 }
1452
1453 // detect if we do not have enough tokens for all the form fields [entirely possible this scenario is fine]
1454 // we got here and finished our tokens but have not exhausted our columns [elements]
1455 while (elementIdx < elementNode.Count && null != elementNode[elementIdx++])
1456 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No token for control: <[{elementNode[elementIdx - 1].GPALElement.GetType()}]> : [{elementNode[elementIdx - 1].GPALElement.Text}]", browser, GPALObjectType.Browser);
1457 }
1458
1459 UnitOfWork safeUOW = browser.CurrentUOW;
1460 CallIfStatus handled = 0;
1461
1462 // CAVEAT: there is no concept of 'handled (1)' vs 'not handled (0)' but definitely can request to exit
1463 // TODO: CAVEAT: is cast to dynamic best?
1464 if (null != browser.CurrentUOW.CallAfterFillIn && true == rowsOfColumns.Any())
1465 {
1466 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Invoking CallAfterFillIn [{browser.CurrentUOW.CallAfterFillIn.Method.Name}]", browser, GPALObjectType.Browser);
1467 handled = browser.CurrentUOW.CallAfterFillIn(browser, tokens, tokenIdx);
1468 }
1469
1470 // CAVEAT: kludge - the call after handler can and probably will set a new current unit of work, but we need our old current unit
1471 // the call after handler UOW is no longer in scope, so restore our UOW
1472 browser.CurrentUOW = safeUOW;
1473
1474 // CAVEAT: elements may go stale because of callafterfillin handler actions
1475 // test if elements still valid by testing the first one
1476 //try
1477 //{
1478 // ((UnitOfWork.ElementNode)(browser.CurrentUOW.ElementGrid[0])[0]).GPALElement.GetAttribute("tag");
1479 //}
1480 //catch (Exception ex) // failed, invalidate elementgrid
1481 //{
1482 // browser.CurrentUOW.ElementGrid.Clear();
1483 // GPAL.PublishSimpleEvent(GPALEventType.INFO, $"CallAfterFillIn handler [{browser.CurrentUOW.CallAfterFillIn.GetInvocationList()[0].Method.Name}] invalidated browser elements, clearing cache. Exception follows: [{ex.Message}]", browser, GPALObjectType.Browser, ex);
1484 // break;
1485 //}
1486
1487 if (CallIfStatus.Terminate == handled)
1488 {
1489 string str = $"CallAfterFillIn handler [{browser.CurrentUOW.CallAfterFillIn.GetInvocationList()[0].Method.Name}] requested program termination.";
1490 GPAL.PublishSimpleEvent(GPALEventType.INFO, str, browser, GPALObjectType.Browser);
1491 throw new GPALException($"{GPAL.MyMethodName()}: " + str);
1492 }
1493
1494 tokenIdx++;
1495 }
1496 else
1497 break;
1498
1499 lastRowCnt += rowCnt;
1500 // TODO: are we done or do we have to go to the next page?
1501
1502 // we are out of tokens, break out
1503 // if we have more tokens, shouldn't we go to another page?
1504 // CAVEAT: BUG: we are just looping back thru the same selectors and consuming more tokens, so does that mean CallAfterFillIn will do something and this is the behavior we want?
1505 // or do we break because we have more tokens then elements? or should we loop?
1506 // why did i put this in a while true loop?
1507 if (tokenIdx == tokens.Count())
1508 break;
1509 }
1510 }
1511
1512 // this is failing after clicks on wells fargo because it's checking faster than the page can load, but i'm reticent to add too much of a delay in jswait
1513 // since this is not entirely critical, we are just going to not print out any messages about this failing, but we also updated execute javascript to return 'fail' instead of 'complete' on catching an exception
1514 // that will result in this waiting the full 30 seconds timeout. not sure if that is good or bad, we will see.
1515
1516 // gap between url reads while waiting to see if a click navigated. Each read is a driver round trip, so this
1517 // is not a hot loop the way WaitForValueOrTime is
1518 private const int NavigationPollMs = 50;
1519
1520 // chromium window handles are the CDP target id behind this prefix; firefox reports its pid in capabilities
1521 private const string CdpWindowPrefix = "CDwindow-";
1522 private const string MozProcessIdCapability = "moz:processID";
1540 static string ReadyStatusOrNavError(Browser browser, string sessionToken)
1541 {
1542 string status = browser.MagicHelper.GetReadyStatus(sessionToken)?.Trim('"');
1543
1544 if (true == status?.ToLower().Contains("nav-error"))
1545 {
1546 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"The page did not load, so there is nothing to work with. [{status}]", browser, GPALObjectType.Browser);
1547 browser.RaiseOnFail(GPALFailure.Navigation, status);
1548 }
1549
1550 return status;
1551 }
1552 public static void CheckDocumentReady(Browser browser, bool overrideSetting = false)
1553 {
1554 bool hasURL = null != browser?.BrowserSettings.CurrentURL && false == "about:blank".Equals(browser.BrowserSettings.CurrentURL);
1555
1556 // A loading page rejects script, which is the condition this method waits out. Restored rather
1557 // than cleared so a nested call does not re-enable it, and held in a finally so a throw does not
1558 // leave the browser suppressed
1559 bool wasSuppressed = true == browser?.BrowserSettings.SuppressJavaScriptFailures;
1560 string sessionToken = Guid.NewGuid().ToString();
1561
1562 if (null != browser)
1563 browser.BrowserSettings.SuppressJavaScriptFailures = true;
1564
1565 try
1566 {
1567 if ((true == browser?.BrowserSettings.WaitOnDocumentReady && true == hasURL) || true == overrideSetting)
1568 {
1569 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Waiting up to [{browser.BrowserSettings.WaitForDocumentReadyTimeoutMs}] ms on document.ready");
1570
1571 // nav-error is reported instead of a ready state when the navigation failed outright, so
1572 // the caller stops rather than polling to the timeout
1573 if (true == browser?.UseOttoMagic)
1574 WaitForValueOrTime(browser, "complete, interactive, nav-error", () => ReadyStatusOrNavError(browser, sessionToken), browser.BrowserSettings.WaitForDocumentReadyTimeoutMs);
1575 else if (true == browser?.UsePuppeteer)
1576 WaitForValueOrTime(browser, "complete, interactive", () => ((PuppeteerClient)browser.PuppeteerClient).GetReadyStatus(sessionToken).Execute<object>(), browser.BrowserSettings.WaitForDocumentReadyTimeoutMs);
1577 else // handles selenium
1578 WaitForValueOrTime(browser, "complete, interactive", () => JsCheckReadyState(browser, sessionToken), browser.BrowserSettings.WaitForDocumentReadyTimeoutMs);
1579 }
1580 else if (true == browser.BrowserSettings.WaitOnNetworkIdle && true == hasURL)
1581 {
1582 if (true == browser.UsePuppeteer)
1583 WaitForValueOrTime(browser, "true", () => browser.PuppeteerClient.CheckNetworkIdle().WithSessionToken(sessionToken).Execute<bool>().ToString(), browser.BrowserSettings.WaitForDocumentReadyTimeoutMs);
1584 else if (true == browser.BrowserSettings.UseOttoMagic)
1585 WaitForValueOrTime(browser, "true", () => WaitForNetworkIdle(browser, browser.BrowserSettings.NetworkIdleTimeoutMs, browser.BrowserSettings.NetworkIdleMaxConnections, browser.BrowserSettings.NetworkIdlePruneMs, sessionToken).ToString(), browser.BrowserSettings.WaitForDocumentReadyTimeoutMs);
1586 else
1587 WaitForValueOrTime(browser, "true", () => WaitForNetworkIdle(browser, sessionToken).ToString(), browser.BrowserSettings.WaitForDocumentReadyTimeoutMs);
1588 }
1589 }
1590 finally
1591 {
1592 if (null != browser)
1593 browser.BrowserSettings.SuppressJavaScriptFailures = wasSuppressed;
1594 }
1595 }
1596
1618 internal static void FetchWithTokens(Browser browser, GPALRequest request)
1619 {
1620 IGPALGrid<string> tokens = browser.CurrentUOW.FetchTokens;
1621 int required = request.TokenCount();
1622 string name = request.Name ?? request.Path;
1623
1624 if (null == tokens && 0 < required)
1625 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Fetch of [{name}] uses [{required}] tokens but no token source was given. Use .WithTokensFrom before .Fetch.", browser, GPALObjectType.Browser);
1626 else
1627 {
1628 // no token source is still one pass, the request exactly as it was written
1629 int rowCount = tokens?.Rows ?? 1;
1630
1631 for (int tokenIdx = 0; tokenIdx < rowCount; tokenIdx++)
1632 {
1633 List<string> row = null == tokens ? null : tokens[tokenIdx];
1634
1635 // a row with a hole in it would send a body with a token still in it, which is worse than not
1636 // sending it at all
1637 if (null != row && row.Count < required)
1638 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Fetch of [{name}] needs [{required}] tokens but row [{tokenIdx + 1}] has [{row.Count}]. Skipping the row.", browser, GPALObjectType.Browser);
1639 else
1640 FetchRow(browser, request, row, tokenIdx);
1641 }
1642 }
1643 }
1652 static void FetchRow(Browser browser, GPALRequest request, List<string> row, int tokenIdx)
1653 {
1654 int mark = browser.CurrentUOW.FetchResults.Count;
1655 string name = request.Name ?? request.Path;
1656
1657 for (int page = 0; page < browser.CurrentUOW.PageCount; page++)
1658 {
1659 string where = null == row ? $"Page [{page + 1}]" : $"Row [{tokenIdx + 1}] page [{page + 1}]";
1660
1661 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Fetching [{name}]. {where}.", browser, GPALObjectType.Browser);
1662
1663 string body = FetchInPage(browser, request, page, row);
1664
1665 // nothing here stops the run. one page failing says nothing about the next one, or about the rows
1666 // after it, and whatever went wrong is written into the results so it reaches .SaveTo and
1667 // CallAfterFetch rather than living only in the log. every page still produces an entry, so
1668 // position in the results always maps to the row and page that made it
1669 if (null == body)
1670 {
1671 string why = $"Fetch of [{name}] returned nothing on {where.ToLower()}.";
1672
1673 GPAL.PublishSimpleEvent(GPALEventType.ERROR, why, browser, GPALObjectType.Browser);
1674 body = why;
1675 }
1676 else if (200 > browser.ServerResponseCode || 300 <= browser.ServerResponseCode)
1677 {
1678 // a server that answered is data whatever it answered, and the body is usually the only thing
1679 // that says why, so it is kept with the reason in front of it
1680 string why = $"Fetch of [{name}] returned HTTP [{browser.ServerResponseCode}] on {where.ToLower()}.";
1681
1682 GPAL.PublishSimpleEvent(GPALEventType.WARNING, why, browser, GPALObjectType.Browser);
1683 body = why + Environment.NewLine + body;
1684 }
1685
1686 browser.CurrentUOW.FetchResults.Add(body);
1687 }
1688
1689 if (null != request.AfterFetch)
1690 {
1691 UnitOfWork safeUOW = browser.CurrentUOW;
1692 List<string> results = browser.CurrentUOW.FetchResults.GetRange(mark, browser.CurrentUOW.FetchResults.Count - mark);
1693
1694 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Invoking CallAfterFetch [{request.AfterFetch.Method.Name}]", browser, GPALObjectType.Browser);
1695
1696 CallIfStatus handled = request.AfterFetch(browser, results, browser.CurrentUOW.FetchTokens, tokenIdx);
1697
1698 // the handler can and probably will start its own unit of work, so restore ours
1699 browser.CurrentUOW = safeUOW;
1700
1701 if (CallIfStatus.Terminate == handled)
1702 {
1703 string str = $"CallAfterFetch handler [{request.AfterFetch.Method.Name}] requested program termination.";
1704 GPAL.PublishSimpleEvent(GPALEventType.INFO, str, browser, GPALObjectType.Browser);
1705 throw new GPALException($"{GPAL.MyMethodName()}: " + str);
1706 }
1707 }
1708 }
1714 internal static string FetchInPage(Browser browser, GPALRequest request, int page, List<string> row)
1715 {
1716 // GPALRequest, paging and tokens are GPAL ideas, so they are resolved here and the layers below only
1717 // ever see a finished url and plain values, the same as every other endpoint
1718 string envelope = FetchEnvelope(browser, request.ResolveUrl(page, row), request.Method, request.ResolveBody(page, row), request.ContentType, request.HeaderPairs(page, row), false);
1719
1720 return UnpackFetchResponse(browser, envelope);
1721 }
1735 static string FetchEnvelope(Browser browser, string url, string method, string body, string contentType, string[] headers, bool asBytes)
1736 {
1737 string envelope = null;
1738
1739 if (true == browser.UseSelenium)
1740 envelope = ((IJavaScriptExecutor)browser.BrowserDriver).ExecuteAsyncScript(
1741 FetchSetupScript(url, method, body, contentType, headers, asBytes) + @"
1742 var done = arguments[arguments.length - 1];
1743
1744 fetch(target.toString(), options)
1745 .then(gpalEnvelope)
1746 .then(done)
1747 .catch(function (error) { done(gpalFailure(error)); });
1748 ") as string;
1749 else if (true == browser.UsePuppeteer)
1750 envelope = browser.PuppeteerClient
1751 .Fetch(url)
1752 .WithVerb(method)
1753 .WithBody(body)
1754 .WithContentType(contentType)
1755 .WithHeaders(headers)
1756 .WithBytes(asBytes)
1757 .Execute();
1758 else if (true == browser.UseOttoMagic)
1759 envelope = browser.MagicHelper.Fetch(url, method, body, contentType, headers, asBytes);
1760 else
1761 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Fetch is not implemented for [{browser.BrowserSettings.AutomationEngine}] yet.", browser, GPALObjectType.Browser);
1762
1763 return envelope;
1764 }
1774 internal static string LiveUserAgent(Browser browser)
1775 {
1776 string retVal = null;
1777
1778 if (true == browser.UseSelenium)
1779 retVal = ((IJavaScriptExecutor)browser.BrowserDriver).ExecuteScript("return navigator.userAgent") as string;
1780 else if (true == browser.UsePuppeteer)
1781 retVal = browser.PuppeteerClient.GetUserAgent().Execute();
1782 else if (true == browser.UseOttoMagic)
1783 retVal = Unquoted(browser.MagicHelper.GetUserAgent());
1784
1785 return retVal;
1786 }
1787
1795 static string Unquoted(string value)
1796 {
1797 string retVal = value;
1798
1799 while (false == string.IsNullOrEmpty(retVal) && true == retVal.StartsWith("\"") && true == retVal.EndsWith("\"") && 1 < retVal.Length)
1800 {
1801 string unwrapped = Newtonsoft.Json.JsonConvert.DeserializeObject<string>(retVal);
1802
1803 if (unwrapped == retVal)
1804 break;
1805
1806 retVal = unwrapped;
1807 }
1808
1809 return retVal;
1810 }
1820 internal static string LiveAcceptLanguage(Browser browser)
1821 {
1822 string languages = null;
1823 string retVal = null;
1824
1825 if (true == browser.UseSelenium)
1826 languages = ((IJavaScriptExecutor)browser.BrowserDriver).ExecuteScript("return navigator.languages.join(',')") as string;
1827 else if (true == browser.UsePuppeteer)
1828 languages = browser.PuppeteerClient.GetLanguages().Execute();
1829 else if (true == browser.UseOttoMagic)
1830 languages = Unquoted(browser.MagicHelper.GetLanguages());
1831
1832 if (false == string.IsNullOrWhiteSpace(languages))
1833 retVal = AcceptLanguageFrom(languages.Split(','), browser.BrowserSettings.BrowserType);
1834
1835 return retVal;
1836 }
1837
1851 static string AcceptLanguageFrom(string[] languages, Enums.BrowserType browserType)
1852 {
1853 List<string> weighted = new List<string>();
1854
1855 for (int index = 0; index < languages.Length; index++)
1856 {
1857 string language = languages[index].Trim();
1858
1859 if (true == string.IsNullOrEmpty(language))
1860 continue;
1861
1862 if (Enums.BrowserType.FireFox == browserType)
1863 language = language.ToLowerInvariant();
1864
1865 if (0 == index)
1866 weighted.Add(language);
1867 else
1868 {
1869 // a tenth is as fine as the header goes, and a language weighted to nothing is left off
1870 // rather than sent as q=0.0, which asks the server not to answer in it at all
1871 double quality = Math.Round(1.0 - (0.1 * index), 1);
1872
1873 if (0 < quality)
1874 weighted.Add($"{language};q={quality.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture)}");
1875 }
1876 }
1877
1878 return string.Join(",", weighted);
1879 }
1892 internal static byte[] FetchBytes(Browser browser, string url, string[] headers, out string lastModified)
1893 {
1894 byte[] bytes = null;
1895 JObject response = UnpackFetchEnvelope(browser, FetchEnvelope(browser, url, "GET", null, null, headers, true));
1896 string encoded = response?["body"]?.ToString();
1897
1898 lastModified = response?["lastModified"]?.ToString();
1899
1900 if (false == string.IsNullOrEmpty(encoded))
1901 bytes = Convert.FromBase64String(encoded);
1902
1903 return bytes;
1904 }
1913 static string UnpackFetchResponse(Browser browser, string envelope)
1914 {
1915 return UnpackFetchEnvelope(browser, envelope)?["body"]?.ToString();
1916 }
1924 static JObject UnpackFetchEnvelope(Browser browser, string envelope)
1925 {
1926 JObject response = null;
1927
1928 // a fetch that could not be made has no status of its own, and leaving the last one standing would
1929 // let a caller read a stale 200 or 304 as though it belonged to this request
1930 browser.ServerResponseCode = 0;
1931
1932 if (true == string.IsNullOrEmpty(envelope))
1933 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{browser.BrowserSettings.AutomationEngine}] returned nothing for the fetch, so the page never answered", browser, GPALObjectType.Browser);
1934 else
1935 {
1936 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"Fetch envelope [{envelope}]", browser, GPALObjectType.Browser);
1937
1938 // never parse blind. a shape we do not recognize is reported with what actually arrived, because
1939 // a stack trace out of a json parser says nothing about what the page sent
1940 if (false == envelope.TrimStart().StartsWith("{"))
1941 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Fetch response was not understood. Received [{envelope}]", browser, GPALObjectType.Browser);
1942 else
1943 {
1944 response = JObject.Parse(envelope);
1945 browser.ServerResponseCode = response["status"]?.Value<int>() ?? 0;
1946
1947 // the page could not make the request at all and said why. Without this the caller only knows
1948 // that nothing came back, which is the same thing a refusal and a bug look like
1949 if (false == string.IsNullOrEmpty(response["error"]?.ToString()))
1950 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"The page could not make the request [{response["error"]}]", browser, GPALObjectType.Browser);
1951 }
1952 }
1953
1954 return response;
1955 }
1970 internal static string FetchSetupScript(string url, string method, string body, string contentType, string[] headers, bool asBytes)
1971 {
1972 return $@"
1973 var target = new URL({Newtonsoft.Json.JsonConvert.SerializeObject(url)}, location.href);
1974 var reqBody = {Newtonsoft.Json.JsonConvert.SerializeObject(body)};
1975 var contentType = {Newtonsoft.Json.JsonConvert.SerializeObject(contentType)};
1976 var headerPairs = {Newtonsoft.Json.JsonConvert.SerializeObject(headers ?? new string[0])};
1977 var asBytes = {(true == asBytes ? "true" : "false")};
1978
1979 var options = {{ method: {Newtonsoft.Json.JsonConvert.SerializeObject(method ?? "GET")}, credentials: 'include', headers: {{}} }};
1980 for (var h = 0; h < headerPairs.length; h += 2)
1981 options.headers[headerPairs[h]] = headerPairs[h + 1];
1982
1983 if (reqBody) {{
1984 options.body = reqBody;
1985 if (!options.headers['Content-Type'])
1986 options.headers['Content-Type'] = contentType;
1987 }}
1988
1989 // one envelope for every engine, so the C# side has a single shape to unpack. text() would put a
1990 // replacement character wherever a byte is not valid utf-8, which is most of any real file, so a
1991 // file is read as an arraybuffer and base64 encoded instead. Written with promises rather than
1992 // await so the same function serves selenium's callback and puppeteer's awaited expression.
1993 // String.fromCharCode is applied over slices because one call with a few hundred thousand
1994 // arguments overflows the stack
1995 function gpalEnvelope(response) {{
1996 return (asBytes
1997 ? response.arrayBuffer().then(function (buffer) {{
1998 var bytes = new Uint8Array(buffer), binary = '', slice = 0x8000;
1999 for (var i = 0; i < bytes.length; i += slice)
2000 binary += String.fromCharCode.apply(null, bytes.subarray(i, i + slice));
2001 return btoa(binary);
2002 }})
2003 : response.text()
2004 ).then(function (payload) {{
2005 return JSON.stringify({{
2006 status: response.status,
2007 body: payload,
2008 encoding: asBytes ? 'base64' : null,
2009 lastModified: response.headers.get('Last-Modified')
2010 }});
2011 }});
2012 }}
2013
2014 // a fetch the page could not make still answers in the envelope shape, carrying the reason.
2015 // returning nothing here is what made a refused request and a bug look identical from C#
2016 function gpalFailure(error) {{
2017 return JSON.stringify({{
2018 status: 0,
2019 body: null,
2020 encoding: null,
2021 lastModified: null,
2022 error: String((error && error.message) || error) + ' requesting ' + target.toString()
2023 }});
2024 }}
2025 ";
2026 }
2027 public static object ExecuteJavaScriptObj(string executeMe, IBrowser browser, params object[] args)
2028 {
2029 dynamic retVal = null;
2030
2031 try
2032 {
2033 if (browser.UseOttoMagic)
2034 {
2035 retVal = browser.MagicHelper.ExecuteJavaScript(executeMe);
2036 }
2037 else if (true == browser.UsePuppeteer)
2038 {
2039 retVal = browser.PuppeteerClient.ExecuteJavaScript(executeMe).WithParameters(args).Execute<object>();
2040 retVal = retVal.result.value;
2041 }
2042 else // beginning chrome 136, dynamic javascript execution, even by a browser extension, is no longer possihle. has to be baked in code.
2043 {
2044 IJavaScriptExecutor js = (IJavaScriptExecutor)browser.BrowserDriver;
2045 retVal = js.ExecuteScript(executeMe, args);
2046 }
2047 }
2048 catch (GPALException)
2049 {
2050 throw;
2051 }
2052 catch (Exception ex)
2053 {
2054 // Read from the browser that failed. A non-Browser argument is not suppressed
2055 bool suppressed = true == (browser as Browser)?.BrowserSettings.SuppressJavaScriptFailures;
2056
2057 if (false == suppressed)
2058 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $@"Failed to execute javascript. Continuing.", browser, GPALObjectType.Browser, ex);
2059 }
2060
2061 return retVal;
2062 }
2063 // NOTE: if this fails we could fallback to puppeteer, but could we do anything with selenium if this doesn't work?
2085 private static string UnwrapScriptResult(string result)
2086 {
2087 string retVal = result;
2088
2089
2090 // the RemoteObject describes the result: a type, and the value it is describing. anything else is a
2091 // returned object that happens to be json, and belongs to the caller untouched
2092 if (false == string.IsNullOrWhiteSpace(retVal) && true == retVal.TrimStart().StartsWith("{"))
2093 {
2094 try
2095 {
2096 Newtonsoft.Json.Linq.JObject remote = Newtonsoft.Json.Linq.JObject.Parse(retVal);
2098 if (null != remote["type"] && null != remote["value"]
2099 && remote.Properties().All(each => "type" == each.Name || "value" == each.Name || "description" == each.Name || "className" == each.Name || "subtype" == each.Name))
2100 {
2101 Newtonsoft.Json.Linq.JToken value = remote["value"];
2102
2103 retVal = Newtonsoft.Json.Linq.JTokenType.String == value.Type
2104 ? value.ToString()
2105 : Newtonsoft.Json.JsonConvert.SerializeObject(value);
2106 }
2107 }
2108 catch (Exception)
2109 {
2110 // shaping the answer is never a reason to fail the call
2111 }
2112 }
2113
2114 // a json encoded string, one layer at a time, until it stops being one
2115 while (false == string.IsNullOrEmpty(retVal) && retVal.StartsWith("\"") && retVal.EndsWith("\""))
2116 {
2117 try
2118 {
2119 string peeled = Newtonsoft.Json.JsonConvert.DeserializeObject<string>(retVal);
2120
2121 if (peeled == retVal)
2122 break;
2123
2124 retVal = peeled;
2125 }
2126 catch (Exception)
2127 {
2128 break; // not an encoded string after all, so it is the value already
2129 }
2130 }
2131
2132 return retVal;
2133 }
2134
2150 public static string ExecuteJavaScript(IBrowser browser, string executeMe, params object[] args)
2151 {
2152 string retVal = RunScript(browser, executeMe, args);
2153
2154 // V8 raises this message only for a return outside a function
2155 if (false == browser.UseSelenium && true == retVal?.Contains("Illegal return statement"))
2156 retVal = RunScript(browser, "(function(){" + Environment.NewLine + executeMe + Environment.NewLine + "})()", args);
2157
2158 return retVal;
2159 }
2160
2161 private static string RunScript(IBrowser browser, string executeMe, params object[] args)
2162 {
2163 dynamic retVal = null;
2165 try
2166 {
2167 if (browser.UseOttoMagic)
2168 {
2169 retVal = browser.MagicHelper.ExecuteJavaScript(executeMe);
2170 }
2171 else if (true == browser.UsePuppeteer)
2172 {
2173 if (true == args.Any())
2174 retVal = browser.PuppeteerClient.ExecuteJavaScript(executeMe).WithParameters(args).Execute<object>();
2175 else
2176 retVal = browser.PuppeteerClient.ExecuteJavaScript(executeMe).Execute<object>();
2177
2178 if (Puppeteer.resultExtractors != null && Puppeteer.resultExtractors.TryGetValue(DevToolsMethods.RuntimeEvaluate, out var extractor))
2179 retVal = extractor((Newtonsoft.Json.Linq.JObject)retVal.result);
2180 else
2181 retVal = retVal.result.ToObject();
2182 }
2183 else // beginning chrome 136, dynamic javascript execution, even by a browser extension, is no longer possihle. has to be baked in code.
2184 {
2185 IJavaScriptExecutor js = (IJavaScriptExecutor)browser.BrowserDriver;
2186 if (true == args?.Any())
2187 retVal = js.ExecuteScript(executeMe, args);
2188 else
2189 retVal = js.ExecuteScript(executeMe);
2191
2192 }
2193 }
2194 catch (GPALException)
2195 {
2196 throw;
2197 }
2198 catch (Exception ex)
2199 {
2200 // Read from the browser that failed. A non-Browser argument is not suppressed
2201 bool suppressed = true == (browser as Browser)?.BrowserSettings.SuppressJavaScriptFailures;
2202
2203 if (false == suppressed)
2204 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $@"Failed to execute javascript. Continuing.", browser, GPALObjectType.Browser, ex);
2205 retVal = "fail";
2206 }
2207
2208 return UnwrapScriptResult(retVal?.ToString());
2209 }
2210
2217 public static string GetCurrentUrl(BrowserSettings browserSettings)
2218 {
2219 string currentUrl = null;
2220
2221 if (true == browserSettings.UseOttoMagic)
2222 currentUrl = browserSettings.MagicHelper.GetCurrentUrl();
2223 else if (true == browserSettings.UsePuppeteer)
2224 currentUrl = browserSettings.PuppeteerClient.GetCurrentUrl().Execute();
2225 else
2226 currentUrl = browserSettings.Browser.BrowserDriver.Url;
2227
2228 // ottomagic and puppeteer answer over the rest layer, whose untyped Execute hands back the response
2229 // body as it stands - a json string, quotes and all. deserializing rather than trimming so an escaped
2230 // quote inside the url comes back as the character it is
2231 if (true == currentUrl?.StartsWith("\"") && true == currentUrl.EndsWith("\""))
2232 currentUrl = Newtonsoft.Json.JsonConvert.DeserializeObject<string>(currentUrl);
2233
2234 return currentUrl;
2235 }
2243 public static string JsCheckReadyState(dynamic browser, string sessionToken)
2244 {
2245 string readyState = "loading";
2246
2247 if (lastSessionToken != sessionToken)
2248 {
2249 lastErrorMessage.Clear();
2250 supressedMessage = false;
2251 lastSessionToken = sessionToken;
2252 }
2253
2254 try
2256 if (browser.UseOttoMagic)
2257 {
2258 readyState = browser.BrowserSettings.MagicHelper.GetReadyStatus();
2259 if (null == readyState)
2260 readyState = @"loading";
2261 else
2262 readyState = readyState.Trim('"');
2263 }
2264 else
2265 {
2266 bool withReturn = false == browser.UsePuppeteer;
2267
2268 // use javascript to get our html document location, but it's off by 2 pixels compared to uiautomation. uiautomation, however, failed so often that is was unreliable
2269 readyState = ExecuteJavaScript(browser, $"{(true == withReturn ? "return " : "")}document.readyState");
2270 }
2271 }
2272 catch (GPALException)
2273 {
2274 throw;
2275 }
2276 catch (Exception ex)
2277 {
2278 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
2279 $"Error executing JavaScript",
2280 null, GPALObjectType.None, ex);
2281 }
2282
2283 string currentErrorMessage = $"Document.Ready status [{readyState}]";
2284
2285 if (false == lastErrorMessage.Contains(currentErrorMessage))
2286 {
2287 lastErrorMessage.Add(currentErrorMessage);
2288 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage);
2289 supressedMessage = false;
2290 }
2291 else if (false == supressedMessage)
2292 {
2293 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
2294 supressedMessage = true;
2295 }
2296
2297 return readyState;
2298 }
2299
2300 // written by grok
2308 public static void SeleniumGoToUrl(GPALUrl url, BrowserSettings browserSettings)
2309 {
2310 string normalizedTarget = url.ToString().TrimEnd('/').ToLowerInvariant();
2311
2312 try
2313 {
2314 // 1. Perform navigation
2315 browserSettings.BrowserDriver.Navigate().GoToUrl(url);
2316
2317 // Give Chrome a brief moment for network events (helps on fast 304s / cached pages)
2318 System.Threading.Thread.Sleep(400); // 400–800 ms usually enough; can be replaced with WebDriverWait if preferred
2319
2320 // 2. Fetch performance logs (Chrome/Edge only — geckodriver does not support getLog)
2321 if (browserSettings.BrowserType != BrowserType.FireFox)
2322 {
2323
2324 var performanceLogs = browserSettings.BrowserDriver.Manage().Logs.GetLog("performance");
2325
2326 int? bestStatus = null;
2327 string bestMatchedUrl = null;
2328 int candidatePriority = int.MaxValue;
2329
2330 foreach (var entry in performanceLogs)
2331 {
2332 string message = entry.Message;
2333
2334 if (message.IndexOf("Network.responseReceived", StringComparison.Ordinal) == -1)
2335 continue;
2336
2337 // Quick filter to reduce sub-resource noise
2338 if (message.IndexOf("\"url\":\"", StringComparison.Ordinal) == -1 ||
2339 message.IndexOf(normalizedTarget, StringComparison.OrdinalIgnoreCase) == -1)
2340 continue;
2341
2342 // Extract status
2343 var statusMatch = System.Text.RegularExpressions.Regex.Match(
2344 message,
2345 @"""status(?:Code)?""\s*:\s*(\d+)",
2346 System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant
2347 );
2348
2349 if (!statusMatch.Success)
2350 continue;
2351
2352 int status;
2353 if (!int.TryParse(statusMatch.Groups[1].Value, out status))
2354 continue;
2355
2356 if (status < 100 || status > 599)
2357 continue;
2358
2359 // Priority: lower number = better candidate
2360 int priority;
2361 if (status == 304)
2362 priority = 0; // jackpot: cache hit
2363 else if (status >= 200 && status < 300)
2364 priority = 10; // success
2365 else if (status >= 300 && status < 400)
2366 priority = 20; // redirect (often intermediate)
2367 else if (status >= 400 && status < 500)
2368 priority = 30; // client error (useful to know)
2369 else if (status >= 500)
2370 priority = 40; // server error
2371 else
2372 priority = 50;
2373
2374 bool shouldUpdate = false;
2375
2376 if (!bestStatus.HasValue)
2377 {
2378 shouldUpdate = true;
2379 }
2380 else if (priority < candidatePriority)
2381 {
2382 shouldUpdate = true;
2383 }
2384 else if (priority == candidatePriority && status == 304)
2385 {
2386 shouldUpdate = true;
2387 }
2388
2389 if (shouldUpdate)
2390 {
2391 bestStatus = status;
2392 candidatePriority = priority;
2393
2394 // Extract matched URL for debug (optional)
2395 var urlMatch = System.Text.RegularExpressions.Regex.Match(message, @"""url"":""([^""]+)""");
2396 if (urlMatch.Success)
2397 {
2398 bestMatchedUrl = urlMatch.Groups[1].Value;
2399 }
2400 }
2401 }
2402
2403 if (bestStatus.HasValue)
2404 {
2405 browserSettings.ServerResponseCode = bestStatus.Value;
2406
2407 // Optional: log what we decided (uncomment for debugging)
2408 /*
2409 GPAL.PublishSimpleEvent(
2410 GPALEventType.DEBUG,
2411 $"Captured final status {bestStatus.Value} (priority {candidatePriority}) for ~{bestMatchedUrl ?? "unknown"}",
2412 browserSettings,
2413 GPALObjectType.Other
2414 );
2415 */
2416 }
2417 else
2419 // Rare: no plausible status found despite navigation
2420 browserSettings.ServerResponseCode = 0;
2422 GPALEventType.DEBUG,
2423 $"No valid HTTP status captured in performance logs for [{url}]",
2424 browserSettings,
2425 GPALObjectType.Other
2426 );
2427 }
2428
2429 } // end Chrome/Edge only block
2430 else
2431 {
2432 try
2433 {
2434 var result = ((OpenQA.Selenium.IJavaScriptExecutor)browserSettings.BrowserDriver)
2435 .ExecuteScript("return performance.getEntriesByType('navigation')[0]?.responseStatus ?? 0;");
2436 browserSettings.ServerResponseCode = result != null && int.TryParse(result.ToString(), out int ffStatus) && ffStatus > 0 ? ffStatus : 0;
2437 }
2438 catch
2439 {
2440 browserSettings.ServerResponseCode = 0;
2441 }
2442 }
2443 }
2444 catch (GPALException)
2445 {
2446 throw;
2447 }
2448 catch (Exception ex)
2449 {
2450 browserSettings.ServerResponseCode = -1;
2451 GPAL.PublishSimpleEvent(
2452 GPALEventType.EXCEPTION,
2453 $"Unexpected error navigting to [{url}]",
2454 browserSettings,
2455 GPALObjectType.Other,
2456 ex
2457 );
2458 }
2459 }
2460
2470
2471 public static KillProcessesResult KillProcessesByName(params string[] namesOfProcessesToKill)
2472 {
2473 KillProcessesResult result = null;
2474
2475 foreach (var name in namesOfProcessesToKill)
2476 result = KillProcess(name);
2478 return result;
2479 }
2491 public static string GetUserAgent(IBrowser browser)
2492 {
2493 string userAgent = null;
2494
2495 try
2496 {
2497 if (true == browser.UseOttoMagic)
2498 userAgent = ((Browser)browser).MagicHelper.GetUserAgent();
2499 else if (true == browser.UsePuppeteer)
2500 userAgent = browser.PuppeteerClient.GetUserAgent().Execute<string>();
2501 else
2502 userAgent = ExecuteJavaScript(browser, "return navigator.userAgent");
2503 }
2504 catch (GPALException)
2505 {
2506 throw;
2507 }
2508 catch (Exception ex)
2509 {
2510 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to get live user agent.", browser, GPALObjectType.Browser, ex);
2511 }
2512
2513 if (true == string.IsNullOrEmpty(userAgent))
2514 userAgent = MagicHelper.GetUserAgentString(browser.BrowserType);
2515
2516 // ottomagic and puppeteer answer over the rest layer, whose untyped Execute hands back the response
2517 // body as it stands - a json string, quotes and all - and a quote is not a legal header value
2518 if (true == userAgent?.StartsWith("\"") && true == userAgent.EndsWith("\""))
2519 userAgent = Newtonsoft.Json.JsonConvert.DeserializeObject<string>(userAgent);
2520
2521 return userAgent;
2522 }
2530 public static bool TestUrlForPDF(Browser browser, string url)
2531 {
2532 if (null == url)
2533 return false;
2534
2535 bool retVal = false;
2536
2537 // does the url contain .pdf?
2538 if (0 <= url.IndexOf(".pdf", 0, StringComparison.OrdinalIgnoreCase))
2539 retVal = true;
2540 else
2541 using (HttpClient client = new HttpClient())
2542 {
2543 client.DefaultRequestHeaders.Add("User-Agent", GetUserAgent(browser));
2544
2545 try
2546 {
2547 // Use HttpMethod.Head to retrieve headers ONLY (minimal overhead)
2548 using (HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Head, url))
2549 {
2550 HttpResponseMessage response = client.SendAsync(request).Result;
2551
2552 if (response.IsSuccessStatusCode)
2553 {
2554 // 1. Check Content-Type for application/pdf
2555 string contentType = response.Content.Headers.ContentType?.MediaType;
2556 if (contentType != null && contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
2557 {
2558 retVal = true;
2559 }
2560 // 2. Fallback: Check Content-Disposition for a .pdf filename
2561 else if (response.Content.Headers.ContentDisposition != null)
2562 {
2563 string fileName = response.Content.Headers.ContentDisposition.FileName;
2564 if (!string.IsNullOrEmpty(fileName) && fileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
2565 {
2566 retVal = true;
2567 }
2568 }
2569 }
2570 else
2571 {
2572 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to verify URL [{url}] via HEAD request. Status code [{response.StatusCode}]");
2573 }
2574 }
2575 }
2576 catch (GPALException)
2577 {
2578 throw;
2579 }
2580 catch (Exception ex)
2581 {
2582 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Exception during HEAD request for [{url}]", null, GPALObjectType.None, ex);
2583 }
2584 }
2585
2586 if (true == retVal)
2587 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"GPAL detected PDF file opened in browser [{url}]");
2588
2589 return retVal;
2590 }
2600 // how many times the file is asked for before giving up on the page in front of it clearing, and how long
2601 // to leave between asks
2602 internal const int PdfFetchAttempts = 3;
2603 internal const int PdfFetchWaitMs = 1500;
2604
2612 internal static bool IsPdf(byte[] content)
2613 {
2614 return 4 < content?.Length
2615 && '%' == content[0] && 'P' == content[1] && 'D' == content[2] && 'F' == content[3];
2616 }
2617 public static bool DownloadPdfFile(string pdfUrl, string savePath, IBrowser browser)
2618 {
2619 // we clicked first, it could've downloaded the file, so look
2620 string filePath = $@"{FileHelper.EnsureDirectoryEndsWithBackslash(Path.GetDirectoryName(savePath))}{System.IO.Path.GetFileName(pdfUrl)}";
2621 if (true == File.Exists(filePath))
2622 {
2623 File.Move(filePath, savePath);
2624 return true;
2625 }
2626 else
2627 {
2628 filePath = $@"{FileHelper.GetDefaultDownloadDirectory(browser)}{Path.GetFileName(pdfUrl)}";
2629 if (true == File.Exists(filePath))
2630 {
2631 File.Move(filePath, savePath);
2632 return true;
2633 }
2634 }
2635
2636 // fetch first, from inside the page, so the file comes down the connection the browser already has:
2637 // its cookies, its tls fingerprint, its clearance. a url the site signed for this session only is
2638 // served to that connection and refused to the bare client below.
2639 // the click may still be on its way to the file, and a site that shows a redirect page first serves
2640 // that page at this very url until it is. so what came back has to be a pdf before it is written, and
2641 // when it is not we wait, ask where the browser is now, and try that
2642 for (int attempt = 0; attempt < PdfFetchAttempts; attempt++)
2643 {
2644 byte[] fetched = FetchBytes((Browser)browser, pdfUrl, null, out string _);
2645
2646 if (true == IsPdf(fetched))
2647 {
2648 File.WriteAllBytes(savePath, fetched);
2649 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"PDF file [{pdfUrl}] fetched from the page to [{savePath}]");
2650 return true;
2651 }
2652
2653 if (attempt < PdfFetchAttempts - 1)
2654 {
2655 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{pdfUrl}] answered with [{fetched?.Length ?? 0}] bytes that are not a pdf, waiting for the file itself", browser, GPALObjectType.Browser);
2656 Thread.Sleep(PdfFetchWaitMs);
2657 pdfUrl = GetCurrentUrl(((Browser)browser).BrowserSettings) ?? pdfUrl;
2658 }
2659 else
2660 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{pdfUrl}] never answered with a pdf, the page in front of the file did. Nothing written to [{savePath}]", browser, GPALObjectType.Browser);
2661 }
2662
2663 // fetch is subject to the page's origin, so a pdf served from somewhere else can be refused where a
2664 // plain request is not. that is what this is here for
2665 using (HttpClient client = new HttpClient())
2666 {
2667 client.DefaultRequestHeaders.Add("User-Agent", GetUserAgent(browser));
2668
2669 try
2670 {
2671 // Download the PDF file content synchronously
2672 byte[] pdfContent = client.GetByteArrayAsync(pdfUrl).Result;
2673
2674 if (false == IsPdf(pdfContent))
2675 {
2676 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{pdfUrl}] answered with [{pdfContent?.Length ?? 0}] bytes that are not a pdf. Nothing written to [{savePath}]", browser, GPALObjectType.Browser);
2677 return false;
2678 }
2679
2680 // Save the PDF file to disk
2681 File.WriteAllBytes(savePath, pdfContent);
2682 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"PDF file [{pdfUrl}] downloaded successfully to [{savePath}]");
2683 return true;
2684 }
2685 catch (GPALException)
2686 {
2687 throw;
2688 }
2689 catch (Exception ex)
2690 {
2691 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"PDF file NOT downloaded from [{pdfUrl}]", ex);
2692 }
2693 }
2694 return false;
2695 }
2696
2705 internal static IWebDriver GetWebDriver(BrowserSettings browserSettings, dynamic options)
2706 {
2707 IWebDriver tmpDriver = null;
2708
2709 try
2710 {
2711 if (true == browserSettings.HiddenDesktop)
2712 {
2713 // the driver is started by hand so it can be named a desktop, and Selenium is handed the port
2714 // rather than a service. StartHiddenDriver sets ServiceDriverPid from the launch
2715 int hiddenPort = StartHiddenDriver(browserSettings);
2716
2717 tmpDriver = new OpenQA.Selenium.Remote.RemoteWebDriver(new Uri($"http://127.0.0.1:{hiddenPort}"), options);
2718 }
2719 else if (BrowserType.Chrome == browserSettings.BrowserType)
2720 {
2721 // Create the service explicitly so we can get the PID
2722 ChromeDriverService chromeService = ChromeDriverService.CreateDefaultService(browserSettings.DriverLocation);
2723 chromeService.HideCommandPromptWindow = true; // optional but recommended
2724
2725 ChromeOptions chromeOptions = (ChromeOptions)options;
2726
2727 tmpDriver = new ChromeDriver(chromeService, chromeOptions);
2728
2729 // *** Capture the chromedriver.exe PID ***
2730 browserSettings.ServiceDriverPid = chromeService.ProcessId;
2731 }
2732 else // Edge
2733 {
2734 EdgeDriverService edgeService = EdgeDriverService.CreateDefaultService(browserSettings.DriverLocation);
2735 edgeService.HideCommandPromptWindow = true; // optional
2736
2737 EdgeOptions edgeOptions = (EdgeOptions)options;
2738
2739 tmpDriver = new EdgeDriver(edgeService, edgeOptions);
2740
2741 // *** Capture the msedgedriver.exe PID (reuse the same property) ***
2742 browserSettings.ServiceDriverPid = edgeService.ProcessId;
2743 }
2744 } catch (Exception ex)
2745 {
2746 string url = string.Empty;
2747 switch (browserSettings.BrowserType)
2748 {
2749 case Enums.BrowserType.Chrome:
2750 url = GPAL.GPALSettings.ChromeDriverUpdateURL;
2751 break;
2752 case Enums.BrowserType.Edge:
2753 url = GPAL.GPALSettings.EdgeDriverUpdateURL;
2754 break;
2755 case Enums.BrowserType.FireFox:
2756 url = GPAL.GPALSettings.FirefoxDriverUpdateURL;
2757 break;
2758 }
2759 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Please manually update the driver @ [{url}]..", browserSettings.Browser, GPALObjectType.Browser, ex);
2760 }
2761 return tmpDriver;
2762 }
2763 #region <KillProcess>
2764
2768
2769 private static class DriverInfos
2770 {
2771 public static DriverInfo Chrome = new DriverInfo(
2772 executableFileName: GPAL.GPALSettings.ChromeDriverFilename,
2773 processName: "chromedriver",
2774 browserName: "chrome"
2775 );
2777 public static DriverInfo FireFox = new DriverInfo(
2778 executableFileName: GPAL.GPALSettings.FirefoxDriverFilename,
2779 processName: "geckodriver",
2780 browserName: "firefox"
2781 );
2782
2783 public static DriverInfo Edge = new DriverInfo(
2784 executableFileName: GPAL.GPALSettings.EdgeDriverFilename,
2785 processName: "MSEdgeDriver",
2786 browserName: "edge"
2787 );
2788
2789 }
2790
2795 private class DriverInfo
2796 {
2797
2803 public DriverInfo(string executableFileName, string processName, string browserName)
2804 {
2805 ExecutableFileName = executableFileName;
2806 ProcessName = processName;
2807 BrowserName = browserName;
2808 }
2809 public string ExecutableFileName { get; private set; }
2810 public string ProcessName { get; private set; }
2811 public string BrowserName { get; private set; }
2812 }
2816 public class KillProcessesResult
2817 {
2818 public string Name { get; set; }
2819 public int Found { get; set; } = 0;
2820 public int Killed { get; set; } = 0;
2821 public bool Success { get; set; } = false;
2822 }
2823
2830 {
2831 List<KillProcessesResult> result = new List<KillProcessesResult>();
2832 KillProcessesResult[] kpa = result.ToArray();
2833 alreadyUpdating = false;
2834
2835 try
2836 {
2837 result.Add(KillProcess(DriverInfos.FireFox.ProcessName));
2838 result.Add(KillProcess(DriverInfos.Chrome.ProcessName));
2839 result.Add(KillProcess(DriverInfos.Edge.ProcessName));
2840
2841 // zero out all browsers
2842 //GPAL.Browsers.Clear();
2843 } catch (Exception ex)
2844 {
2845 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to kill processes", ex);
2846 }
2847
2848 return kpa;
2849 }
2855 private const int MaxParentHops = 8;
2856
2857 internal const int GracefulExitMs = 10000; // long enough for a browser to write its profile out, short enough not to hold up a run
2858 internal const int ProfileReleaseWaitMs = 15000; // a browser asked to close is still letting its profile go
2859 internal const int ProfileReleaseCheckMs = 250;
2860
2871 private static void CloseBrowserGracefully(Browser aBrowser, bool endItAll)
2872 {
2873 try
2874 {
2875 if (true == aBrowser.UsePuppeteer)
2876 {
2877 // Browser.close is CDP's own orderly shutdown
2878 if (false == aBrowser.BrowserSettings.Process?.HasExited)
2879 {
2880 aBrowser.BrowserSettings.PuppeteerCommunicator.SendCommand(DevToolsMethods.BrowserClose, new Dictionary<string, object>(), null).GetAwaiter().GetResult();
2881 aBrowser.PuppeteerCommunicator._readerReadyTcs.SetCanceled();
2882
2883 if (false == aBrowser.PuppeteerCommunicator._usePipes)
2884 aBrowser.PuppeteerCommunicator.CloseOutputWebSocketAsync().GetAwaiter();
2885 }
2886 }
2887 else if (true == aBrowser.UseSelenium && null != aBrowser.BrowserDriver)
2888 {
2889 // Quit ends the session and closes every window; Close leaves the others alone when this is
2890 // one of several GPAL browsers sharing the driver.
2891 // EdgeDriver has a habit of sitting on Quit for its whole command timeout, which is what had
2892 // this skipped altogether, so it gets its own budget here and the caller kills what is left
2893 Task closing = Task.Run(() =>
2894 {
2895 if (true == endItAll)
2896 aBrowser.BrowserDriver?.Quit();
2897 else
2898 aBrowser.BrowserDriver?.Close(); // NOTE: CAVEAT: closes the currently focused window
2899 });
2900
2901 closing.Wait(GracefulExitMs);
2902 }
2903 else
2904 {
2905 // ottomagic. an extension cannot close the browser hosting it, and a headless browser has no
2906 // window for a message to reach. the browser's own windows it can close, and they are there
2907 // in headless the same as anywhere, so those go first and the window message is left as the
2908 // fallback for whatever survives it
2909 // an extension cannot quit the browser hosting it, but it can close every window it has,
2910 // and closing the last one is an ordinary quit: the profile is written and the exit is
2911 // recorded clean rather than as a crash. this reaches headless, where a window message has
2912 // nothing to arrive at.
2913 // the extension enumerates them, so a window a page opened counts the same as one GPAL did,
2914 // and nothing here has to know how many there are
2915 // asking a browser that has already gone costs a full round of retries against a port
2916 // nothing is listening on, and that shows up as a slow start on the next run
2917 if (true == aBrowser.UseOttoMagic && true == aBrowser.IsAlive)
2918 aBrowser.MagicHelper.CloseBrowser();
2919
2920 if (false == aBrowser.BrowserSettings.Process?.HasExited)
2921 aBrowser.BrowserSettings.Process?.CloseMainWindow();
2922 }
2923 }
2924 catch
2925 {
2926 }
2927 }
2928
2929 public static KillProcessesResult[] KillAllRunningProcesses(bool killWebDrivers, IBrowser browser)
2930 {
2931 List<KillProcessesResult> result = new List<KillProcessesResult>();
2932 KillProcessesResult[] kpa = result.ToArray();
2933
2934 try
2935 {
2936 int browserCount = GPAL.Browsers.Count;
2937
2938 if (0 == browserCount)
2939 {
2940 // forms or converter or application, this is expected...
2941 //GPAL.PublishSimpleEvent(GPALEventType.INFO, "No browsers to kill? How'd we get here?", browser, GPALObjectType.Browser);
2942 return kpa;
2943 }
2944
2945 // suppose we have two windows open, we will have two GPAL browsers in the list, both with the same driver
2946 // what happens if one workflow issues a .close() which ends the workflow instead of a .CloseWindow()
2947 // we can't perform any operation on the browserdriver as that will affect both windows
2948 // we could see the browser count > 1 and if killWebDrivers == false, assume it's not the end
2949 // then we can look at the browsersettings, get the CUrrentUrl and then CloseWindow(browserSettings.CurrentUrl)?
2950 foreach (Browser aBrowser in GPAL.Browsers)
2951 {
2952 if (aBrowser == browser || null == browser) // null browser means unhandled exception, kill it all; browser not null = close(), killwebdrivers ends it all
2953 try
2954 {
2955 bool endItAll = (aBrowser == browser && true == killWebDrivers) || null == browser;
2956
2957 // ask before killing. a browser that is killed records its own exit as a crash, and
2958 // chromium reopens the previous session after a crash whatever session.restore_on_startup
2959 // says, so every run GPAL killed taught the next one to come up holding its tabs. a
2960 // killed browser also lingers holding the profile, long enough to write its own
2961 // Preferences back over what GPAL set before launch
2962 CloseBrowserGracefully(aBrowser, endItAll);
2963
2964 if (null != aBrowser.BrowserSettings.Process)
2965 try
2966 {
2967 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{aBrowser.BrowserSettings.BrowserType}] waiting on process [{aBrowser.BrowserSettings.Process.Id}] to exit", aBrowser, GPALObjectType.Browser);
2968
2969 // it was asked and it went, so there is nothing to kill. the kill is the
2970 // fallback it was always meant to be rather than the way this normally ends
2971 if (false == aBrowser.BrowserSettings.Process.WaitForExit(GracefulExitMs))
2972 {
2973 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{aBrowser.BrowserSettings.BrowserType}] did not shut down within [{GracefulExitMs / 1000}] seconds, killing it. Its profile will read as crashed.", aBrowser, GPALObjectType.Browser);
2974 result.Add(KillProcess(aBrowser.BrowserSettings.Process));
2975 }
2976 }
2977 catch
2978 { }
2979
2980 // the webdriver service is ours, not the user's, and holds no profile to leave in a
2981 // bad state, so it is killed outright once the browser it was driving is gone
2982 if (true == aBrowser.UseSelenium && true == endItAll)
2983 {
2984 int pid = aBrowser.BrowserSettings.ServiceDriverPid;
2985
2986 GPAL.PublishSimpleEvent(GPALEventType.INFO, ">>> Disposing browser driver");
2987
2988 try
2989 {
2990 if (0 < pid)
2991 KillProcessTree(pid);
2992
2993 // fallback - kill any stray driver processes by name
2994 var kpaDrivers = KillAllRunningWebDrivers();
2995
2996 foreach (KillProcessesResult killProcessesResult in kpaDrivers)
2997 result.Add(killProcessesResult);
2998 }
2999 catch { /* ignore */ }
3000 finally
3001 {
3002 aBrowser.BrowserSettings.ServiceDriverPid = 0;
3003 }
3004 }
3005
3006 Thread.Sleep(1_000);
3007 }
3008 catch
3009 {
3010
3011 }
3012 finally
3013 {
3014 if (true == aBrowser.BrowserSettings.TempProfileCreated)
3015 ChromeProfileManager.RemoveTempUserProfile(aBrowser.BrowserSettings.ProfileDataDirectory);
3016
3017 // this browser's own profile is handled above. the sweep is for the ones a run that
3018 // never reached here left behind
3019 ChromeProfileManager.SweepAbandonedTempProfiles();
3020 }
3021 }
3022 }
3023 catch (Exception ex)
3024 {
3025 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to kill processes", ex);
3026 }
3027
3028 // obsolete now that we have kill process tree since capturing the driver pid
3029 //try
3030 //{
3031 // if (true == killWebDrivers)
3032 // {
3033 // var kpaDrivers = KillAllRunningWebDrivers();
3034
3035 // foreach (KillProcessesResult killProcessesResult in kpaDrivers)
3036 // result.Add(killProcessesResult);
3037
3038 // foreach (IBrowser aBrowser in GPAL.Browsers)
3039 // switch (aBrowser.BrowserType)
3040 // {
3041 // case BrowserType.Chrome:
3042 // KillProcess(DriverInfos.Chrome.BrowserName);
3043 // break;
3044
3045 // case BrowserType.Edge:
3046 // KillProcess(DriverInfos.Edge.BrowserName);
3047 // break;
3048
3049 // case BrowserType.FireFox:
3050 // KillProcess(DriverInfos.FireFox.BrowserName);
3051 // break;
3052 // }
3053 // }
3054 //}
3055 //catch { }
3056
3057 return result.ToArray();
3058 }
3075 internal static void EndWindowlessSuccessor(BrowserSettings browserSettings)
3076 {
3077 int ourPid = browserSettings.Process?.Id ?? 0;
3078
3079 if (0 < ourPid)
3080 {
3081 // the replacement is started as our browser goes, so there is nothing to find until it has gone
3082 WaitForBrowserToExit(browserSettings, Browser.ProfileRestoreWaitMs);
3083
3084 // caught rather than thrown: WMI is not always answerable, and a browser we could not ask about
3085 // is not a reason to fail a close that has otherwise finished
3086 try
3087 {
3088 using (var search = new System.Management.ManagementObjectSearcher(
3089 $"SELECT ProcessId, CommandLine FROM Win32_Process WHERE ParentProcessId = {ourPid}"))
3090 foreach (System.Management.ManagementObject found in search.Get())
3091 {
3092 string commandLine = found["CommandLine"]?.ToString() ?? string.Empty;
3093
3094 if (true == commandLine.Contains("--no-startup-window"))
3095 {
3096 int successor = Convert.ToInt32(found["ProcessId"]);
3097
3098 GPAL.PublishSimpleEvent(GPALEventType.INFO,
3099 $"Ending the windowless browser [{successor}] left behind by [{ourPid}], which would hold the profile open",
3100 browserSettings, GPALObjectType.Browser);
3101
3102 KillProcessTree(successor);
3103 }
3104 }
3105 }
3106 catch (Exception ex)
3107 {
3108 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Could not look for a windowless browser left behind by [{ourPid}]",
3109 browserSettings, GPALObjectType.Browser, ex);
3110 }
3111 }
3112 }
3113
3114 private static void KillProcessTree(int pid)
3115 {
3116 if (pid <= 0) return;
3117
3118 try
3119 {
3120 var startInfo = new ProcessStartInfo
3121 {
3122 FileName = "taskkill",
3123 Arguments = $"/PID {pid} /T /F", // /T = tree, /F = force
3124 WindowStyle = ProcessWindowStyle.Hidden,
3125 CreateNoWindow = true,
3126 UseShellExecute = false
3127 };
3129 using (Process taskkillProcess = Process.Start(startInfo))
3130 {
3131 taskkillProcess?.WaitForExit(5000); // optional timeout
3132 }
3133 }
3134 catch
3135 {
3136 // Ignore errors during cleanup (access denied, already dead, etc.)
3137 }
3138 }
3143 public static KillProcessesResult KillProcess(Process process)
3144 {
3145 if (null != process)
3146 {
3148
3149 string Name = "";
3150 try
3151 {
3152 Name = process.ProcessName;
3153 result.Name = Name;
3154 }
3155 catch (Exception)
3156 {
3157 // process has already exited, nothing to do
3158 result.Success = true;
3159 return result;
3160 }
3161
3162 process.Kill();
3163 process.WaitForExit();
3164
3165 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Killed process [{result.Name}]");
3166 return result;
3167 }
3168 return null;
3169 }
3170
3181 public static KillProcessesResult KillProcess(string processName)
3182 {
3183 KillProcessesResult result = new KillProcessesResult { Name = processName };
3184
3185 var processes = Process.GetProcessesByName(processName);
3186
3187 result.Found = processes.Count();
3188
3189 if (result.Found > 0)
3190 {
3191 foreach (var process in processes)
3192 {
3193 do
3194 {
3195 try
3196 {
3197 process.Kill();
3198 process.WaitForExit(); // Wait for the process to finish
3199 }
3200 catch
3201 {
3202
3203 }
3204 } while (false == process.HasExited);
3205
3206 result.Killed++;
3207 result.Success = true;
3208 result.Found++;
3209
3210 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Killed process [{processName}]");
3211 }
3212 }
3213 else
3214 {
3215 result.Success = true;
3216 }
3217
3218 return result;
3220 #endregion <KillProcess>
3221
3222 private static Stopwatch watch = new System.Diagnostics.Stopwatch();
3223 private static int lastX = 0, lastY = 0;
3224
3225 // https://stackoverflow.com/questions/8438483/bring-browser-from-back-to-front-selenium-web-driver-java
3226 // NOTE: we prolly should restore the window, too, if it's minimized? or do a hardware top for all?
3227 // NOTE: maybe we only need to top if we have image selectors? otherwise, we don't need to be on top...
3248 private const string PortsLogName = "ports-in-use.log";
3249
3251 private const string NativeHostName = "gpal.rest.api.nativeapp";
3252
3257 private static readonly string[] NativeHostKeys =
3258 {
3259 @"Software\\Google\\Chrome\\NativeMessagingHosts\\" + NativeHostName,
3260 @"Software\\Chromium\\NativeMessagingHosts\\" + NativeHostName,
3261 @"Software\\Microsoft\\Edge\\NativeMessagingHosts\\" + NativeHostName,
3262 @"Software\\WOW6432Node\\Google\\Chrome\\NativeMessagingHosts\\" + NativeHostName,
3263 @"Software\\WOW6432Node\\Chromium\\NativeMessagingHosts\\" + NativeHostName,
3264 @"Software\\WOW6432Node\\Microsoft\\Edge\\NativeMessagingHosts\\" + NativeHostName
3265 };
3266
3272 public static string RestApiLocation()
3273 {
3274 string retVal = null;
3275
3276 foreach (RegistryKey root in new[] { Registry.CurrentUser, Registry.LocalMachine })
3277 {
3278 foreach (string keyPath in NativeHostKeys)
3279 try
3280 {
3281 using (RegistryKey key = root.OpenSubKey(keyPath))
3282 {
3283 string manifestPath = key?.GetValue(null) as string;
3284
3285 if (true == string.IsNullOrWhiteSpace(manifestPath) || false == File.Exists(manifestPath))
3286 continue;
3287
3288 // the manifest is the browser's own record of where the host lives, so it is the
3289 // one place that cannot disagree with what the browser will actually launch
3290 dynamic manifest = Newtonsoft.Json.JsonConvert.DeserializeObject(File.ReadAllText(manifestPath));
3291 string exePath = manifest?.path;
3292
3293 if (false == string.IsNullOrWhiteSpace(exePath) && true == File.Exists(exePath))
3294 {
3295 retVal = exePath;
3296 break;
3297 }
3298 }
3299 }
3300 catch (Exception ex)
3301 {
3302 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Could not read the native host registration at [{keyPath}]", null, GPALObjectType.None, ex);
3303 }
3304
3305 if (false == string.IsNullOrWhiteSpace(retVal))
3306 break;
3307 }
3308
3309 return retVal;
3310 }
3311
3327 public static int LiveRestApiHosts(out List<(int processId, int port)> hosts)
3328 {
3329 hosts = new List<(int processId, int port)>();
3330
3331 string exePath = RestApiLocation();
3332 string folder = true == string.IsNullOrWhiteSpace(exePath) ? null : Path.GetDirectoryName(exePath);
3333 string path = true == string.IsNullOrWhiteSpace(folder) ? null : Path.Combine(folder, PortsLogName);
3334
3335 // no file is the ordinary answer when no ottomagic browser is up, because the last host out deletes it
3336 if (true == string.IsNullOrWhiteSpace(path) || false == File.Exists(path))
3337 return hosts.Count;
3338
3339 try
3340 {
3341 // opened sharing rather than locking, so reading never holds a host up mid write
3342 using (FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
3343 using (StreamReader reader = new StreamReader(stream))
3344 foreach (string line in reader.ReadToEnd().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
3345 {
3346 string[] halves = line.Split(':');
3347
3348 if (2 == halves.Length
3349 && true == int.TryParse(halves[0], out int pid)
3350 && true == int.TryParse(halves[1], out int port)
3351 && false == hosts.Any(each => each.processId == pid))
3352 hosts.Add((pid, port));
3353 }
3354 }
3355 catch (Exception ex)
3356 {
3357 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Could not read the host list at [{path}]", null, GPALObjectType.None, ex);
3358 }
3359
3360 return hosts.Count;
3361 }
3362
3363 internal static bool RestApiAnswering(string baseUrl)
3364 {
3365 bool retVal = false;
3366
3367 if (false == string.IsNullOrWhiteSpace(baseUrl))
3368 try
3369 {
3370 System.Net.HttpWebRequest ask = (System.Net.HttpWebRequest)System.Net.WebRequest.Create($"{baseUrl}status");
3371 ask.Timeout = 1000;
3372 ask.ReadWriteTimeout = 1000;
3373
3374 using (System.Net.HttpWebResponse answer = (System.Net.HttpWebResponse)ask.GetResponse())
3375 retVal = System.Net.HttpStatusCode.OK == answer.StatusCode;
3376 }
3377 catch (System.Net.WebException)
3378 {
3379 // refused, timed out, or answered something other than a running server
3380 }
3381
3382 return retVal;
3383 }
3384
3385 public static void TopBrowser(Browser browser, bool moveDontClick, bool forceClick = false) // AFTER HARDWARE CLICK, we want to MOVE NOT CLICK mouse off browser into a safe area, so reusing this method
3386 {
3387 int x, y;
3388
3389 // only top if we are interacting with the page using hardware or image mathcing on the current selector
3390 // NOTE: null-safe throughout - CurrentUOW/CurrentSelector are null when we are not on a real page, and
3391 // a (bool) cast on a null bool? (from the ?. chain) would throw.
3392 bool needsTopping = (browser.CurrentUOW?.CurrentSelector?.SelectorSettings?.SelectorPaths
3393 ?.Any(path => path.SelectorPathType == SelectorPathType.Image) ?? false)
3394 || InteractionType.Hardware == browser.CurrentUOW?.CurrentSelector?.InteractionType
3395 || true == browser.BrowserSettings.UseHardware;
3396
3397 // don't do anything, we are always in control and only need it for image matching potentially
3398 if (true == browser.BrowserSettings.UseHeadless || false == needsTopping)
3399 return;
3400
3401 // NOTE: moving the mouse off the browser after a click exists only to keep stray :hover effects
3402 // (tooltips, dropdowns, sticky menus) from interfering with subsequent IMAGE selector matching -
3403 // CSS/DOM selectors don't care about hover state. If this unit of work has no image selectors,
3404 // skip the move entirely: teleporting the cursor off-viewport after every single click is itself
3405 // a mechanical, easily-fingerprinted pattern, so we only pay that cost when it's actually needed.
3406 if (true == moveDontClick || true == forceClick) // selenium control - click the topmost left corner of the browser to top it, only thing that works 100%
3407 {
3408 if (true == watch.IsRunning)
3409 {
3410 if (1000 > watch.ElapsedMilliseconds) // don't do anything if we were called less than 1 second ago, avoid double clicking the browser (max window)
3411 return;
3412 watch.Stop();
3413 }
3414 watch = System.Diagnostics.Stopwatch.StartNew();
3415
3416 HardwareHelper.GetCursorPos(out Point currCursorPos);
3417
3418 if (true == browser.BrowserSettings.UseHardware || true == GPAL.UseHardware || Enums.BrowserType.FireFox == browser.BrowserSettings.BrowserType || true == moveDontClick || true == forceClick)
3419 {
3420 try
3421 {
3422 Rectangle rect = ElementHelper.GetWindowRectangle(browser);
3423 // true screen origin/size of the window (window.screenLeft/screenTop + outer size), so
3424 // nothing has to guess at the height of a browser's chrome
3425 Rectangle outerRect = ElementHelper.GetWindowOuterRectangle(browser);
3426
3427 if (true == moveDontClick)
3428 {
3429 // NOTE: park the mouse at a randomly chosen edge/corner of the window (above, below,
3430 // left, or right) instead of always "above the window" - landing in the same relative
3431 // spot after every click is itself a mechanical, easily-fingerprinted pattern.
3432 var rnd = new Random();
3433 switch (rnd.Next(4))
3434 {
3435 case 0: x = rect.Left + rnd.Next(-50, rect.Width + 50); y = outerRect.Top - 20; break; // above the true window top
3436 case 1: x = rect.Left + rnd.Next(-50, rect.Width + 50); y = rect.Bottom + 50; break; // below
3437 case 2: x = rect.Left - 60; y = rect.Top + rnd.Next(0, rect.Height); break; // left
3438 default: x = rect.Right + 60; y = rect.Top + rnd.Next(0, rect.Height); break; // right
3439 }
3440 // keep the parked cursor on the desktop working area - "below" a maximized window is
3441 // the taskbar, and hovering there pops window previews that then sit over the page
3442 // and corrupt the very image matching this parking exists to protect
3443 Rectangle workingArea = Screen.PrimaryScreen.WorkingArea;
3444 x = Math.Min(Math.Max(workingArea.Left, x), workingArea.Right - 1);
3445 y = Math.Min(Math.Max(workingArea.Top, y), workingArea.Bottom - 1);
3446 }
3447 else
3448 {
3449 // click a few pixels into the true top-left corner of the browser window to top it -
3450 // just inside the corner so we land on the window frame/title, off the resize hotzone,
3451 // and never on a tab or page content
3452 x = outerRect.Left + 3;
3453 y = outerRect.Top + 3;
3454 }
3455 lastX = x;
3456 lastY = y;
3457 //x = browser.BrowserDriver.Manage().Window.Position.X + 6;
3458 //y = x = browser.BrowserDriver.Manage().Window.Position.Y + 6;
3459 }
3460 catch (GPALException)
3461 {
3462 throw;
3463 }
3464 catch (Exception ex)
3465 {
3466 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to get browser coords. Retrying.", browser, GPALObjectType.Browser, ex);
3467 x = lastX;
3468 y = lastY;
3469 }
3470
3471 if (true == GPAL.SimulateMouse || true == browser.CurrentUOW.CurrentSelector?.SimulateMouse)
3472 HardwareHelper.MoveMouse(x, y, 10, 10); // if we are moving the mouse out of the way, just move it up from the current spot
3473 else
3474 HardwareHelper.MoveMouse(x, y);
3475
3476 if (false == moveDontClick)
3477 {
3478 HardwareHelper.HardwareClick(x, y, ClickType.LeftClick); // firefox will not take software topping requests, only hardware clicks
3479 Thread.Sleep(10);
3480
3481 if (true == GPAL.SimulateMouse || true == browser.CurrentUOW.CurrentSelector?.SimulateMouse)
3482 HardwareHelper.MoveMouse(currCursorPos.X, currCursorPos.Y, 10, 10);
3483 else
3484 HardwareHelper.MoveMouse(currCursorPos.X, currCursorPos.Y);
3485 }
3486 }
3487 else if (true == browser.UseSelenium)
3488 {
3489 try
3490 {
3491 // Top the browser by re-focusing whatever tab Selenium currently has active - a no-op for
3492 // tab selection but it still brings the window forward. Do NOT switch by our own
3493 // CurrentTabIdx here: if that index is stale it yanks focus to the wrong tab right before
3494 // a click. If even this is problematic, the catch falls back to the WindowHelper route.
3495 browser.BrowserDriver.SwitchTo().Window(browser.BrowserDriver.CurrentWindowHandle);
3496 }
3497 catch (GPALException)
3498 {
3499 throw;
3500 }
3501 catch (Exception ex)
3502 {
3503 string tmpStr = "Faling back to windows topping.";
3504 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to top browser using Selenium. {(false == GPAL.NoFallbackRecoveryActions ? tmpStr : String.Empty)})", browser, GPALObjectType.Browser, ex);
3505 if (false == GPAL.NoFallbackRecoveryActions)
3506 WindowHelper.TopBrowser(browser.BrowserSettings.Process, browser.BrowserSettings.ServiceDriverPid);
3507 }
3508 }
3509 }
3510 else
3511 { // we have an image selector or we use ottomagic or puppeteer so use windows to top the browser
3512 if (false == WindowHelper.TopBrowser(browser.BrowserSettings.Process, browser.BrowserSettings.ServiceDriverPid))
3513 throw new GPALException("Browser has exited");
3514 }
3515 }
3516
3517 // New utility method to find a free port
3543 internal static bool CameFromProcess(int processId, int ancestorId)
3544 {
3545 bool retVal = false;
3546 int walking = processId;
3547
3548 for (int hop = 0; hop < MaxParentHops && 0 != walking && false == retVal; hop++)
3549 {
3550 if (walking == ancestorId)
3551 retVal = true;
3552 else
3553 walking = ParentOf(walking);
3554 }
3555
3556 return retVal;
3557 }
3558
3565 private static int ParentOf(int processId)
3566 {
3567 int retVal = 0;
3568
3569 try
3570 {
3571 using (var searcher = new System.Management.ManagementObjectSearcher(
3572 $"SELECT ParentProcessId FROM Win32_Process WHERE ProcessId = {processId}"))
3573 foreach (System.Management.ManagementObject each in searcher.Get())
3574 using (each)
3575 retVal = Convert.ToInt32(each["ParentProcessId"]);
3576 }
3577 catch (Exception ex)
3578 {
3579 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Could not ask who started process [{processId}]", null, GPALObjectType.Browser, ex);
3580 }
3581
3582 return retVal;
3583 }
3584
3594 internal static void RefuseIfProfileIsNotARoot(BrowserSettings browserSettings)
3595 {
3596 string given = browserSettings.ProfileDataDirectory;
3597
3598 if (true == string.IsNullOrEmpty(given) || BrowserType.FireFox == browserSettings.BrowserType) return;
3599
3600 // A user data root holds profile directories; a profile directory holds a Preferences file
3601 if (false == File.Exists(Path.Combine(given, "Preferences"))) return;
3602
3603 string message = $"[{given}] is a browser profile, not a user data directory. Chromium would build a new empty profile inside it and run that instead. Give the directory that holds it and name the profile, or copy it out to a directory of its own";
3604
3605 GPAL.PublishSimpleEvent(GPALEventType.ERROR, message, null, GPALObjectType.Browser);
3606
3607 throw new GPALException($"{GPAL.MyMethodName()}: {message}");
3608 }
3609
3610 internal static void RefuseIfProfileIsOpen(BrowserSettings browserSettings)
3611 {
3612 RefuseIfProfileIsNotARoot(browserSettings);
3613
3614 string profile = browserSettings.ProfileDataDirectory;
3615
3616 if (false == string.IsNullOrEmpty(profile))
3617 {
3618 string wanted = NormalizedDirectory(profile);
3619 bool isFirefox = BrowserType.FireFox == browserSettings.BrowserType;
3620
3621 string processName = true == isFirefox ? "firefox.exe"
3622 : BrowserType.Edge == browserSettings.BrowserType ? "msedge.exe"
3623 : "chrome.exe";
3624
3625 // chromium names the profile with --user-data-dir=, firefox with -profile and the path after it
3626 string profileArgument = true == isFirefox
3627 ? "-profile\\s+(\"[^\"]*\"|[^\\s]*)"
3628 : "--user-data-dir=(\"[^\"]*\"|[^\\s]*)";
3629 int holder = 0;
3630
3631 // A browser started without --user-data-dir owns the default root. Chromium only: Firefox
3632 // names a profile rather than a root, and its default cannot be derived from the path
3633 string defaultRoot = true == isFirefox
3634 ? null
3635 : NormalizedDirectory(GetBrowserProfileDirectory(browserSettings, true));
3636
3637 holder = ProfileHolder(profile, processName, wanted, profileArgument, defaultRoot);
3638
3639 // a browser closed a moment ago is still letting the profile go, and a run that follows another
3640 // on the same profile arrives during exactly that. Only a profile still held after the wait is
3641 // one somebody else is using
3642 if (0 != holder)
3643 {
3644 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Profile [{profile}] is still held by [{processName}] process [{holder}], waiting up to [{ProfileReleaseWaitMs / 1000}] seconds for it to be let go", null, GPALObjectType.Browser);
3645
3646 Stopwatch waited = Stopwatch.StartNew();
3647
3648 while (0 != holder && ProfileReleaseWaitMs > waited.ElapsedMilliseconds)
3649 {
3650 System.Threading.Thread.Sleep(ProfileReleaseCheckMs);
3651
3652 holder = ProfileHolder(profile, processName, wanted, profileArgument, defaultRoot);
3653 }
3654
3655 if (0 == holder)
3656 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Profile [{profile}] was let go after [{waited.ElapsedMilliseconds}] ms", null, GPALObjectType.Browser);
3657 }
3658
3659 if (0 != holder)
3660 {
3661 string message = $"Profile [{profile}] is already open in [{processName}] process [{holder}]. Close it, or give this run a profile of its own";
3662
3663 GPAL.PublishSimpleEvent(GPALEventType.ERROR, message, null, GPALObjectType.Browser);
3664
3665 throw new GPALException($"{GPAL.MyMethodName()}: {message}");
3666 }
3667 }
3668 }
3669
3680 private static int ProfileHolder(string profile, string processName, string wanted, string profileArgument, string defaultRoot)
3681 {
3682 int retVal = 0;
3683
3684 try
3685 {
3686 using (var search = new System.Management.ManagementObjectSearcher(
3687 $"SELECT ProcessId, CommandLine FROM Win32_Process WHERE Name = '{processName}'"))
3688 {
3689 foreach (System.Management.ManagementObject found in search.Get())
3690 {
3691 string commandLine = found["CommandLine"]?.ToString();
3692
3693 if (true == string.IsNullOrEmpty(commandLine)) continue;
3694
3695 // Only the browser process holds the profile. Renderer, GPU, utility and crashpad
3696 // processes carry --type= and no --user-data-dir, so without this every child of
3697 // every browser reads as holding the default root
3698 if (true == commandLine.Contains("--type=")) continue;
3699
3700 Match match = Regex.Match(commandLine, profileArgument);
3701
3702 // The root it named, or the default root when it named none
3703 string holds = true == match.Success
3704 ? NormalizedDirectory(match.Groups[1].Value.Trim('"'))
3705 : defaultRoot;
3706
3707 if (true == string.IsNullOrEmpty(holds)) continue;
3708
3709 if (true == wanted.Equals(holds, StringComparison.OrdinalIgnoreCase))
3710 {
3711 retVal = Convert.ToInt32(found["ProcessId"]);
3712 break;
3713 }
3714 }
3715 }
3716 }
3717 catch (Exception ex)
3718 {
3719 // asking is a courtesy, not the job. a machine that will not answer wmi gets the old behavior
3720 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Could not check whether [{profile}] is already open", null, GPALObjectType.Browser, ex);
3721 }
3722
3723 return retVal;
3724 }
3725 // trailing slashes and relative segments make two spellings of one directory, and a command line can carry
3726 // either. compared as full paths, they are the same directory or they are not
3727 private static string NormalizedDirectory(string path)
3728 {
3729 string retVal = path;
3730
3731 try
3732 {
3733 retVal = Path.GetFullPath(path).TrimEnd('\\', '/');
3734 }
3735 catch
3736 {
3737 retVal = path.TrimEnd('\\', '/');
3738 }
3739
3740 return retVal;
3741 }
3742
3743 // a browser sets these for itself, and a fetch issued in the page refuses to be told otherwise. copying
3744 // them into a request achieves nothing; the ones that matter are whatever the site's own code added
3745 private static readonly string[] browserOwnedHeaders =
3746 {
3747 "referer", "user-agent", "host", "origin", "cookie", "content-length",
3748 "connection", "accept-encoding", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
3749 "sec-ch-ua-arch", "sec-ch-ua-model", "sec-ch-ua-full-version-list", "sec-ch-device-memory",
3750 "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site"
3751 };
3752
3758 internal static bool BrowserOwnedHeader(string name)
3759 {
3760 return Array.Exists(browserOwnedHeaders, owned => owned.Equals(name, StringComparison.OrdinalIgnoreCase));
3761 }
3762
3763 // ports already handed out, and when. a browser takes seconds to start listening, and in that gap the
3764 // port it was given still looks free to anyone who asks. holding the number for a while is what stops
3765 // the second browser being sent to the first one's port
3766 // Every port handed out by this process. Nothing is removed: a browser may not bind for some seconds
3767 // and stays running afterwards, and reusing a port would put two browsers on one session
3768 private static readonly HashSet<int> portsHandedOut = new HashSet<int>();
3769
3770 // Where the next search starts, so ports climb instead of rescanning from the base
3771 private static int lastPortHandedOut = 0;
3772
3781 internal static int StartHiddenDriver(BrowserSettings browserSettings)
3782 {
3783 int retVal = FindFreePort();
3784 string driverName;
3785 string portArgument;
3786
3787 switch (browserSettings.BrowserType)
3788 {
3789 case BrowserType.FireFox:
3790 driverName = "geckodriver.exe";
3791 portArgument = $"--port {retVal}";
3792 break;
3793 case BrowserType.Edge:
3794 driverName = "msedgedriver.exe";
3795 portArgument = $"--port={retVal}";
3796 break;
3797 default:
3798 driverName = "chromedriver.exe";
3799 portArgument = $"--port={retVal}";
3800 break;
3801 }
3802
3803 string driverPath = Path.Combine(browserSettings.DriverLocation ?? AppDomain.CurrentDomain.BaseDirectory, driverName);
3804
3805 // Selenium Manager fetches a driver that is not there, and it is not on this path. A hidden run needs
3806 // the driver already in place, so it says which one and where rather than failing further along
3807 if (false == File.Exists(driverPath))
3808 throw new GPALException($"No [{driverName}] in [{Path.GetDirectoryName(driverPath)}], which a hidden run needs because Selenium Manager cannot fetch one here");
3809
3810 Native.STARTUPINFO startup = new Native.STARTUPINFO
3811 {
3812 cb = (uint)Marshal.SizeOf<Native.STARTUPINFO>(),
3813 lpDesktop = HiddenDesktop.StartupNameFor(browserSettings.HiddenDesktopName)
3814 };
3815
3816 if (false == Native.CreateProcess(null, $@"""{driverPath}"" {portArgument}", IntPtr.Zero, IntPtr.Zero, false,
3817 Native.CREATE_NO_WINDOW, IntPtr.Zero, null, ref startup, out var started))
3818 throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), $"Could not start [{driverName}] on [{browserSettings.HiddenDesktopName}]");
3819
3820 Native.CloseHandle(started.hProcess);
3821 Native.CloseHandle(started.hThread);
3822
3823 // the pid the teardown kills by, taken straight from the launch rather than out of a DriverService.
3824 // nothing else holds the browser the driver launches, so a lost pid is a browser that outlives the run
3825 browserSettings.ServiceDriverPid = (int)started.dwProcessId;
3826
3827 if (false == WaitForPort(retVal, 30_000))
3828 throw new GPALException($"[{driverName}] never answered on port [{retVal}]");
3829
3830 return retVal;
3831 }
3832
3839 private static bool WaitForPort(int port, int timeoutInMs)
3840 {
3841 bool retVal = false;
3842 DateTime giveUpAt = DateTime.Now.AddMilliseconds(timeoutInMs);
3843
3844 while (false == retVal && DateTime.Now < giveUpAt)
3845 {
3846 try
3847 {
3848 using (TcpClient probe = new TcpClient())
3849 {
3850 probe.Connect("127.0.0.1", port);
3851 retVal = true;
3852 }
3853 }
3854 catch (SocketException)
3855 {
3856 Thread.Sleep(200);
3857 }
3858 }
3859
3860 return retVal;
3861 }
3862
3869 internal static int FindFreePort(int startPort = 0xdead)
3870 {
3871 int retVal = 0;
3872
3873 lock (portsHandedOut)
3874 {
3875 // Continues from the last port handed out, so one not yet bound is not offered again
3876 int from = Math.Max(startPort, lastPortHandedOut + 1);
3877
3878 for (int port = from; port <= 65535 && 0 == retVal; port++)
3879 {
3880 if (true == portsHandedOut.Contains(port))
3881 continue;
3882
3883 // binding is the only question that answers itself. connecting says free for a port nobody
3884 // is listening on yet, which is every port a browser has been told to use and not reached
3885 TcpListener probe = null;
3886
3887 try
3888 {
3889 probe = new TcpListener(System.Net.IPAddress.Loopback, port);
3890 probe.Start();
3891
3892 // claimed while the lock is still held, so the next thread in never sees this one free
3893 portsHandedOut.Add(port);
3894 lastPortHandedOut = port;
3895 retVal = port;
3896 }
3897 catch (SocketException)
3898 {
3899 // somebody holds it, so move along
3900 }
3901 finally
3902 {
3903 probe?.Stop();
3904 }
3905 }
3906 }
3907
3908 if (0 == retVal)
3909 throw new GPALException($"No free ports available between [{startPort}] and [65535].");
3910
3911 return retVal;
3912 }
3913
3932 internal static string DocumentStamp(Browser browser)
3933 {
3934 // OttoMagic uses its own endpoint. ExecuteJavaScript there runs through userScripts.execute, which
3935 // requires the profile to allow user scripts
3936 string stamp = true == browser.UseOttoMagic
3937 ? browser.MagicHelper.GetDocumentStamp()
3938 : ExecuteJavaScriptObj("return performance.timeOrigin + '|' + location.href;", browser)?.ToString();
3939
3940 return stamp;
3941 }
3942
3943 // timeOrigin is first so the split is on the first bar: a url may contain one, a number may not
3944 private static string OriginFromStamp(string stamp)
3945 {
3946 int bar = stamp?.IndexOf('|') ?? -1;
3947 string origin = 0 <= bar ? stamp.Substring(0, bar) : null;
3948
3949 return origin;
3950 }
3951
3952 private static string UrlFromStamp(string stamp)
3953 {
3954 int bar = stamp?.IndexOf('|') ?? -1;
3955 string url = 0 <= bar ? stamp.Substring(bar + 1) : null;
3956
3957 return url;
3958 }
3959
3960 internal static object MarkDocument(Browser browser)
3961 {
3962 object mark = null;
3963
3964 // Selenium marks the document with the html element and detects replacement by staleness, which
3965 // needs no script. The others take a stamp, which also carries the url the poll would otherwise
3966 // fetch separately
3967 if (true == browser.UseSelenium)
3968 mark = browser.BrowserDriver.FindElement(By.TagName("html"));
3969 else if (true == browser.UsePuppeteer || true == browser.UseOttoMagic)
3970 mark = DocumentStamp(browser);
3971
3972 return mark;
3973 }
3983 internal static bool DocumentReplaced(Browser browser, object mark)
3984 {
3985 bool replaced = false;
3986
3987 if (true == browser.UseSelenium && mark is IWebElement html)
3988 try { _ = html.TagName; }
3989 catch (StaleElementReferenceException) { replaced = true; }
3990 catch (NoSuchElementException) { replaced = true; }
3991 else if (mark is string priorStamp)
3992 {
3993 string nowOrigin = OriginFromStamp(DocumentStamp(browser));
3994 string wasOrigin = OriginFromStamp(priorStamp);
3995
3996 replaced = false == string.IsNullOrEmpty(nowOrigin)
3997 && false == string.IsNullOrEmpty(wasOrigin)
3998 && wasOrigin != nowOrigin;
3999 }
4000
4001 return replaced;
4002 }
4016 internal static bool WaitForNavigationOrTime(Browser browser, string priorUrl, object documentMark, int timeOutAfterMs)
4017 {
4018 bool navigated = false;
4019 bool documentGone = false;
4020 bool urlChanged = false;
4021 string lastUrl = priorUrl;
4022 DateTime timeout = DateTime.Now.AddMilliseconds(timeOutAfterMs);
4023 GPALEventType publishToConsole = GPAL.GPALSettings.ConsoleEvents;
4024 GPALEventType publishToDebug = GPAL.GPALSettings.DebugEvents;
4025
4026 GPAL.GPALSettings.ConsoleEvents = GPALEventType.NONE;
4027 GPAL.GPALSettings.DebugEvents = GPALEventType.NONE;
4028
4029 try
4030 {
4031 if (true == browser.IsAlive)
4032 do
4033 {
4034 // One round trip for both signals. A stamp that cannot be read leaves the last known
4035 // url in place and the loop repeats
4036 if (documentMark is string priorStamp)
4037 {
4038 string nowStamp = DocumentStamp(browser);
4039 string nowOrigin = OriginFromStamp(nowStamp);
4040 string wasOrigin = OriginFromStamp(priorStamp);
4041
4042 // A missing origin on either side means undecided. Only two origins that both
4043 // arrived and differ count as a replaced document
4044 documentGone = false == string.IsNullOrEmpty(nowOrigin)
4045 && false == string.IsNullOrEmpty(wasOrigin)
4046 && wasOrigin != nowOrigin;
4047
4048 lastUrl = UrlFromStamp(nowStamp) ?? lastUrl;
4049 }
4050 else
4051 {
4052 documentGone = DocumentReplaced(browser, documentMark);
4053 lastUrl = browser.GetSetCurrentUrl();
4054 }
4055
4056 urlChanged = false == UrlHelper.AreEquivalent(priorUrl, lastUrl);
4057
4058 navigated = true == documentGone || true == urlChanged;
4059
4060 // only sleep if we are going round again, so a grace of 0 costs exactly one look
4061 if (false == navigated && DateTime.Now < timeout)
4062 Thread.Sleep(NavigationPollMs);
4063 }
4064 while (false == navigated && DateTime.Now < timeout);
4065 }
4066 finally
4067 {
4068 GPAL.GPALSettings.ConsoleEvents = publishToConsole;
4069 GPAL.GPALSettings.DebugEvents = publishToDebug;
4070 }
4071
4072 // said once per click, after publishing is back on, so we can see which of the two signals answered and
4073 // whether the engine gave us a document to watch at all
4074 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
4075 $"Navigation watch [{(navigated ? "navigated" : "stayed")}] document [{(documentGone ? "replaced" : "same")}] url [{(urlChanged ? "changed" : "same")}] mark [{(documentMark is long mark ? mark.ToString() : null == documentMark ? "none" : "held")}] from [{priorUrl}] to [{lastUrl}]",
4076 browser, GPALObjectType.Browser);
4077
4078 return navigated;
4079 }
4089 // TODO - pass in a unique GUID to each method (add a parm to them) so the called methods can suppress repeat messages knowing they are called with the same token (so the same session)
4090 // gap between asks while waiting on a page state. short enough that a page which is already there is not
4091 // held up noticeably, long enough that a page taking seconds is asked tens of times rather than hundreds
4092 internal const int WaitForValuePollMs = 250;
4093
4094 internal static void WaitForValueOrTime(Browser browser, string resultsExpected, Func<dynamic> action, int timeOutAfterMs)
4095 {
4096 DateTime timeout = DateTime.Now.AddMilliseconds(timeOutAfterMs);
4097 string taskReturnValue;
4098 // the expected values are written with a space after each comma, so every token but the first
4099 // carried a leading space and could never be found in an answer. "interactive" and "nav-error"
4100 // were unreachable, and a failed navigation polled to the full timeout instead of stopping
4101 string[] tokens = resultsExpected.Split(',').Select(token => token.Trim()).ToArray();
4102
4103 if (true == browser.IsAlive)
4104 do
4105 {
4106 dynamic result = action();
4107
4108 try
4109 {
4110 // Try to treat as Task
4111 var task = (Task<string>)result; // will throw if not a Task
4112 task.GetAwaiter().GetResult(); // block until complete
4113
4114 // If generic Task<T>, get the Result property
4115 var taskType = task.GetType();
4116 if (taskType.IsGenericType)
4117 {
4118 var taskResult = taskType.GetProperty("Result")?.GetValue(task);
4119 taskReturnValue = taskResult?.ToString() ?? resultsExpected;
4120 }
4121 else
4122 {
4123 taskReturnValue = tokens[0]; // non-generic Task
4124 }
4125 }
4126 catch
4127 {
4128 // Not a Task — treat as a string
4129 taskReturnValue = result?.ToString() ?? resultsExpected;
4130 }
4131
4132 foreach (string token in tokens)
4133 if (true == taskReturnValue.ToLower().Contains(token))
4134 return;
4135
4136 // the loop had no pace of its own, so it asked again the instant the last answer landed and
4137 // a slow page took hundreds of round trips to report one state change. a page does not become
4138 // ready any sooner for being asked more often
4139 Thread.Sleep(WaitForValuePollMs);
4140 }
4141 while (DateTime.Now < timeout);
4142 }
4143
4144 #region NetworkHelpers
4153 public static bool WaitForNetworkIdle(Browser browser, string sessionToken)
4154 {
4155 int timeoutMs = browser.BrowserSettings.NetworkIdleTimeoutMs;
4156 int maxConnections = browser.BrowserSettings.NetworkIdleMaxConnections;
4157 int pruneMs = browser.BrowserSettings.NetworkIdlePruneMs;
4158
4159 DateTime timeout = DateTime.Now.AddMilliseconds(timeoutMs);
4160 DateTime lastChange = DateTime.Now;
4161
4162 if (lastSessionToken != sessionToken)
4163 {
4164 lastErrorMessage.Clear();
4165 supressedMessage = false;
4166 lastSessionToken = sessionToken;
4167 }
4168
4169 int lastCount = -1;
4170
4171 string currentErrorMessage = $"Waiting up to [{timeoutMs}] ms for network to idle to [{maxConnections}] connections for [{pruneMs}] ms";
4172
4173 if (false == lastErrorMessage.Contains(currentErrorMessage))
4174 {
4175 lastErrorMessage.Add(currentErrorMessage);
4176 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage, browser, GPALObjectType.Browser);
4177 supressedMessage = false;
4178 }
4179 else if (false == supressedMessage)
4180 {
4181 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
4182 supressedMessage = true;
4183 }
4184
4185 while (DateTime.Now < timeout)
4186 {
4187 // Get current inflight requests
4188 int inflight = Convert.ToInt32(BrowserHelper.ExecuteJavaScriptObj(
4189 "return window.__gpalNetworkMonitor ? window.__gpalNetworkMonitor.inflight : 0;",
4190 browser
4191 ));
4192
4193 if (inflight != lastCount)
4194 {
4195 lastCount = inflight;
4196 lastChange = DateTime.Now;
4197 }
4198
4199 // If inflight is 0 for pruneMs, we consider network idle
4200 if (inflight == 0 && (DateTime.Now - lastChange).TotalMilliseconds >= pruneMs)
4201 {
4202 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Network IS Idle. Status [true]");
4203 return true;
4204 }
4205
4206 Thread.Sleep(100); // Poll every 100ms
4207 }
4208
4209 currentErrorMessage = $"Network IS NOT Idle. Status [false]";
4210
4211 if (false == lastErrorMessage.Contains(currentErrorMessage))
4212 {
4213 lastErrorMessage.Add(currentErrorMessage);
4214 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage, browser, GPALObjectType.Browser);
4215 supressedMessage = false;
4216 }
4217 else if (false == supressedMessage)
4218 {
4219 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
4220 supressedMessage = true;
4221 }
4222 return false; // Timeout
4223 }
4224
4225 // requires selenium.webdriver update, we are using the framework version :(
4226 //public static string WaitForNetworkIdle(Browser browser)
4227 //{
4228 // var task = WaitForNetworkIdleAsyncInternal(browser);
4229 // return task.GetAwaiter().GetResult();
4230 //}
4231 //public static async Task<string> WaitForNetworkIdleAsyncInternal(Browser browser)
4232 //{
4233 // int timeoutMs = browser.BrowserSettings.NetworkIdleTimeoutMs;
4234 // int maxConnections = browser.BrowserSettings.NetworkIdleMaxConnections;
4235 // int pruneMs = browser.BrowserSettings.NetworkIdlePruneMs;
4236 // IWebDriver driver = browser.BrowserDriver;
4237
4238
4239 // if (!(driver is IDevTools devToolsDriver))
4240 // {
4241 // GPAL.PublishSimpleEvent(GPALEventType.ERROR, "This driver does not support check network idle.", browser, GPALObjectType.Browser);
4242 // return "true";
4243 // }
4244
4245 // GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Waiting up to [{timeoutMs}] ms for network to idle to [{maxConnections}] connections for [{pruneMs}] ms", browser, GPALObjectType.Browser);
4246
4247 // var network = driver.Manage().Network;
4248 // var requests = new ConcurrentBag<string>();
4249 // bool isIdle = false;
4250
4251 // var activeRequests = new ConcurrentDictionary<string, byte>();
4252
4253 // // Handlers
4254 // EventHandler<NetworkRequestSentEventArgs> requestSentHandler = (sender, e) =>
4255 // {
4256 // activeRequests[e.RequestId] = 0;
4257 // };
4258
4259 // EventHandler<NetworkResponseReceivedEventArgs> responseReceivedHandler = (sender, e) =>
4260 // {
4261 // activeRequests.TryRemove(e.RequestId, out _);
4262 // };
4263
4264 // // Attach
4265 // network.NetworkRequestSent += requestSentHandler;
4266 // network.NetworkResponseReceived += responseReceivedHandler;
4267
4268 // try
4269 // {
4270 // await network.StartMonitoring();
4271
4272 // var overallTimeout = DateTime.UtcNow.AddMilliseconds(timeoutMs);
4273 // DateTime? idleSince = null;
4274
4275 // while (DateTime.UtcNow < overallTimeout)
4276 // {
4277 // int openConnections = activeRequests.Count;
4278
4279 // if (openConnections <= maxConnections)
4280 // {
4281 // idleSince ??= DateTime.UtcNow; // mark when idle started
4282 // if ((DateTime.UtcNow - idleSince.Value).TotalMilliseconds >= pruneMs)
4283 // {
4284 // isIdle = true;
4285 // break;
4286 // }
4287 // }
4288 // else
4289 // {
4290 // idleSince = null; // reset idle timer if activity resumed
4291 // }
4292
4293 // await Task.Delay(250); // small polling interval
4294 // }
4295
4296 // if (!isIdle)
4297 // GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Network idle timeout reached.", browser, GPALObjectType.Browser);
4298 // }
4299 // finally
4300 // {
4301 // network.NetworkRequestSent -= requestSentHandler;
4302 // network.NetworkResponseReceived -= responseReceivedHandler;
4303 // await network.StopMonitoring();
4304 // }
4305
4306 // return isIdle.ToString();
4307 //}
4317 public static bool WaitForNetworkIdle(
4318 Browser browser,
4319 int timeoutMs = 500,
4320 int maxConnections = 0,
4321 int pruneMs = 3000,
4322 string sessionToken = null)
4323 {
4324 Exception innerEx = null;
4325 try
4326 {
4327 if (lastSessionToken != sessionToken)
4328 {
4329 lastErrorMessage.Clear();
4330 supressedMessage = false;
4331 lastSessionToken = sessionToken;
4332 }
4333
4334 string currentErrorMessage = $"Waiting up to [{timeoutMs}] ms for network to idle to [{maxConnections}] connections for [{pruneMs}] ms";
4335
4336 if (false == lastErrorMessage.Contains(currentErrorMessage))
4337 {
4338 lastErrorMessage.Add(currentErrorMessage);
4339 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage);
4340 supressedMessage = false;
4341 }
4342 else if (false == supressedMessage)
4343 {
4344 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
4345 supressedMessage = true;
4346 }
4347
4348 DateTime startTime = DateTime.Now;
4349 while ((DateTime.Now - startTime).TotalMilliseconds < timeoutMs)
4350 {
4351 string response = browser.OttoMagicClient.WithEndpoint(ApiEndpoint.CheckNetworkIdle).WithMaxConnections(maxConnections).WithPruneMs(pruneMs).WithTimeoutMs(timeoutMs).Execute();
4352
4353 if (false == string.IsNullOrEmpty(response))
4354 {
4355 bool retVal = response.Contains("idle");
4356
4357 currentErrorMessage = $"Network IS{(retVal ? "" : " NOT")} Idle. Status [{retVal}]";
4359 if (false == lastErrorMessage.Contains(currentErrorMessage))
4360 {
4361 lastErrorMessage.Add(currentErrorMessage);
4362 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage);
4363 supressedMessage = false;
4364 }
4365 else if (false == supressedMessage)
4366 {
4367 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
4368 supressedMessage = true;
4369 }
4370
4371 return retVal;
4372 }
4373
4374 Thread.Sleep(100);
4375 }
4376 }
4377 catch (GPALException)
4378 {
4379 throw;
4380 }
4381 catch (Exception ex)
4382 {
4383 innerEx = ex;
4384 }
4385 // Log timeout
4386 GPAL.PublishSimpleEvent(null != innerEx ? GPALEventType.EXCEPTION : GPALEventType.WARNING,
4387 $"Page did not reach network idle state within [{timeoutMs}] milliseconds",
4388 null, GPALObjectType.None, innerEx);
4389 return false;
4390 }
4395 public static IPEndPoint[] GetAllTcpListeners()
4396 {
4397 IPGlobalProperties ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties();
4398 return ipGlobalProperties.GetActiveTcpListeners();
4399 }
4411 public static bool IsPortListening(int port)
4412 {
4413 IPEndPoint[] tcpListeners = GetAllTcpListeners();
4414 return tcpListeners.Any(ep => ep.Port == port);
4415 }
4416 #endregion NetworkHelpers
4417 }
4418}
4419
Result of attempting to kill one or more processes by name.
static string ExecuteJavaScript(IBrowser browser, string executeMe, params object[] args)
Runs a script and hands back what it produced. Selenium's executor is a function body,...
static void FillInWithTokens(Browser browser, IGPALGrid< string > tokens, WriteMode writeMode)
Finds the elements matching the unit of work's selectors, arranges them into a grid of rows and colum...
static void SeleniumRemoveScriptToEvaluateOnNewDocument(BrowserSettings browserSettings, string identifier)
Removes a script previously registered via SeleniumAddScriptToEvaluateOnNewDocument,...
static string FindTab(BrowserSettings browserSettings, string URL)
Searches all open browser window handles for one whose URL matches (or is matched by) URL ,...
static KillProcessesResult KillProcessesByName(params string[] namesOfProcessesToKill)
Kills all running processes whose name matches any of namesOfProcessesToKill .
static string GetCurrentUrl(BrowserSettings browserSettings)
Returns the current page URL, queried via OttoMagic, Puppeteer, or the Selenium WebDriver depending o...
static string RestApiLocation()
Where GPALRestAPI is installed, worked out the way the browser works it out: a registry key names the...
static string FindFirefoxBinaryLocation()
Looks up the installed Firefox executable path from the registry key configured in GPAL....
static string JsCheckReadyState(dynamic browser, string sessionToken)
Use javascript to check the page document.ready state, only called from one place that does the timin...
static void AllowDownloads(BrowserSettings browserSettings, string directory)
Accept downloads into directory without asking where to put them, whatever the profile says....
static void SeleniumGoToUrl(GPALUrl url, BrowserSettings browserSettings)
Navigates the Selenium WebDriver to url and inspects the browser's performance logs (Network....
static bool TestUrlForPDF(Browser browser, string url)
checking the extension of the url, but also calling the URL to see if it returns a pdf response heade...
static string FindProfileDirectory(string userDataDirectory, string profileName)
the profile name might not be what we expect, which is a directory name. this will iterate thru all d...
static KillProcessesResult KillProcess(Process process)
Kill the process (browser launched by MagicHelper or puppeteer.
static bool IsPortListening(int port)
Checks whether anything is currently listening on the given TCP port.
static string GetUserAgent(IBrowser browser)
Test the url to determine whether it is a PDF file by.
static string GetBrowserProfileDirectory(BrowserSettings browserSettings, bool getDefaultDirectory=false)
Get the browserSettings.ProfileUserName profile directory under the default user directory.
static bool WaitForNetworkIdle(Browser browser, string sessionToken)
Polls a custom javascript network monitor (window.__gpalNetworkMonitor) until there are 0 in-flight r...
static IPEndPoint[] GetAllTcpListeners()
Returns all active TCP listener endpoints on this machine.
static void SetHeadlessDownload(BrowserSettings browserSettings, string destination)
Instructs the underlying Chromium driver (via CDP Page.setDownloadBehavior) to send the next download...
static KillProcessesResult[] KillAllRunningWebDrivers()
Kills all known webdriver processes (geckodriver, chromedriver, MSEdgeDriver) and resets the "already...
static int LiveRestApiHosts(out List<(int processId, int port)> hosts)
Every GPALRestAPI running on this machine right now, as the process that hosts it and the port it lis...
static IWebDriver GetBrowserDriver(BrowserSettings browserSettings)
Create (or return the existing) Selenium WebDriver for the given BrowserSettings. Configures Chrome,...
static bool GotoNextPage(Browser browser)
Navigates to the next page of results, either by clicking/following the configured "next page" select...
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
IAllowBrowserActionOrAnySelector PageEnd
Scroll the current tab window to the bottom of the page. Scrolling ends the unit of work,...
Definition Browser.cs:2556
IAllowBrowserActionOrAnySelector GoTo(GPALUrl URL)
Navigate to the URL specified. If no browser is open, one will be launched. If using an already ope...
Definition Browser.cs:2815
IRESTClient OttoMagicClient
The client for the OttoMagic REST API this browser is running. Only the OttoMagic engines start GPALR...
Definition Browser.cs:7557
bool UseOttoMagic
True if this Browser is using the OttoMagic automation engine.
Definition Browser.cs:7387
bool UsePuppeteer
True if this Browser is using the Puppeteer automation engine.
Definition Browser.cs:7380
MagicHelper MagicHelper
The MagicHelper used to issue commands when this Browser is using the OttoMagic engine.
Definition Browser.cs:7547
IPuppeteerClient PuppeteerClient
The Puppeteer client used to issue commands when this Browser is using the Puppeteer engine.
Definition Browser.cs:7539
IAllowAllBrowserAndAllSelector Maximize
Maximize the browser to full-screen.
Definition Browser.cs:2687
IAllowAfterAnySelectorExceptWithAll WithAllThatMatch(int rowCount=int.MaxValue)
Indicates the Selectors refer to/match repeating, multiple elements on the page. Use after all your ...
Definition Browser.cs:2370
IMagicHelper MagicHelper
This browser's own MagicHelper, talking to this browser's OttoMagic port. Assigned when the browser i...
string GetReadyStatus()
Gets the ready status NOTE: if no page is loaded, this will return null cause there is no content scr...
string GetUserAgent()
Gets the user agent string userAgent = browser.MagicHelper.GetUserAgent();.
static string GetUserAgentString(BrowserType browserType)
Get chrome or edge useragent string based upon what is installed on this system This looks up the ver...
File-side plumbing behind the fluent chain: writing a unit of work's data out in a delimited format,...
Definition FileHelper.cs:55
static string GetDefaultDownloadDirectory(IBrowser browser)
Returns the download directory this browser will actually use, read out of its own configuration rath...
Pseudo element used in Applications and Browser workflows for image matching and unified automation....
string Css
CSS selector used to locate this element.
Thrown where GPAL deliberately ends the workflow, such as a CallIf handler returning CallIfStatus....
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
GPAL Selector used to locate Application and Browser elements. Instantiated with GPAL....
Definition Selector.cs:56
int DeltaX
Drag-and-drop deltaX to move from the elemenet.X + OffsetX.
Definition Selector.cs:838
int OffsetY
Y offset fromm the top left corner to interact with the element, image match included....
Definition Selector.cs:823
InteractionType InteractionType
Defined method to interact with this element. NOTE: This can be overridden in the workflow.
Definition Selector.cs:743
int OffsetX
X offset from the top left corner to interact with the element, image match included....
Definition Selector.cs:807
Everything revolves around the Unit of Work. A Unit of Work is defined as one or more selectors betw...
Definition UnitOfWork.cs:39
int WithAllThatMatch
Controls whether the selectors in scope are multiple elements on screen, and to get all Use ....
Selector NextPageButton
The next page button. When scraping multiple pages of results, this element is pressed for a new page...
Definition UnitOfWork.cs:66
List< Selector > WithSelectorList
List of With selectors Use .WithSelector to add selectors to this list.
Definition UnitOfWork.cs:45
bool PartialMatch
Only relevant if there is a match criteria or matching function. Of all the found selectors,...
bool InfiniteScroll
Go to page end to get the next page of results. Use with .WithPages() to retrieve multiple pages of ...
Definition UnitOfWork.cs:74
IAllowPuppeteerInputText FillInInsert(dynamic elementOrelementId, int delayMs=0)
IAllowPuppeteerInputText FillInOverwrite(dynamic elementOrelementId, int delayMs=0)
IAllowPuppeteerInputText FillInAppend(dynamic elementOrelementId, int delayMs=0)
Sets the element's value directly (fast path) - does NOT honor GPAL.TypingDelay. delayMs only paces ...