GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
Browser.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
17#define DONOTUSECDP // which solution to use for printDomElement
18
19using System;
20using System.Collections.Generic;
21using System.Collections.ObjectModel;
22using System.Diagnostics;
23using System.Drawing;
24using System.IO;
25using System.Linq;
26using System.Text.RegularExpressions;
27using System.Threading;
28using System.Threading.Tasks;
29using System.Xml;
30using DocumentFormat.OpenXml.Bibliography;
32using Newtonsoft.Json;
33using OpenQA.Selenium;
34using OpenQA.Selenium.Interactions;
35using static GenerallyPositive.Enums;
37using HWND = System.IntPtr;
38
39// the browser has a HiddenDesktop property now, which hides the static class of the same name in this
40// file. the alias says which one is meant at the four places that want the static one
42
44{
66 public class Browser
67 : IBrowser
68 {
106 public delegate CallIfStatus CallIfDelegate(IBrowser browser, List<IGPALElement> foundElements, List<IGPALElement> matchedElements, Selector selector, bool matchedAll);
133 public delegate CallIfStatus CallAfterFillInDelegate(IBrowser browser, IGPALGrid<string> tokens, int tokenIdx);
155 public delegate CallIfStatus CallAfterFetchDelegate(IBrowser browser, List<string> results, IGPALGrid<string> tokens, int tokenIdx);
156
168 public delegate CallIfStatus CallOnFailDelegate(IBrowser browser, GPALFailure failure, string detail);
169
186 {
187 return this;
188 }
189
190 #region <Working Vars>
191 BrowserSettings _browserSettings = null;
195 internal UnitOfWork persistentUOW { get; set; } = null;
199 // When the browser was launched, null until it is. GoTo only ever asked whether the tab tally was zero to
200 // find this out, which made a counter answer a question that is not about counting
201 internal DateTime? BrowserLaunched { get; set; } = null;
202
203 // Tabs GPAL opened itself, which is not the same as the tabs the browser has: ResolveGhostTabHandle finds
204 // Edge's phantom tab precisely by noticing there are more real tabs than tabs we opened. Only counted where
205 // the answer is free, which is why no engine is asked to go and look
206 internal int tabsWeOpened { get; set; } = 0;
213 internal string GhostTabUrl { get; set; } = null;
218 internal string GhostTabHandle { get; set; } = null;
222 public int CurrentTabIdx { get; internal set; } = 0;
223
224 // Which window each tab belongs to, tab handle to window handle. Selenium hands back one flat list of every
225 // tab in every window and will not say which belongs to which, but GPAL opened them so GPAL knows. Recorded
226 // once where a tab is born and never updated after. A tab that appeared on its own joins the window we were
227 // looking at when we first noticed it, which keeps it reachable by cycling.
228 private readonly Dictionary<string, string> tabWindows = new Dictionary<string, string>();
229
230 internal bool _areRobotsAllowed = true;
235 public bool AreRobotsAllowed { get => _areRobotsAllowed; internal set => _areRobotsAllowed = value; }
236
237 #endregion <Working Vars>
259 internal Browser()
260 {
261 this.BrowserSettings = new BrowserSettings(this)
262 {
263 DriverLocation = GPAL.DriverLocation,
264 DownloadTimeoutInSec = GPAL.GPALSettings.DownloadTimeoutInSec
265 };
266
267 CurrentUOW = new UnitOfWork() { Browser = this };
268 persistentUOW = new UnitOfWork() { Browser = this };
269 WorkflowManager = new WorkflowManager(this); // Pass current Browser instance
270 }
271
272 #region <Browser Settings>
288 {
289 BrowserSettings.DriverLocation = FileHelper.EnsureDirectoryEndsWithBackslash(Environment.ExpandEnvironmentVariables(directory));
290 return this;
291 }
292
298 {
299 BrowserSettings.UseDirectDownload = trueFalse;
300 return this;
301 }
302
308 {
309 BrowserSettings.DownloadTimeoutInSec = seconds;
310 return this;
311 }
312
318 {
319 BrowserSettings.DownloadLocation = FileHelper.EnsureDirectoryEndsWithBackslash(Environment.ExpandEnvironmentVariables(directory));
320 return this;
321 }
322
337 {
338 BrowserSettings.BlockPopUps = blockPopUp;
339 return this;
340 }
341
363 {
364 BrowserSettings.HiddenDesktop = hiddenDesktop;
365
366 // a browser that asks for a hidden desktop without saying which is asking for one of its own, so it
367 // gets a name nothing else will be handed. two browsers sharing is what naming one is for
368 if (true == hiddenDesktop)
369 BrowserSettings.HiddenDesktopName = Desktops.NextName();
370
371 return this;
372 }
373
389 {
390 BrowserSettings.HiddenDesktop = true;
391
392 // calling this at all is asking for a hidden desktop. a name that turned out to be nothing is a
393 // desktop of this browser's own, the same as asking without a name. not wanting one is not calling
394 BrowserSettings.HiddenDesktopName = true == string.IsNullOrWhiteSpace(desktopName)
395 ? Desktops.NextName()
396 : desktopName;
397
398 return this;
399 }
400
407 private T LaunchWhereAsked<T>(Func<T> launch)
408 {
409 T retVal;
410
411 if (true == BrowserSettings.HiddenDesktop)
412 retVal = Desktops.Launch(BrowserSettings.HiddenDesktopName, launch);
413 else
414 retVal = launch();
415
416 return retVal;
417 }
430 private static AutomationEngine FirefoxStepDown(AutomationEngine engine)
431 {
432 AutomationEngine retVal = engine;
433
434 switch (engine)
435 {
436 case AutomationEngine.PuppeteerPort:
437 case AutomationEngine.PuppeteerPipe:
438 retVal = AutomationEngine.Selenium;
439 break;
440 case AutomationEngine.PuppeteerPortHW:
441 case AutomationEngine.PuppeteerPipeHW:
442 retVal = AutomationEngine.SeleniumHW;
443 break;
444 }
445
446 return retVal;
447 }
448
449 private static AutomationEngine HardwareStepDown(AutomationEngine engine)
450 {
451 AutomationEngine retVal = engine;
452
453 switch (engine)
454 {
455 case AutomationEngine.OttoMagicHW:
456 retVal = AutomationEngine.OttoMagic;
457 break;
458 case AutomationEngine.PuppeteerPortHW:
459 retVal = AutomationEngine.PuppeteerPort;
460 break;
461 case AutomationEngine.PuppeteerPipeHW:
462 retVal = AutomationEngine.PuppeteerPipe;
463 break;
464 case AutomationEngine.SeleniumHW:
465 retVal = AutomationEngine.Selenium;
466 break;
467 }
468
469 return retVal;
470 }
487 [BrowserType(Description = "Chrome, Edge or Firefox")]
489 {
490 BrowserSettings.BrowserType = browserType;
491
492 return this;
493 }
494
509 {
510 BrowserSettings.DeleteFileBeforeDownload = overwriteFile;
511 return this;
512 }
513
528 {
529 BrowserSettings.LoadImages = loadImages;
530 return this;
531 }
532
550 {
551 BrowserSettings.ScrollIntoView = trueFalse;
552 return this;
553 }
554
571 {
572 BrowserSettings.PromptForDownload = promptForDownload;
573 return this;
574 }
575
581 {
582 BrowserSettings.OpenPDFExternally = openPDFExternally;
583 return this;
584 }
585
586 //public IAllowBrowserSettingsOrGoTo WithDownloadFileTypes(string csvList)
587 //{
588 // string[] fileTypes = csvList.Split(',');
589 // foreach (string fileType in fileTypes)
590 // BrowserSettings.DownloadFileTypeList.Add(fileType);
591 // return this;
592 //}
593
620 public IAllowWithHeaderOrFileActions WithGridToSave(IGPALGrid<string> inputGrid)
621 {
622 // a grid handed in is where the data comes from, so it starts a unit of work of its own the same
623 // way a selector does. Carrying on in the spent one meant the save inherited the last read's
624 // header list, control headers and selector names, and named this grid's columns after them
625 if (true == CurrentUOW.ActionCalled)
626 {
627 var iframeRoot = CurrentUOW.ContextPath;
628 var shadowRoot = CurrentUOW.ShadowRoot;
629
630 CurrentUOW = new UnitOfWork
631 {
632 ContextPath = iframeRoot,
633 ShadowRoot = shadowRoot,
634 Browser = this
635 };
636 }
637
638 if (null != inputGrid && 0 < inputGrid.Count())
639 {
640 // TODO: CAVEAT: is this best? we can define any type Grid but then saving it to dynamic?
641 CurrentUOW.RetGrid = inputGrid;
642 CurrentUOW.ColCount = inputGrid.Columns;
643 }
644 else
645 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Input IGPALGrid<T> is empty.", this, GPALObjectType.Browser);
646
647 return this;
648 }
649
650 //public IAllowBrowserSettingsOrGoTo WithOpenFileTypes(string csvList)
651 //{
652 // string[] fileTypes = csvList.Split(',');
653 // foreach (string fileType in fileTypes)
654 // BrowserSettings.OpenFileTypeList.Add(fileType);
655 // return this;
656 //}
657
684 {
685 BrowserSettings.UseExistingBrowser = true;
686 BrowserSettings.ExistingBrowserPort = port;
687
688 // the browser is already listening there, so that is the port this workflow talks on too. left unset,
689 // the puppeteer path found no port, took the next free one and started a second browser on it
690 BrowserSettings.DebugPort = port;
691
692 return this;
693 }
694
739 {
740 if (true == string.IsNullOrWhiteSpace(restApiUrl))
741 {
742 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No address given to WithGPALRestAPIUrl, so a browser will be launched as usual", this, GPALObjectType.Browser);
743 return this;
744 }
745
746 // the engine is what decides how a browser is driven, and only OttoMagic is driven over REST
747 if (false == UseOttoMagic)
748 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"WithGPALRestAPIUrl drives a browser over GPALRestAPI, which is the OttoMagic engine. The engine is currently [{BrowserSettings.AutomationEngine}]", this, GPALObjectType.Browser);
749
750 BrowserSettings.RestApiBaseUrl = restApiUrl.EndsWith("/") ? restApiUrl : restApiUrl + "/";
751 BrowserSettings.AttachedRestApi = true;
752
753 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Driving the browser already running on [{BrowserSettings.RestApiBaseUrl}].", this, GPALObjectType.Browser);
754
755 return this;
756 }
757
758 public IAllowBrowserSettingsOrGoTo WithCDPDebugPort(int port = 0xdead) // will actually take default from interface and is not required, but for clarity
759 {
760 if (port < 1024 || port > 65535)
761 {
762 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Invalid debug port [{port}]. Must be between 1024 and 65535. Using current port [{BrowserSettings.DebugPort}].", this, GPALObjectType.Browser);
763 return this;
764 }
765
766 // if the port is already in use on the computer, keep bumping it up one until we find an open port
767 while (true == BrowserHelper.IsPortListening(port))
768 port++;
769
770 BrowserSettings.DebugPort = port;
771
772 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Puppeteer using port [{port}]", this, GPALObjectType.Browser);
773
774 // NOTE: CAVEAT: existing port generally should be the same as debug port - do we want to set this?
775 // BUT, it is possible we are automating multiple browsers, so these have to be separate for each browser, but if it's not yet set...
776 // so, if we are open in debugger port, we could then connect to another browser on another existing port and copy data over.
777 // automating multiple browsers is problematic, tabs is the way to go, if using puppeteer, we could in theory handle multiple browsers.
778 if (null == BrowserSettings.ExistingBrowserPort)
779 BrowserSettings.ExistingBrowserPort = port;
780
781 AutomationEngine = AutomationEngine.PuppeteerPort;
782 BrowserSettings.DebugPipe = false;
783
784 return this;
785 }
793 {
794 if (BrowserType.FireFox == BrowserSettings.BrowserType &&
795 (AutomationEngine.PuppeteerPort == automationEngine || AutomationEngine.PuppeteerPortHW == automationEngine ||
796 AutomationEngine.PuppeteerPipe == automationEngine || AutomationEngine.PuppeteerPipeHW == automationEngine))
797 {
798 AutomationEngine steppedDownTo = FirefoxStepDown(automationEngine);
799
800 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Firefox cannot be driven with [{automationEngine}], so this browser runs on [{steppedDownTo}]", this, GPALObjectType.Browser);
801
802 automationEngine = steppedDownTo;
803 }
804
805 AutomationEngine = automationEngine;
806 return this;
807 }
808
815 public IAllowBrowserSettingsOrGoTo WithCDPDebugPipe(bool trueFalse = false) // will actually take default from interface and is not required, but for clarity
816 {
817 AutomationEngine = AutomationEngine.PuppeteerPipe;
818 BrowserSettings.DebugPipe = true;
819 BrowserSettings.DebugPort = null;
820
821 return this;
822 }
823
841 {
842 BrowserSettings.WaitOnDocumentReady = true;
843 BrowserSettings.WaitOnNetworkIdle = false;
844 BrowserSettings.WaitForDocumentReadyTimeoutMs = timeoutInMs;
845
846 return this;
847 }
848
865 {
866 BrowserSettings.NavigationGraceMs = graceInMs;
867
868 return this;
869 }
870
891 {
892 BrowserSettings.WaitOnNetworkIdle = trueFalse;
893 BrowserSettings.WaitOnDocumentReady = trueFalse ? false : BrowserSettings.WaitOnDocumentReady;
894
895 return this;
896 }
897
916 public IAllowNetworkIdleSettings WithNetworkIdleTimeoutMs(int networkIdleTimeoutMs = 500)
917 {
918 BrowserSettings.NetworkIdleTimeoutMs = networkIdleTimeoutMs;
919 return this;
920 }
921
941 {
942 BrowserSettings.NetworkIdleMaxConnections = maxConnections;
943 return this;
944 }
945
966 public IAllowNetworkIdleSettings WithNetworkIdlePruneMs(int networkIdlePruneMs = 3_000)
967 {
968 BrowserSettings.NetworkIdlePruneMs = networkIdlePruneMs;
969 return this;
970 }
971
989 public IAllowBrowserSettingsOrGoTo WithProfileDataDirectory(string profileDataDirectory)
990 {
991 string tmpProfileDirectory = FileHelper.EnsureDirectoryEndsWithBackslash(Environment.ExpandEnvironmentVariables(profileDataDirectory));
992
993 BrowserSettings.ProfileDataDirectory = tmpProfileDirectory;
994
995 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Using profile data directory [{tmpProfileDirectory}].", this, GPALObjectType.Browser);
996
997 return this;
998 }
999
1018 {
1019 BrowserSettings.ProfileUserName = profileUserName;
1020 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Using profile user name [{profileUserName}].", this, GPALObjectType.Browser);
1021
1022 return this;
1023 }
1024
1043 {
1044 BrowserSettings.ProfileName = profileName;
1045 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Using profile name [{profileName}].", this, GPALObjectType.Browser);
1046
1047 return this;
1048 }
1049
1056 public IAllowBrowserSettingsOrGoTo WithUseStealth(StealthType steathType)
1057 {
1058 BrowserSettings.StealthType = steathType;
1059 return this;
1060 }
1061
1068 {
1069 BrowserSettings.Referrer = referrer;
1070 return this;
1071 }
1072
1078 public IAllowBrowserSettingsOrGoTo WithUseUserAgent(string userAgent = "")
1079 {
1080 BrowserSettings.OverrideUserAgent = userAgent;
1081 return this;
1082 }
1083
1094 public IAllowBrowserSettingsOrGoTo WithUserAgentFromBrowser(bool userAgentFromBrowser = true)
1095 {
1096 BrowserSettings.UserAgentFromBrowser = userAgentFromBrowser;
1097 return this;
1098 }
1099
1125 {
1126 selector.ElementsFoundAndMatchedCount = 0; // in case it's being reused
1127 selector.DeleteMe = false; // Remove() latches this on and nothing else clears it, so a reused selector would be swept on its first maintenance pass
1128 selector.RemovedMethods.Clear(); // a handler that took itself out of play in an earlier workflow is back in play for this one
1129 if (true == CurrentUOW.ActionCalled)
1130 {
1131 // NOTE: CAVEAT: should this a list to have nested iframes?
1132 var iframeRoot = CurrentUOW.ContextPath;
1133 var shadowRoot = CurrentUOW.ShadowRoot;
1134
1135 CurrentUOW = new UnitOfWork
1136 {
1137 ContextPath = iframeRoot,
1138 ShadowRoot = shadowRoot
1139 };
1140
1141 CurrentUOW.Browser = this;
1142 selector.Browser = this;
1143 }
1144
1145 ResolveInteractionType(selector);
1146
1147 // if we are reusing, we can't use cached results in a new uow
1148 selector.WebSelectorFoundResults = null;
1149 selector.WebSelectorMatchedResults = null;
1150
1151 //CurrentUOW.SelectorType = SelectorType.WithSelector;
1152 // clone so if this selector is reused, each UOW list will be unique and we won't change the selector by changing the browser it is associated with
1153 // NOTE: cloning disassociates with actual program selector, so we can't set any queryable properties on Selector like 'ElementsFoundAndMatchedCount'
1154 // BrowserSettings.MySelector = new Selector(this, selector); // MySelector temp hold for other browser syntax
1155 BrowserSettings.CurrentSelector = selector;
1156
1157 CurrentUOW.WithSelectorList.Add(BrowserSettings.CurrentSelector);
1158 return this;
1159 }
1160
1165 public IAllowAfterAnySelector WithSelector(string literalData)
1166 {
1167 if (true == CurrentUOW.ActionCalled)
1168 {
1169 // NOTE: CAVEAT: should this a list to have nested iframes?
1170 var iframeRoot = CurrentUOW.ContextPath;
1171 var shadowRoot = CurrentUOW.ShadowRoot;
1172 // var shadowRootElement = CurrentUOW.ShadowRootGPALElement;
1173
1174 CurrentUOW = new UnitOfWork
1175 {
1176 ContextPath = iframeRoot,
1177 ShadowRoot = shadowRoot
1178 };
1179
1180 persistentUOW = new UnitOfWork();
1181
1182 CurrentUOW.Browser = this;
1183 persistentUOW.Browser = this;
1184 }
1185
1186 Selector tmpSel = GPAL.Selector.WithText(literalData).ToGPALObject();
1187 tmpSel.selectorSettings.SelectorType = SelectorType.Data;
1188 tmpSel.selectorSettings.Name = "Literal" + CurrentUOW.WithSelectorList.Select(s => SelectorType.Data == s.SelectorSettings.SelectorType).Count() + 1;
1189 CurrentUOW.WithSelectorList.Add(tmpSel);
1190 return this;
1191 }
1192
1197 public IAllowAfterAnySelector WithSelector(Func<string> dataFunction)
1198 {
1199 Selector tmpSel = GPAL.Selector.ToGPALObject();
1200 tmpSel.selectorSettings.SelectorType = SelectorType.DataFunc;
1201 tmpSel.selectorSettings.Name = dataFunction.Method.Name;
1202 tmpSel.selectorSettings.DataFunction = dataFunction;
1203 CurrentUOW.WithSelectorList.Add(tmpSel);
1204 return this;
1205 }
1206
1207 #region Storage
1214 private int WalkInElementIfAny(Action body)
1215 {
1216 UnitOfWork.WebElementWithType inElement = CurrentUOW.ContextPath?
1217 .LastOrDefault(context => ElementType.Element == context.elementType && null != context.selector);
1218
1219 if (null == inElement)
1220 {
1221 body();
1222
1223 return 0;
1224 }
1225
1226 // however many containers were kept when they were found, which is where .WithAllThatMatch was
1227 // applied. Nothing is decided here
1228 for (int container = 0; container < inElement.elements.Count; container++)
1229 {
1230 inElement.current = container;
1231
1232 body();
1233 }
1234
1235 inElement.current = 0;
1236
1237 return inElement.elements.Count;
1238 }
1239
1247 private void RefreshContainers(UnitOfWork.WebElementWithType inElement)
1248 {
1249 int at = CurrentUOW.ContextPath.IndexOf(inElement);
1250
1251 CurrentUOW.ContextPath.RemoveAt(at);
1252
1253 // A next page can land on an interstitial before the results, and the containers are simply not
1254 // there yet. The same .WaitFor the workflow gave its selectors applies here, asked again until the
1255 // page settles or the time is up
1256 List<GPALElement> containers = null;
1257 Stopwatch waited = Stopwatch.StartNew();
1258 long waitFor = WaitTime.Never < CurrentUOW.WaitForInMs ? (long)CurrentUOW.WaitForInMs : 0;
1259
1260 do
1261 {
1262 ElementHelper.FindWebElements(this, CurrentUOW, inElement.selector, out _, out containers, null, false);
1263
1264 if (0 < containers?.Count) break;
1265
1266 if (waitFor > waited.ElapsedMilliseconds)
1267 System.Threading.Thread.Sleep(250);
1268 }
1269 while (waitFor > waited.ElapsedMilliseconds);
1270
1271 CurrentUOW.ContextPath.Insert(at, inElement);
1272
1273 if (0 < containers?.Count)
1274 {
1275 // .WithAllThatMatch counts what is being collected, and with .InElement that is the containers:
1276 // ten cards, not ten prices out of each card. Applied here so nothing is carried that will not
1277 // be read. Same precedence a search uses: unit of work, then browser, then global
1278 int wanted = int.MaxValue;
1279
1280 if (int.MaxValue != CurrentUOW.WithAllThatMatch && 0 < CurrentUOW.WithAllThatMatch)
1281 wanted = CurrentUOW.WithAllThatMatch;
1282 else if (int.MaxValue != BrowserSettings.WithAllThatMatch && 0 < BrowserSettings.WithAllThatMatch)
1283 wanted = BrowserSettings.WithAllThatMatch;
1284 else if (0 < GPAL.GPALSettings.WithAllThatMatch)
1285 wanted = GPAL.GPALSettings.WithAllThatMatch;
1286
1287 if (int.MaxValue != wanted && wanted < containers.Count)
1288 {
1289 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{wanted}] containers kept of [{containers.Count}] found for selector [{inElement.selector.Name}] with [WithAllThatMatch({wanted})]", this, GPALObjectType.Browser);
1290
1291 containers = containers.Take(wanted).ToList();
1292 }
1293
1294 inElement.elements = containers;
1295 inElement.gPalElement = containers[0];
1296 }
1297 else
1298 {
1299 inElement.elements = null;
1300 inElement.gPalElement = null;
1301
1302 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"InElement selector returned no results [{inElement.selector.Name}]", this, GPALObjectType.Browser);
1303 }
1304
1305 inElement.current = 0;
1306 }
1317 private bool RowsFromContainers(List<ReadOnlyCollection<GPALElement>> tmpColumns,
1318 List<(string Name, string AttributeName)> selectorMeta, int pages)
1319 {
1320 bool retVal = false;
1321
1322 UnitOfWork.WebElementWithType inElement = CurrentUOW.ContextPath?
1323 .LastOrDefault(context => ElementType.Element == context.elementType && null != context.selector);
1324
1325 if (null != inElement)
1326 {
1327 RefreshContainers(inElement);
1328
1329 // a workflow that said .InElement gets rows from containers or it gets none. Falling back to
1330 // the column search would read the page a different way and say nothing about having done so,
1331 // which is the misalignment .InElement is here to prevent
1332 retVal = true;
1333 }
1334
1335 if (0 < inElement?.elements?.Count)
1336 {
1337 List<Selector> selectors = CurrentUOW.WithSelectorList;
1338 List<List<GPALElement>> building = selectors.Select(sel => new List<GPALElement>()).ToList();
1339
1340 foreach (Selector sel in selectors)
1341 {
1342 sel.WebSelectorFoundResults = null;
1343 sel.WebSelectorMatchedResults = null;
1344
1345 selectorMeta.Add((sel.Name, sel.AttributeName));
1346 }
1347
1348 int rows = WalkInElementIfAny(() =>
1349 {
1350 for (int column = 0; column < selectors.Count; column++)
1351 {
1352 Selector sel = selectors[column];
1353 GPALElement cell = null;
1354
1355 if (SelectorType.Selector == sel.selectorSettings.SelectorType)
1356 {
1357 ElementHelper.FindWebElements(this, CurrentUOW, sel, out _, out List<GPALElement> matchedElems);
1358
1359 // the first is the answer for this container. A card showing two prices gives its
1360 // own row two prices to choose from, not the row below it a wrong one
1361 if (0 < matchedElems?.Count)
1362 {
1363 cell = matchedElems[0];
1364 cell.AttributeName = sel.AttributeName;
1365 }
1366 }
1367 else if (SelectorType.Data == sel.selectorSettings.SelectorType)
1368 {
1369 cell = GPAL.Element;
1370 cell.Text = sel.SelectorPath;
1371 }
1372 else if (SelectorType.DataFunc == sel.selectorSettings.SelectorType)
1373 {
1374 cell = GPAL.Element;
1375 cell.Text = sel.selectorSettings.DataFunction();
1376 }
1377
1378 // a container that has not got the thing still owes a cell
1379 if (null == cell)
1380 cell = new GPALElement(new Point(0, 0), new Size(1, 1), $"{GPAL.ErrorPlaceholder} : {sel.Name}", BrowserDriver, "GPALElement")
1381 {
1382 AttributeName = sel.AttributeName,
1383 Browser = this,
1384 Selector = sel
1385 };
1386
1387 building[column].Add(cell);
1388 }
1389 });
1390
1391 foreach (List<GPALElement> column in building)
1392 tmpColumns.Add(new ReadOnlyCollection<GPALElement>(column));
1393
1394 CurrentUOW.RowCount = rows;
1395 CurrentUOW.ColCount = selectors.Count;
1396
1397 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Grid page [{pages + 1}] read [{rows}] containers of [{selectors.Count}] columns", this, GPALObjectType.Browser);
1398 }
1399 else if (true == retVal)
1400 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No containers for [{inElement.selector.Name}] on page [{pages + 1}], so no rows from it. The page was not read another way", this, GPALObjectType.Browser);
1401
1402 return retVal;
1403 }
1414 {
1415 // the browser's own origin, so the client is ready to be given an endpoint rather than the caller
1416 // restating what the browser already knows
1417 string origin = UrlHelper.GetOrigin(BrowserHelper.GetCurrentUrl(BrowserSettings));
1418 RESTClient retVal = (RESTClient)GPAL.RESTClient.WithAPIBase(origin);
1419
1420 // a browser that has not been anywhere has no origin to talk to and no cookies to carry, so the
1421 // client would come back empty and fail later at the request instead of here at the call
1422 if (true == string.IsNullOrEmpty(origin))
1423 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No page loaded, so there is no session to carry. Navigate first, then continue as a RESTClient", this, GPALObjectType.Browser);
1424
1425 // a cookie get scoped to this origin, declared and run the way any storage action is. it is declared
1426 // on a url of its own so a workflow that declared its own actions still runs only those
1427 GPALUrl cookieUrl = new GPALUrl(origin);
1428
1429 cookieUrl.Add(new StorageAction { StorageType = WebsiteStorageType.cookie, Action = WebsiteStorageAction.get });
1430
1431 UrlHelper.TryPerformAction(cookieUrl, BrowserSettings,
1432 new StorageAction { StorageType = WebsiteStorageType.cookie, Action = WebsiteStorageAction.get },
1433 out string cookieJson);
1434
1435 string cookieHeader = CookieHeaderFrom(cookieJson, origin, out int cookiesRead);
1436
1437 if (false == string.IsNullOrEmpty(cookieHeader))
1438 retVal.WithHeader("Cookie", cookieHeader);
1439
1440 // asked of the browser rather than taken from what it was configured with, because a site can bind a
1441 // session to the agent it saw
1442 string sentUserAgent = BrowserHelper.LiveUserAgent(this);
1443 string sentAcceptLanguage = BrowserHelper.LiveAcceptLanguage(this);
1444
1445 retVal.WithHeader("User-Agent", sentUserAgent ?? BrowserHelper.ResolveUserAgent(BrowserSettings));
1446
1447 if (false == string.IsNullOrEmpty(sentAcceptLanguage))
1448 retVal.WithHeader("Accept-Language", sentAcceptLanguage);
1449
1450 // the credential the browser is actually using, whichever way it got one. WithCredentials is the
1451 // common case and basic auth never becomes a cookie - the browser resends the header on every
1452 // request - so without this a client carrying only cookies gets a flat 401
1453 ICredentials browserCredential = BrowserSettings.Credentials;
1454
1455 if (null != browserCredential)
1456 {
1457 Credentials credential = (Credentials)browserCredential;
1458 string authValue = BrowserHelper.AuthorizationHeader(credential, credential.WebAuthType);
1459
1460 if (false == string.IsNullOrEmpty(authValue))
1461 retVal.WithHeader(BrowserHelper.AuthorizationHeaderName(credential.WebAuthType), authValue);
1462
1463 }
1464
1465 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Continuing as a RESTClient from [{BrowserSettings.BrowserType}], carrying its [{(null == sentUserAgent ? "configured" : "live")}] user agent, [{(string.IsNullOrEmpty(sentAcceptLanguage) ? "no accept language" : sentAcceptLanguage)}], [{cookieHeader?.Split(';').Length ?? 0}] of [{cookiesRead}] cookies for [{origin}] and [{(null == browserCredential ? "no credential" : ((Credentials)browserCredential).WebAuthType.ToString())}]", this, GPALObjectType.Browser);
1466
1467 client = retVal;
1468
1469 return this;
1470 }
1471
1480 private static string CookieHeaderFrom(string cookieJson, string origin, out int cookiesRead)
1481 {
1482 string retVal = null;
1483
1484 cookiesRead = 0;
1485
1486 try
1487 {
1488 if (false == string.IsNullOrEmpty(cookieJson))
1489 {
1490 string host = true == Uri.TryCreate(origin, UriKind.Absolute, out Uri originUri) ? originUri.Host : null;
1491 Newtonsoft.Json.Linq.JArray cookies = Newtonsoft.Json.Linq.JArray.Parse(cookieJson);
1492 List<string> pairs = new List<string>();
1493
1494 cookiesRead = cookies.Count;
1495
1496 foreach (Newtonsoft.Json.Linq.JToken cookie in cookies)
1497 {
1498 // puppeteer calls the name Key, the other two call it name
1499 string name = (string)(cookie["name"] ?? cookie["Name"] ?? cookie["Key"]);
1500 string value = (string)(cookie["value"] ?? cookie["Value"]);
1501 string domain = (string)(cookie["domain"] ?? cookie["Domain"]);
1502
1503 if (false == string.IsNullOrEmpty(name) && true == UrlHelper.CookieAppliesToHost(domain, host))
1504 pairs.Add($"{name}={value}");
1505 }
1506
1507 if (0 < pairs.Count)
1508 retVal = string.Join("; ", pairs);
1509 }
1510 }
1511 catch (GPALException)
1512 {
1513 throw;
1514 }
1515 catch (Exception ex)
1516 {
1517 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Could not read the cookies into a header", null, GPALObjectType.Browser, ex);
1518 }
1519
1520 return retVal;
1521 }
1522
1523
1530 public IAllowStorageOutData RunGet(WebsiteStorageType storageType)
1531 {
1532 BrowserSettings.StorageActionCalled = true;
1533 BrowserSettings.StorageActionToRun.Action = WebsiteStorageAction.get;
1534 BrowserSettings.StorageActionToRun.StorageType = storageType;
1535 UrlHelper.TryPerformAction(BrowserSettings.CurrentGPALUrl, BrowserSettings, BrowserSettings.StorageActionToRun, out BrowserSettings.StorageActionToRun._data);
1536 return this;
1537 }
1538
1544 public IAllowBrowserActionOrAnySelector RunSet(WebsiteStorageType storageType)
1545 {
1546 BrowserSettings.StorageActionCalled = true;
1547 BrowserSettings.StorageActionToRun.Action = WebsiteStorageAction.set;
1548 BrowserSettings.StorageActionToRun.StorageType = storageType;
1549 UrlHelper.TryPerformAction(BrowserSettings.CurrentGPALUrl, BrowserSettings, BrowserSettings.StorageActionToRun, out _);
1550 return this;
1551 }
1552
1558 public IAllowBrowserActionOrAnySelector RunDelete(WebsiteStorageType storageType)
1559 {
1560 BrowserSettings.StorageActionCalled = true;
1561 BrowserSettings.StorageActionToRun.Action = WebsiteStorageAction.delete;
1562 BrowserSettings.StorageActionToRun.StorageType = storageType;
1563 UrlHelper.TryPerformAction(BrowserSettings.CurrentGPALUrl, BrowserSettings, BrowserSettings.StorageActionToRun, out _);
1564 return this;
1565 }
1566
1574 {
1575 if (true == BrowserSettings.StorageActionCalled)
1576 {
1577 BrowserSettings.StorageActionToRun = new StorageAction();
1578 BrowserSettings.StorageActionCalled = false;
1579 }
1580
1581 BrowserSettings.StorageActionToRun.Domain = domain;
1582
1583 return this;
1584 }
1585
1593 {
1594 if (true == BrowserSettings.StorageActionCalled)
1595 {
1596 BrowserSettings.StorageActionToRun = new StorageAction();
1597 BrowserSettings.StorageActionCalled = false;
1598 }
1599
1600 BrowserSettings.StorageActionToRun.Path = path;
1601
1602 return this;
1603 }
1604
1612 {
1613 if (true == BrowserSettings.StorageActionCalled)
1614 {
1615 BrowserSettings.StorageActionToRun = new StorageAction();
1616 BrowserSettings.StorageActionCalled = false;
1617 }
1618
1619 BrowserSettings.StorageActionToRun.Key = key;
1620
1621 return this;
1622 }
1623
1631 {
1632 if (true == BrowserSettings.StorageActionCalled)
1633 {
1634 BrowserSettings.StorageActionToRun = new StorageAction();
1635 BrowserSettings.StorageActionCalled = false;
1636 }
1637
1638 BrowserSettings.StorageActionToRun.StoreName = storeName;
1639
1640 return this;
1641 }
1642
1650 {
1651 if (true == BrowserSettings.StorageActionCalled)
1652 {
1653 BrowserSettings.StorageActionToRun = new StorageAction();
1654 BrowserSettings.StorageActionCalled = false;
1655 }
1656
1657 BrowserSettings.StorageActionToRun.Data = data;
1658
1659 return this;
1660 }
1661
1669 {
1670 if (true == BrowserSettings.StorageActionCalled)
1671 {
1672 BrowserSettings.StorageActionToRun = new StorageAction();
1673 BrowserSettings.StorageActionCalled = false;
1674 }
1675
1676 BrowserSettings.StorageActionToRun.UserDefined = userDefined;
1677
1678 return this;
1679 }
1680
1687 {
1688 data = BrowserSettings.StorageActionToRun.Data;
1689
1690 return this;
1691 }
1692 #endregion Storage
1693
1702 {
1703 selector.ElementsFoundAndMatchedCount = 0;
1704 selector.WebSelectorFoundResults = null;
1705 selector.WebSelectorMatchedResults = null;
1706
1707 // Fresh UOW after any action (your standard reset)
1708 if (CurrentUOW.ActionCalled)
1709 {
1710 var previousShadowRoot = CurrentUOW.ShadowRoot;
1711 var previousContextPath = CurrentUOW.ContextPath;
1712
1713 CurrentUOW = new UnitOfWork
1714 {
1715 Browser = this,
1716 ShadowRoot = previousShadowRoot,
1717 ContextPath = previousContextPath // preserve chain
1718 };
1719
1720 persistentUOW = new UnitOfWork { Browser = this };
1721 }
1722
1723 BrowserSettings.CurrentSelector = selector;
1724 CurrentUOW.InSelectorList.Add(selector);
1725
1726 // --- Switch to clean context first ---
1727 //if (true == UseOttoMagic)
1728 // MagicHelper?.SwitchToDefaultContent();
1729 //else if (true == UsePuppeteer)
1730 // PuppeteerClient.SwitchToDefaultContent().Execute();
1731 //else
1732 // BrowserDriver.SwitchTo().DefaultContent();
1733
1734 // Walk existing context
1735 IEnumerable<UnitOfWork.WebElementWithType> stepsToProcess;
1736
1737 stepsToProcess = CurrentUOW.ContextPath;
1738
1739 GPALElement inElement = null;
1740
1741 foreach (UnitOfWork.WebElementWithType contextType in stepsToProcess)
1742 {
1743 if (ElementType.IFrame == contextType.elementType)
1744 {
1745 if (UsePuppeteer)
1746 PuppeteerClient.SwitchToFrame(contextType.gPalElement.Css).Execute();
1747 else
1748 BrowserDriver.SwitchTo().Frame((IWebElement)contextType.gPalElement.WebElement);
1749
1750 // a search context belongs to one document - anything scoped in the frame we just left is
1751 // stale, and a shadow root from the parent throws "no such shadow root" if reused here
1752 inElement = null;
1753 }
1754 else if (ElementType.ShadowRoot == contextType.elementType)
1755 {
1756 if (UseOttoMagic)
1757 MagicHelper.SwitchToShadowRoot(contextType.gPalElement.Css);
1758 else if (UsePuppeteer)
1759 PuppeteerClient.SwitchToShadowRoot(contextType.gPalElement.Css).Execute();
1760
1761 // scope to THIS step's shadow root rather than the last one added to the unit of work, so a
1762 // path with more than one shadow root walks through each in turn instead of collapsing them
1763 inElement = contextType.gPalElement;
1764 }
1765 else if (ElementType.Element == contextType.elementType)
1766 {
1767 inElement = contextType.gPalElement; // this becomes the new root
1768 }
1769 }
1770
1771 // The containers are named here and found when an action runs. .WithAllThatMatch comes after this
1772 // in the chain and says how many are wanted, and a next page replaces them all, so finding them now
1773 // would be answering both questions before either has been asked
1774 var elementEntry = new UnitOfWork.WebElementWithType(selector, null, ElementType.Element);
1775
1776 CurrentUOW.ContextPath ??= new List<UnitOfWork.WebElementWithType>();
1777 CurrentUOW.ContextPath.Add(elementEntry);
1778
1779 return this;
1780 }
1781
1808 {
1809 selector.ElementsFoundAndMatchedCount = 0;
1810 selector.WebSelectorFoundResults = null;
1811 selector.WebSelectorMatchedResults = null;
1812
1813 // Fresh UOW after any action (standard fluent reset)
1814 if (CurrentUOW.ActionCalled)
1815 {
1816 var previousShadowRoot = CurrentUOW.ShadowRoot;
1817 var previousContextPath = CurrentUOW.ContextPath;
1818
1819 CurrentUOW = new UnitOfWork
1820 {
1821 Browser = this,
1822 ShadowRoot = previousShadowRoot,
1823 ContextPath = previousContextPath
1824 };
1825
1826 persistentUOW = new UnitOfWork { Browser = this };
1827 }
1828
1829 BrowserSettings.CurrentSelector = selector;
1830 CurrentUOW.InSelectorList.Add(selector);
1831
1832 if (true == UseOttoMagic)
1833 MagicHelper.SwitchToDefaultContent();
1834 else if (true == UsePuppeteer)
1835 PuppeteerClient.SwitchToDefaultContent().Execute();
1836 else
1837 this.BrowserDriver.SwitchTo().DefaultContent();
1838
1839 IEnumerable<UnitOfWork.WebElementWithType> stepsToProcess;
1840 GPALElement inElement = null;
1841
1842 // puppeteer or selenium have to be iframe aware: process the full original path
1843 stepsToProcess = CurrentUOW.ContextPath;
1844
1845 // Now walk the (possibly filtered) steps
1846 foreach (UnitOfWork.WebElementWithType contextType in stepsToProcess)
1847 {
1848 if (ElementType.IFrame == contextType.elementType)
1849 {
1850 if (true == UseOttoMagic)
1851 MagicHelper.SwitchToElement(contextType.gPalElement.Css);
1852 else if(true == UsePuppeteer)
1853 PuppeteerClient.SwitchToFrame(contextType.gPalElement.Css).Execute();
1854 else
1855 BrowserDriver.SwitchTo().Frame((IWebElement)contextType.gPalElement.WebElement);
1856
1857 // a search context belongs to one document - anything scoped in the frame we just left is
1858 // stale, and a shadow root from the parent throws "no such shadow root" if reused here
1859 inElement = null;
1860 }
1861 else if (ElementType.ShadowRoot == contextType.elementType)
1862 {
1863 if (true == UseOttoMagic)
1864 MagicHelper.SwitchToShadowRoot(contextType.gPalElement.Css);
1865 else if (true == UsePuppeteer)
1866 PuppeteerClient.SwitchToShadowRoot(contextType.gPalElement.Css).Execute();
1867
1868 // scope to THIS step's shadow root rather than the last one added to the unit of work, so a
1869 // path with more than one shadow root walks through each in turn instead of collapsing them
1870 inElement = contextType.gPalElement;
1871 }
1872 }
1873
1874 // Find the iframe
1875 ElementHelper.FindWebElements(
1876 this, CurrentUOW, selector, out _, out var tempElems, CurrentUOW.ShadowRoot);
1877
1878 if (tempElems?.Count > 0)
1879 {
1880 var iframeElement = new UnitOfWork.WebElementWithType(
1881 selector, tempElems[0], ElementType.IFrame);
1882
1883 CurrentUOW.ContextPath ??= new List<UnitOfWork.WebElementWithType>();
1884 CurrentUOW.ContextPath.Add(iframeElement);
1885 }
1886
1887 return this;
1888 }
1889
1921 {
1922 shadowDomSelector.ElementsFoundAndMatchedCount = 0;
1923 shadowDomSelector.WebSelectorFoundResults = null;
1924 shadowDomSelector.WebSelectorMatchedResults = null;
1925
1926 if (CurrentUOW.ActionCalled)
1927 {
1928 var previousShadowRoot = CurrentUOW.ShadowRoot;
1929 var previousContextPath = CurrentUOW.ContextPath;
1930
1931 CurrentUOW = new UnitOfWork
1932 {
1933 Browser = this,
1934 ShadowRoot = previousShadowRoot,
1935 ContextPath = previousContextPath
1936 };
1937
1938 persistentUOW = new UnitOfWork { Browser = this };
1939 }
1940
1941 BrowserSettings.CurrentSelector = shadowDomSelector;
1942 CurrentUOW.InSelectorList.Add(shadowDomSelector);
1943
1944 if (true == UseOttoMagic)
1945 MagicHelper.SwitchToDefaultContent();
1946 else if (true == UsePuppeteer)
1947 PuppeteerClient.SwitchToDefaultContent().Execute();
1948 else
1949 this.BrowserDriver.SwitchTo().DefaultContent();
1950
1951 GPALElement inElement = null;
1952
1953 // Now walk the (possibly filtered) steps
1954 foreach (UnitOfWork.WebElementWithType contextType in CurrentUOW.ContextPath)
1955 {
1956 if (ElementType.IFrame == contextType.elementType)
1957 {
1958 if (true == UseOttoMagic)
1959 MagicHelper.InFrame(contextType.gPalElement.Css);
1960 else if (true == UsePuppeteer)
1961 PuppeteerClient.SwitchToFrame(contextType.gPalElement.Css).Execute();
1962 else
1963 BrowserDriver.SwitchTo().Frame((IWebElement)contextType.gPalElement.WebElement);
1964
1965 // a search context belongs to one document - anything scoped in the frame we just left is
1966 // stale, and a shadow root from the parent throws "no such shadow root" if reused here
1967 inElement = null;
1968 }
1969 else if (ElementType.ShadowRoot == contextType.elementType)
1970 {
1971 if (true == UseOttoMagic)
1972 MagicHelper.SwitchToShadowRoot(contextType.gPalElement.Css);
1973 else if (true == UsePuppeteer)
1974 PuppeteerClient.SwitchToShadowRoot(contextType.gPalElement.Css).Execute();
1975
1976 // scope to THIS step's shadow root rather than the last one added to the unit of work, so a
1977 // path with more than one shadow root walks through each in turn instead of collapsing them
1978 inElement = contextType.gPalElement;
1979 }
1980 }
1981
1982 ElementHelper.FindWebElements(
1983 this, CurrentUOW, shadowDomSelector, out _, out var tempElems, inElement);
1984
1985 if (tempElems?.Count > 0)
1986 {
1987 var shadowElement = new UnitOfWork.WebElementWithType(
1988 shadowDomSelector, tempElems[0], ElementType.ShadowRoot);
1989
1990 CurrentUOW.ShadowRoot = tempElems[0];
1991 CurrentUOW.ShadowRoot.IsShadowRoot = true;
1992
1993 CurrentUOW.ContextPath ??= new List<UnitOfWork.WebElementWithType>();
1994 CurrentUOW.ContextPath.Add(shadowElement);
1995
1996 // Existing shadow root plumbing (unchanged)
1997 if (true == UseOttoMagic)
1998 {
1999 // CurrentUOW.ShadowRoot.ShadowRoot = MagicHelper.GetShadowRoot(shadowDomSelector.SelectorPaths[0].SelectorPath); // this doesn't make sense, nothing useful can be returned
2000 // tempElems[0].ShadowRoot = CurrentUOW.ShadowRoot;
2001 }
2002 else if (true == UsePuppeteer)
2003 {
2004 //CurrentUOW.ShadowRoot.ShadowRoot = PuppeteerClient.GetShadowRoot(tempElems[0].Css).Execute<GPALElement>();
2005 //tempElems[0].ShadowRoot = CurrentUOW.ShadowRoot;
2006 }
2007 else if (true == UseSelenium)
2008 {
2009 ISearchContext shadowRoot = null;
2010
2011 // GetShadowRoot is webdriver's own call for this and throws only when the host genuinely has
2012 // no open root. asking javascript instead cannot tell a closed root from a script that did not
2013 // run, because ExecuteJavaScriptObj swallows its own failures and hands back null either way -
2014 // and a null here marks an open root closed, which sends every search inside it back the host
2015 // instead of the children.
2016 try
2017 {
2018 shadowRoot = ((IWebElement)tempElems[0].WebElement).GetShadowRoot();
2019 }
2020 catch
2021 {
2022 // closed, handled below
2023 }
2024
2025 if (null != shadowRoot)
2026 CurrentUOW.ShadowRoot.WebElement = shadowRoot; // dynamic - the search dispatches to it at runtime
2027 else
2028 {
2029 CurrentUOW.ShadowRoot.IsClosedShadowRoot = true;
2030 // a closed shadow root is never handed to webdriver - arguments[0].shadowRoot is null and
2031 // GetShadowRoot throws. leave the host element in place so the root still has a real
2032 // position to click relative to, and so searches inside it stay scoped to the host rather
2033 // than silently falling back to the whole document and matching something unrelated.
2034 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Closed shadow root for selector [{shadowDomSelector.Name}]. Will return host element for searches inside.", this, GPALObjectType.Browser);
2035 }
2036 }
2037 }
2038
2039 return this;
2040 }
2041
2047 {
2048 CurrentUOW.ShadowRoot = null;
2049 CurrentUOW.ContextPath?.Clear();
2050
2051 if (true == UseOttoMagic)
2052 MagicHelper.SwitchToDefaultContent();
2053 else if (true == UsePuppeteer)
2054 PuppeteerClient.SwitchToDefaultContent().Execute();
2055 else if (true == UseSelenium)
2056 this.BrowserDriver.SwitchTo().DefaultContent();
2057
2058 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Returned to main document", CurrentUOW);
2059 return this;
2060 }
2061
2073 {
2074 CurrentUOW.NextPageButton = nextPageButtonselector;
2075 BrowserSettings.CurrentSelector = nextPageButtonselector; // for use by .WithHardware
2076 return this;
2077 }
2078
2085 {
2086 CurrentUOW.PageCount = pageCount;
2087 return this;
2088 }
2089
2096 public IAllowWithPagesAndFetch WithTokensFrom(IGPALGrid<string> inputGrid)
2097 {
2098 CurrentUOW.FetchTokens = inputGrid;
2099 return this;
2100 }
2101
2104 {
2105 List<IGPALGrid<string>> tokenList = FileHelper.TokenizeFile(CurrentUOW, inputFile);
2106
2107 CurrentUOW.FetchTokens = 0 == tokenList?.Count ? null : tokenList[0];
2108 return this;
2109 }
2110
2113 {
2114 DatabaseHelper.TokenizeDatabase(CurrentUOW, inputDatabase);
2115
2116 CurrentUOW.FetchTokens = CurrentUOW.InputDatabase?.Tokens;
2117 return this;
2118 }
2119
2122 {
2123 IGPALGrid<string> grid = GPAL.GridForType<string>();
2124
2125 grid.AddRow(new List<string>(tokens?.Split(',') ?? new string[0]));
2126 CurrentUOW.FetchTokens = grid;
2127 return this;
2128 }
2129
2136 public IAllowAfterAnySelector CallAfterFillIn(Browser.CallAfterFillInDelegate callAfterFillIn)
2137 {
2138 CurrentUOW.CallAfterFillIn = callAfterFillIn;
2139
2140 return this;
2141 }
2142
2152 public IAllowAfterAnySelector CallIfFound(Browser.CallIfDelegate callIfFoundDelegate)
2153 {
2154 CurrentUOW.CallIfFound.Add(callIfFoundDelegate);
2155
2156 return this;
2157 }
2158
2168 public IAllowAfterAnySelector CallIfNotFound(Browser.CallIfDelegate callIfNotFound)
2169 {
2170 CurrentUOW.CallIfNotFound.Add(callIfNotFound);
2171
2172 return this;
2173 }
2174
2188 private HWND WaitForCommonDialog(int waitTimeInSeconds)
2189 {
2190 int owner = BrowserSettings.Process?.Id ?? 0;
2191
2192 return FormHelper.WaitForCommonDialog(owner, waitTimeInSeconds);
2193 }
2194
2201 {
2202 BrowserSettings.WaitForWindowTimeoutInSeconds = waitTimeInSeconds;
2203 return this;
2204 }
2205
2215 {
2216 persistentUOW.CallIfFound.Add(persistentCallIfFound);
2217
2218 return this;
2219 }
2220
2230 {
2231 persistentUOW.CallIfNotFound.Add(persistentCallIfNotFound);
2232
2233 return this;
2234 }
2235
2258 {
2259 BrowserSettings.Credentials = credentials;
2260
2261 return this;
2262 }
2263 public IAllowBrowserSettingsOrGoTo CallOnFail(CallOnFailDelegate callOnFail)
2264 {
2265 BrowserSettings.CallOnFail.Add(callOnFail);
2266
2267 return this;
2268 }
2278 internal CallIfStatus RaiseOnFail(GPALFailure failure, string detail)
2279 {
2280 CallIfStatus handled = CallIfStatus.NotHandled;
2281 List<CallOnFailDelegate> handlers = 0 < BrowserSettings.CallOnFail.Count ? BrowserSettings.CallOnFail : GPAL.GPALSettings.CallOnFailHandlers;
2282
2283 foreach (CallOnFailDelegate handler in handlers)
2284 {
2285 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Invoking CallOnFail [{handler.Method.Name}] for [{failure}]", this, GPALObjectType.Browser);
2286
2287 handled = handler(this, failure, detail);
2288
2289 if (CallIfStatus.Terminate == handled)
2290 {
2291 string str = $"CallOnFail handler [{handler.Method.Name}] requested program termination.";
2292 GPAL.PublishSimpleEvent(GPALEventType.INFO, str, this, GPALObjectType.Browser);
2293 throw new GPALException($"{GPAL.MyMethodName()}: " + str);
2294 }
2295
2296 if (CallIfStatus.Handled == handled)
2297 break;
2298 }
2299
2300 return handled;
2301 }
2313 {
2314 // when would we ever add another persistent uow?
2315 if (true == CurrentUOW.ActionCalled)
2316 {
2317 // Preserve context for new UnitOfWork
2318 var iframeRoot = CurrentUOW.ContextPath;
2319 var shadowRoot = CurrentUOW.ShadowRoot;
2320
2321 CurrentUOW = new UnitOfWork
2322 {
2323 ContextPath = iframeRoot,
2324 ShadowRoot = shadowRoot,
2325 Browser = this
2326 };
2327
2328 persistentUOW = new UnitOfWork() { Browser = this };
2329 }
2330
2331 selector.Browser = this;
2332 selector.DeleteMe = false; // Remove() latches this on and nothing else clears it, so a reused persistent selector would be swept on its first maintenance pass
2333 selector.RemovedMethods.Clear(); // a handler that took itself out of play in an earlier workflow is back in play for this one
2334
2335 ResolveInteractionType(selector);
2336
2337 // if we are reusing, we can't use cached results in a new uow
2338 selector.WebSelectorFoundResults = null;
2339 selector.WebSelectorMatchedResults = null;
2340
2341 persistentUOW.WithSelectorList.Add(selector); // CAVEAT: there is no way to know when this is defined that we are headless, so this selector could be hardware emulaiton which won't work
2342 return this;
2343 }
2344
2345
2352 {
2353 if (true == CurrentUOW.GetGridCalled)
2354 {
2355 CurrentUOW.GetGridCalled = false;
2356 CurrentUOW.HeaderList.Clear();
2357 }
2358 CurrentUOW.HeaderList.Add(header);
2359 return this;
2360 }
2361
2370 public IAllowAfterAnySelectorExceptWithAll WithAllThatMatch(int rowCount = int.MaxValue)
2371 {
2372 // if we haven't started yet, this is called on the browser to set the per browser value
2373 // if called after an action, it's the same thing, it's for the whole browser
2374 if (0 == CurrentUOW.WithSelectorList.Count || true == CurrentUOW.ActionCalled)
2375 BrowserSettings.WithAllThatMatch = rowCount;
2376 else
2377 CurrentUOW.WithAllThatMatch = rowCount;
2378 return this;
2379 }
2380
2388 {
2389 int screenWidth = Puppeteer.GetScreenWidthInt();
2390 int screenHeight = Puppeteer.GetScreenHeightInt();
2391
2392 if (100 > windowSize.Width || 100 > windowSize.Height)
2393 {
2394 int widthToUse = Math.Min(windowSize.Width, 100);
2395 int heightToUse = Math.Min(windowSize.Height, 100);
2396
2397 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Requested browser sizes [{windowSize.Width}x{windowSize.Height}] cannot be less than 100. Using [{widthToUse}x{heightToUse}].", this, GPALObjectType.Browser);
2398
2399 windowSize.Width = widthToUse;
2400 windowSize.Height = heightToUse;
2401 }
2402 else if (screenWidth < windowSize.Width || screenHeight < windowSize.Height)
2403 {
2404 int widthToUse = Math.Min(windowSize.Width, screenWidth);
2405 int heightToUse = Math.Min(windowSize.Height, screenHeight);
2406
2407 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Requested browser size [{windowSize.Width}x{windowSize.Height}] exceeds screen size [{screenWidth}x{screenHeight}]. Using [{widthToUse}x{heightToUse}].", this, GPALObjectType.Browser);
2408
2409 windowSize.Width = widthToUse;
2410 windowSize.Height = heightToUse;
2411
2412 }
2413
2414 BrowserSettings.WindowSize = windowSize;
2415
2416 return this;
2417 }
2418
2424 {
2425 get
2426 {
2427 // set this in the selector by calling it fluently
2428 _ = BrowserSettings.CurrentSelector.WithHardware;
2429 return this;
2430 }
2431 }
2432
2439 {
2440 get
2441 {
2442 // set this in the selector by calling it fluently
2443 _ = BrowserSettings.CurrentSelector.WithJavaScript;
2444 return this;
2445 }
2446 }
2447
2451 public IAllowWithPagesAndGridActions WithInfiniteScroll // TODO: add aprameter ScrolThenScrape, ScrapeThenScroll to indicate how the pave behaves (1 page in mem or all pages)
2452 {
2453 get
2454 {
2455 CurrentUOW.InfiniteScroll = true;
2456 return this;
2457 }
2458 }
2459 #region ACTION GETTERS
2465 {
2466 get
2467 {
2468 InAction(false, false);
2469 // selenium doesn't know what happened
2470 // BrowserHelper.ExecuteJavaScript(this, "return history.back();");
2471 if (true == BrowserSettings.UseOttoMagic)
2472 MagicHelper.Back();
2473 else if (true == BrowserSettings.UsePuppeteer)
2474 PuppeteerClient.Back().Execute();
2475 else
2476 BrowserDriver.Navigate().Back();
2477
2478 return this;
2479 }
2480 }
2481
2486 {
2487 get
2488 {
2489 InAction(false, false);
2490 // selenium doesn't know what happened
2491 // BrowserHelper.ExecuteJavaScript(this, "return history.forward();");
2492 if (true == BrowserSettings.UseOttoMagic)
2493 MagicHelper.Forward();
2494 else if (true == BrowserSettings.UsePuppeteer)
2495 PuppeteerClient.Forward().Execute();
2496 else
2497 BrowserDriver.Navigate().Forward();
2498
2499 return this;
2500 }
2501 }
2502
2507 {
2508 get
2509 {
2510 InAction(false, false);
2511
2512 if (true == BrowserSettings.UseOttoMagic)
2513 {
2514 MagicHelper.Refresh();
2515 Thread.Sleep(500); // for some reason we check document ready before it even refreshes
2516 }
2517 else if (true == UsePuppeteer)
2518 PuppeteerClient.Refresh().Execute();
2519 else
2520 BrowserDriver.Navigate().Refresh();
2521
2522 BrowserHelper.CheckDocumentReady(this);
2523
2524 return this;
2525 }
2526 }
2527
2534 {
2535 get
2536 {
2537 InAction(false, false);
2538
2539 if (true == BrowserSettings.UseOttoMagic)
2540 MagicHelper.PageDown();
2541 else if (true == UsePuppeteer)
2542 PuppeteerClient.PageDown().Execute();
2543 else
2544 ScrollPage("down");
2545
2546 return this;
2547 }
2548 }
2549
2556 {
2557 get
2558 {
2559 InAction(false, false);
2560
2561 if (true == BrowserSettings.UseOttoMagic)
2562 MagicHelper.PageEnd();
2563 else if (true == UsePuppeteer)
2564 PuppeteerClient.PageEnd().Execute();
2565 else
2566 ScrollToPosition("end");
2567
2568 return this;
2569 }
2570 }
2571
2578 {
2579 get
2580 {
2581 InAction(false, false);
2582
2583 if (true == BrowserSettings.UseOttoMagic)
2584 MagicHelper.PageTop(); // Assuming you have a PageUp() method in MagicHelper
2585 else if (true == UsePuppeteer)
2586 PuppeteerClient.PageTop().Execute();
2587 else
2588 ScrollToPosition("top");
2589
2590 return this;
2591 }
2592 }
2593
2600 {
2601 get
2602 {
2603 InAction(false, false);
2604
2605 if (true == BrowserSettings.UseOttoMagic)
2606 MagicHelper.PageUp();
2607 else if (true == UsePuppeteer)
2608 PuppeteerClient.PageUp().Execute();
2609 else
2610 ScrollPage("up");
2611
2612 return this;
2613 }
2614 }
2615
2619 {
2620 get
2621 {
2622 BrowserSettings.FullScreen = false;
2623 BrowserSettings.Maximize = false;
2624 BrowserSettings.Minimize = false;
2625
2626 //InAction(false, false);
2627
2628 if (null != BrowserSettings.Process || 0 != BrowserSettings.ServiceDriverPid)
2629 if (true == BrowserSettings.UseOttoMagic)
2630 {
2631 if (true == BrowserSettings.FullScreen)
2632 MagicHelper.Normal();
2633 else
2634 MagicHelper.Restore();
2635 }
2636 else if (true == BrowserSettings.UsePuppeteer)
2637 {
2638 if (true == BrowserSettings.FullScreen)
2639 PuppeteerClient.Normal().Execute();
2640 else
2641 PuppeteerClient.Restore().Execute();
2642 }
2643 else if (null != BrowserSettings.BrowserDriver)
2644 BrowserSettings.BrowserDriver.Manage().Window.Size = BrowserSettings.SavedWindowSize;
2645
2646 return this;
2647 }
2648 }
2649
2654 {
2655 get
2656 {
2657 BrowserSettings.Minimize = false;
2658 BrowserSettings.Maximize = false;
2659 BrowserSettings.FullScreen = true;
2660
2661 //InAction(false, false);
2662
2663 if (null != BrowserSettings.Process || 0 != BrowserSettings.ServiceDriverPid)
2664 if (true == BrowserSettings.UseOttoMagic)
2665 {
2666 BrowserSettings.SavedWindowSize = new Size(MagicHelper.WindowOuterWidth(), MagicHelper.WindowOuterHeight());
2667 MagicHelper.FullScreen();
2668 }
2669 else if (true == BrowserSettings.UsePuppeteer)
2670 {
2671 BrowserSettings.SavedWindowSize = new Size(PuppeteerClient.WindowOuterWidth().Execute<int>(), PuppeteerClient.WindowOuterHeight().Execute<int>());
2672 MagicHelper.FullScreen();
2673 }
2674 else if (null != BrowserSettings.BrowserDriver)
2675 {
2676 BrowserSettings.SavedWindowSize = BrowserSettings.BrowserDriver.Manage().Window.Size;
2677 BrowserSettings.BrowserDriver?.Manage().Window.FullScreen();
2678 }
2679
2680 return this;
2681 }
2682 }
2683
2687 {
2688 get
2689 {
2690 BrowserSettings.Maximize = true;
2691 BrowserSettings.Minimize = false;
2692 BrowserSettings.FullScreen = false;
2693
2694 //InAction(false, false);
2695
2696 if (null != BrowserSettings.Process || 0 != BrowserSettings.ServiceDriverPid)
2697 if (true == BrowserSettings.UseOttoMagic)
2698 {
2699 if (0 == BrowserSettings.WindowSize.Width && 0 == BrowserSettings.WindowSize.Height)
2700 BrowserSettings.SavedWindowSize = new Size(MagicHelper.WindowOuterWidth(), MagicHelper.WindowOuterHeight());
2701 MagicHelper.Maximize();
2702 }
2703 else if (true == BrowserSettings.UsePuppeteer)
2704 {
2705 if (0 == BrowserSettings.WindowSize.Width && 0 == BrowserSettings.WindowSize.Height)
2706 BrowserSettings.SavedWindowSize = new Size(PuppeteerClient.WindowOuterWidth().Execute<int>(), PuppeteerClient.WindowOuterHeight().Execute<int>());
2707 PuppeteerClient.Maximize().Execute();
2708 }
2709 else if (null != BrowserSettings.BrowserDriver)
2710 {
2711 // get current window size if one was not defined on startup
2712 BrowserSettings.SavedWindowSize = BrowserSettings.BrowserDriver.Manage().Window.Size;
2713 BrowserSettings.BrowserDriver?.Manage().Window.Maximize();
2714 }
2715
2716 return this;
2717 }
2718 }
2719
2723 {
2724 get
2725 {
2726 BrowserSettings.Minimize = true;
2727 BrowserSettings.Maximize = false;
2728 BrowserSettings.FullScreen = false;
2729
2730 //InAction(false);
2731
2732 if (null != BrowserSettings.Process || 0 != BrowserSettings.ServiceDriverPid)
2733 if (true == BrowserSettings.UseOttoMagic)
2734 {
2735 BrowserSettings.SavedWindowSize = new Size(MagicHelper.WindowOuterWidth(), MagicHelper.WindowOuterHeight());
2736 MagicHelper.Minimize();
2737 }
2738 else if (true == BrowserSettings.UsePuppeteer)
2739 {
2740 BrowserSettings.SavedWindowSize = new Size(PuppeteerClient.WindowOuterWidth().Execute<int>(), PuppeteerClient.WindowOuterHeight().Execute<int>());
2741 PuppeteerClient.Minimize().Execute();
2742 }
2743 else if (null != BrowserSettings.BrowserDriver)
2744 {
2745 // get current window size if one was not defined on startup
2746 BrowserSettings.SavedWindowSize = BrowserSettings.BrowserDriver.Manage().Window.Size;
2747 BrowserSettings.BrowserDriver?.Manage().Window.Minimize();
2748 }
2749
2750 return this;
2751 }
2752 }
2753 #endregion ACTION GETTERS
2754 #endregion <Browser Settings>
2755 #region <Browser Actions>
2761 public IAllowBrowserSettingsOrGoTo WithObeyRobotsTxt(bool trueFalse = false)
2762 {
2763 BrowserSettings.ObeyRobotsTxt = trueFalse;
2764 return this;
2765 }
2766
2772 {
2773 BrowserSettings.RespectRobotMetaTags = trueFalse;
2774 return this;
2775 }
2776
2784 {
2785 try
2786 {
2787 BrowserSettings.UseHeadless = true;
2788
2789 // configure headless options
2790 BrowserSettings.OpenPDFExternally = false; // these two settings will download any pdf file to the specified "download.default_directory" (see WithDownloadDirectory and SetDownloadFilename)
2791 BrowserSettings.PromptForDownload = false;
2792
2793 BrowserSettings.OpenInTab = false;
2794 GoTo(URL);
2795 }
2796 catch (GPALException)
2797 {
2798 throw;
2799 }
2800 catch (Exception ex)
2801 {
2802 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to GET from [{URL}]", this, GPALObjectType.Browser, ex);
2803 }
2804
2805 return this;
2806 }
2807
2816 {
2817 bool firstRunSave = firstRun;
2818
2819 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Visiting [{URL?.Url}]", this, GPALObjectType.Browser);
2820
2821 if (BrowserType.FireFox == BrowserSettings.BrowserType &&
2822 (AutomationEngine.PuppeteerPort == AutomationEngine || AutomationEngine.PuppeteerPortHW == AutomationEngine ||
2823 AutomationEngine.PuppeteerPipe == AutomationEngine || AutomationEngine.PuppeteerPipeHW == AutomationEngine))
2824 {
2825 AutomationEngine steppedDownTo = FirefoxStepDown(BrowserSettings.AutomationEngine);
2826
2827 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Firefox cannot be driven with [{BrowserSettings.AutomationEngine}], so this browser runs on [{steppedDownTo}]", this, GPALObjectType.Browser);
2828
2829 AutomationEngine = steppedDownTo;
2830 }
2831
2832 URL.ForUrl(MagicHelper.GetFullUrl(URL?.Url, this, out _areRobotsAllowed));
2833
2834 if (false == AreRobotsAllowed && true == BrowserSettings.ObeyRobotsTxt)
2835 {
2836 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Visiting [{URL?.Url}] is disallowed by robots.txt and your request to honor it. Not going to URL. Workflow will fail.", this, GPALObjectType.Browser);
2837 URL.ForUrl("https://google.com");
2838 }
2839
2840 // where we were, so a navigation that turns out to be a download rather than a page can put both the
2841 // browser and what we say about it back
2842 string priorUrl = BrowserSettings.CurrentURL;
2843 GPALUrl priorGPALUrl = BrowserSettings.CurrentGPALUrl;
2844 TabTuple priorTab = true == UseOttoMagic ? new TabTuple(GetActiveTabId(), priorUrl) : null;
2845 string priorHandle = null; // the tab we are leaving, by id, filled in only when we open a new one
2846 int tabsBefore = -1;
2847
2848 BrowserSettings.CurrentURL = URL?.Url;
2849 BrowserSettings.CurrentGPALUrl = URL;
2850
2851 // keep this in, new page, new uow
2852 CurrentUOW = new UnitOfWork() { Browser = this }; // so an immediate .inshadowdom or .inframe starts new and doesn't carry forward
2853
2854 // DO NOT PASS IN URL for first run startup, it doesn't like it in headless mode puppeteer
2855 if (true == InAction(false, false, null)) // actions have ended but we do have to signal how to denote a new UOW, too
2856 {
2857 try
2858 {
2859 if (true == BrowserSettings.WaitOnNetworkIdle && true == UseSelenium)
2860 ApplyNetworkMonitor();
2861
2862 CheckAndApplyStealth();
2863 SetReferrer();
2864 SetUserAgent();
2865
2866 // said once, before the first navigation, because every mechanism that carries a credential
2867 // holds for the session: a header keeps being sent, and a challenge the browser has already
2868 // answered stays answered for that origin
2869 if (null != BrowserSettings.Credentials && null == BrowserSettings.CredentialsPresented)
2870 BrowserHelper.PresentCredentials(this, URL);
2871
2872 BrowserHelper.CheckDocumentReady(this);
2873
2874 // do we have an open browser? no
2875 if ((null == BrowserLaunched && false == BrowserSettings.UseExistingBrowser) || false == BrowserSettings.OpenInTab)
2876 {
2877
2878 if (true == UseOttoMagic)
2879 {
2880 //if (false == firstRunSave) // url passed on command line
2881 {
2882 //MagicHelper.InMainDom();
2883 TabTuple tabTuple = MagicHelper.GoTo(URL);
2884 UpdateActiveTab(tabTuple);
2885 }
2886 //else
2887 // UpdateActiveTab(MagicHelper.NextTab()); // get the active tab tuple, no other tabs, will return current
2888 }
2889 else if (true == UsePuppeteer)
2890 {
2891 PuppeteerClient.SwitchToDefaultContent().Execute();
2892 //if (false == firstRunSave) // url passed on command line
2893 PuppeteerClient.GoTo(URL).Execute();
2894 }
2895 else
2896 {
2897 BrowserDriver.SwitchTo().DefaultContent();
2899 }
2900 if (null == BrowserLaunched)
2901 {
2902 BrowserLaunched = DateTime.Now;
2903 tabsWeOpened++;
2904 }
2905 }
2906 else
2907 {
2908 // what the browser has before we add ours, so we can tell afterwards whether ours survived
2909 tabsBefore = RealTabCount();
2910 priorHandle = true == UseSelenium ? BrowserDriver.CurrentWindowHandle : null; // to get back by id, not by position
2911 tabsWeOpened++;
2912 if (true == UseOttoMagic)
2913 {
2914 TabTuple tabTuple = MagicHelper.NewTab(URL);
2915 if (true == string.IsNullOrEmpty(tabTuple?.Url))
2916 tabTuple.Url = URL;
2917 GoToTab(tabTuple);
2918 }
2919 else if (true == UsePuppeteer)
2920 {
2921 // NewTab makes the tab it opened the active one, so moving to the next tab from here
2922 // moves off it: with two tabs it wraps back to the workflow's own, which then gets
2923 // navigated and, when the caller closes the tab it thinks it opened, closed
2924 PuppeteerClient.NewTab().Execute();
2925 PuppeteerClient.GoTo(URL).Execute();
2926 }
2927 else
2928 {
2929 BrowserDriver.SwitchTo().NewWindow(WindowType.Tab);
2930 RecordTabWindow(BrowserDriver.CurrentWindowHandle, priorHandle); // a new tab of the window we were in
2931 //_ = NextTab;
2933 }
2934 }
2935
2936 // the browser saying the page never loaded. Selenium leaves ServerResponseCode at -1, puppeteer
2937 // answers Page.navigate with an errorText, and OttoMagic reports it through get-ready-status,
2938 // which is where its own raise happens. GPAL fails forward from here either way: the workflow
2939 // carries on against a page that is not there, its selectors find nothing and CallIfNotFound
2940 // fires, unless a CallOnFail handler decides otherwise
2941 bool navigationFailed = (true == UseSelenium && -1 == BrowserSettings.ServerResponseCode)
2942 || (true == UsePuppeteer && false == string.IsNullOrEmpty(PuppeteerCommunicator.LastNavigationError));
2943
2944 if (true == UseSelenium && -1 == BrowserSettings.ServerResponseCode)
2945 RaiseOnFail(GPALFailure.Navigation, $"[{URL?.Url}] did not load.");
2946 else if (true == UsePuppeteer && false == string.IsNullOrEmpty(PuppeteerCommunicator.LastNavigationError))
2947 RaiseOnFail(GPALFailure.Navigation, $"[{URL?.Url}] did not load. [{PuppeteerCommunicator.LastNavigationError}]");
2948
2949 // a url that downloads gives back no page at all, and a tab opened for one has no document to
2950 // fall back on, so the browser disposes it and tells nobody. Only the difference between the
2951 // two readings is trusted, which leaves any tab we did not open sitting in both and cancelling
2952 if (-1 != tabsBefore && RealTabCount() == tabsBefore)
2953 BackOutOfSelfClosedTab(URL, priorUrl, priorGPALUrl, priorTab, priorHandle);
2954 else
2955 {
2956 BrowserHelper.CheckDocumentReady(this);
2957
2958 // use shortcuiting, if it's not set, why check?
2959 if (true == BrowserSettings.RespectRobotMetaTags && false == UrlHelper.CheckRobotMetaTags(URL, this))
2960 {
2961 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Visiting [{BrowserSettings.CurrentURL}] is disallowed by robot meta tags and your request to honor them..");
2962 URL.ForUrl("https://google.com");
2963
2964 if (true == UseOttoMagic)
2965 {
2966 //MagicHelper.InMainDom();
2967 TabTuple tabTuple = MagicHelper.GoTo(URL);
2968 UpdateActiveTab(tabTuple);
2969 }
2970 else if (true == UsePuppeteer)
2971 {
2972 PuppeteerClient.SwitchToDefaultContent().Execute();
2973 PuppeteerClient.GoTo(URL).Execute();
2974 }
2975 else
2976 {
2977 BrowserDriver.SwitchTo().DefaultContent();
2979 }
2980 }
2981 else
2982 {
2983 BrowserSettings.CurrentURL = URL.Url;
2984
2985 // RaiseOnFail says nothing of its own when no CallOnFail handler is registered, so
2986 // without this a navigation that ended on the browser's error page read as a load
2987 if (true == navigationFailed)
2988 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{BrowserSettings.CurrentURL}] did not load. Continuing against whatever the browser is showing.");
2989 else
2990 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{BrowserSettings.CurrentURL}] loaded.");
2991 }
2992
2993 BrowserSettings.GPALUrlList.Add(URL);
2994
2995 // there is a document now, which is what the dialog override needs before it can be put
2996 // on the page in front of us rather than only on the ones still to load
2997 _navigated = true;
2998 }
2999 }
3000 catch (GPALException)
3001 {
3002 throw;
3003 }
3004 catch (Exception ex)
3005 {
3006 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to navigate to [{URL?.Url}]", this, GPALObjectType.Browser, ex);
3007 }
3008
3009 //int retries = 3;
3010
3011 //if (false == URL.Contains(GetSetCurrentUrl()) && 0 < retries--)
3012 // Thread.Sleep(500);
3013
3014 BrowserSettings.OpenInTab = false; // .WithNewTab applies only once, so it must appear before each .GoTo
3015 }
3016 else // we had a driver initialization falure
3017 throw new GPALException("Driver initialization error. Please see mesages.");
3018
3019 return this;
3020 }
3021
3025 private void ApplyGoogleReferrer()
3026 {
3027 bool applied = true;
3028
3029 if (true == UseOttoMagic)
3031 else if (true == UsePuppeteer)
3032 PuppeteerCommunicator.SetGoogleReferrer().GetAwaiter().GetResult();
3033 else if (BrowserType.FireFox != BrowserSettings.BrowserType)
3034 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Network.setExtraHTTPHeaders", new Dictionary<string, object>
3035 {
3036 {
3037 "headers", new Dictionary<string, object>
3038 {
3039 { "Referer", "https://www.google.com/" } // NOTE: the referrer misspelling is baked into the http/1.0 protocol spec
3040 }
3041 }
3042 });
3043 else
3044 applied = false;
3045
3046 if (true == applied)
3047 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Stealth Google Referrer applied.", this, GPALObjectType.Browser);
3048 else
3049 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{BrowserSettings.BrowserType}] on [{BrowserSettings.AutomationEngine}] cannot set a header, so the Google referrer was not applied", this, GPALObjectType.Browser);
3050 }
3055 private void ApplyNetworkMonitor()
3056 {
3057 string networkMonitorScript = @"
3058 (function() {
3059 if (!window.__gpalNetworkMonitor) {
3060 window.__gpalNetworkMonitor = { inflight: 0 };
3061
3062 const origOpen = XMLHttpRequest.prototype.open;
3063 XMLHttpRequest.prototype.open = function() {
3064 this.addEventListener('loadstart', () => { window.__gpalNetworkMonitor.inflight++; });
3065 this.addEventListener('loadend', () => { window.__gpalNetworkMonitor.inflight--; });
3066 origOpen.apply(this, arguments);
3067 };
3068
3069 const origFetch = window.fetch;
3070 window.fetch = function(...args) {
3071 window.__gpalNetworkMonitor.inflight++;
3072 return origFetch(...args).finally(() => { window.__gpalNetworkMonitor.inflight--; });
3073 };
3074 }
3075 })();
3076 ";
3077
3078 if (UsePuppeteer)
3079 {
3080 PuppeteerCommunicator.EnableFetchScriptInjection(networkMonitorScript, null)
3081 .GetAwaiter().GetResult();
3082 }
3083 else if (BrowserType.FireFox == BrowserSettings.BrowserType)
3084 {
3085 // Firefox has no CDP preload script support — inject post-load via ExecuteScript instead.
3086 // The guard (function(){if(!window.__gpalNetworkMonitor)...}) makes re-injection safe on each navigation.
3087 ((OpenQA.Selenium.IJavaScriptExecutor)BrowserDriver).ExecuteScript(networkMonitorScript);
3088 }
3089 else
3090 {
3091 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand(
3092 "Page.addScriptToEvaluateOnNewDocument",
3093 new Dictionary<string, object>
3094 {
3095 { "source", networkMonitorScript }
3096 }
3097 );
3098 }
3099
3100 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Network idle monitor script injected.", this, GPALObjectType.Browser);
3101 }
3106 private void ApplyCDPProtection()
3107 {
3108 // hide CDP port detection by adding a script that will always run when a page loads (before the website loads any detection scripts)
3109 // https://www.reddit.com/r/webscraping/comments/1evht3i/help_in_bypassing_cdp_detection/
3110 // https://github.com/rebrowser/rebrowser-patches
3111 // https://github.com/kaliiiiiiiiii/brotector/blob/master/brotector.js#L377 // string detections
3112 string antiAntiBotScript = @"
3113 (function() {
3114 var originalError = Error;
3115 Object.defineProperty(Error.prototype, 'stack', {
3116 configurable: false,
3117 enumerable: true,
3118 writable: true,
3119 value: (function() {
3120 try {
3121 throw new originalError();
3122 } catch (e) {
3123 return e.stack;
3124 }
3125 })()
3126 });
3127
3128 window.Error = new Proxy(originalError, {
3129 construct(target, args) {
3130 var instance = new target(...args);
3131 return Object.freeze(instance);
3132 }
3133 });
3134 })();
3135 ";
3136
3137 if (true == UsePuppeteer)
3138 {
3139 PuppeteerCommunicator.EnableFetchScriptInjection(antiAntiBotScript, null).GetAwaiter().GetResult();
3140 }
3141 else if (true == UseSelenium && BrowserType.FireFox != BrowserSettings.BrowserType)
3142 {
3143 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Page.addScriptToEvaluateOnNewDocument", new Dictionary<string, object>
3144 {
3145 { "source", antiAntiBotScript }
3146 });
3147 }
3148
3149 string antiAntiBotScript2 = "Object.defineProperty(navigator, 'webdriver', { get: () => undefined })";
3150 if (true == UseSelenium && BrowserType.FireFox != BrowserSettings.BrowserType)
3151 {
3152 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Page.addScriptToEvaluateOnNewDocument", new Dictionary<string, object>
3153 {
3154 { "source", antiAntiBotScript2 }
3155 });
3156 }
3157 else if (true == UsePuppeteer)
3158 {
3159 PuppeteerCommunicator.EnableFetchScriptInjection(antiAntiBotScript2, null).GetAwaiter().GetResult();
3160 }
3161
3162 // both injections above take Puppeteer or Selenium on a chromium browser, so anything else was skipped
3163 bool applied = true == UsePuppeteer || (true == UseSelenium && BrowserType.FireFox != BrowserSettings.BrowserType);
3164
3165 if (true == applied)
3166 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Stealth CDP protection applied.", this, GPALObjectType.Browser);
3167 else
3168 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{BrowserSettings.BrowserType}] on [{BrowserSettings.AutomationEngine}] cannot run a script on every new document, so CDP protection was not applied", this, GPALObjectType.Browser);
3169 }
3176 private void ApplyToStringOverride()
3177 {
3178 string toStringOverrideScript = @"
3179 (function() {
3180 // Store the original toString
3181 const originalToString = Object.prototype.toString;
3182
3183 // Create a proxy handler to intercept toString calls
3184 const handler = {
3185 apply: function(target, thisArg, argumentsList) {
3186 if (thisArg instanceof Function && /antiAntiBotScript|CDP/.test(originalToString.call(thisArg))) {
3187 return 'function () { [native code] }';
3188 }
3189 return target.apply(thisArg, argumentsList);
3190 }
3191 };
3192
3193 // Wrap toString with a proxy
3194 Object.prototype.toString = new Proxy(originalToString, handler);
3195
3196 console.log('toString override applied via Proxy');
3197 })();
3198 ";
3199
3200 if (true == UseSelenium && BrowserType.FireFox != BrowserSettings.BrowserType)
3201 {
3202 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Page.addScriptToEvaluateOnNewDocument", new Dictionary<string, object>
3203 {
3204 { "source", toStringOverrideScript }
3205 });
3206 }
3207 else if (true == UsePuppeteer)
3208 {
3209 PuppeteerCommunicator.EnableFetchScriptInjection(toStringOverrideScript, null).GetAwaiter().GetResult();
3210 }
3211
3212 // the injection above takes Puppeteer or Selenium on a chromium browser, so anything else was skipped
3213 bool applied = true == UsePuppeteer || (true == UseSelenium && BrowserType.FireFox != BrowserSettings.BrowserType);
3214
3215 if (true == applied)
3216 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Stealth toString override applied.", this, GPALObjectType.Browser);
3217 else
3218 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{BrowserSettings.BrowserType}] on [{BrowserSettings.AutomationEngine}] cannot run a script on every new document, so the toString override was not applied", this, GPALObjectType.Browser);
3219 }
3224 private void CheckAndApplyStealth()
3225 {
3226 // hide CDP port detection by adding a script that will always run when a page loads (before the website loads any detection scripts)
3227 // https://www.reddit.com/r/webscraping/comments/1evht3i/help_in_bypassing_cdp_detection/
3228 // https://github.com/rebrowser/rebrowser-patches
3229 // https://github.com/kaliiiiiiiiii/brotector/blob/master/brotector.js#L377 // string detections
3230
3231 if (false == BrowserSettings.StealthApplied)
3232 {
3233 if (false == UseOttoMagic && BrowserType.FireFox != BrowserType)
3234 {
3235 if (BrowserSettings.StealthType.HasFlag(StealthType.CDP))
3236 ApplyCDPProtection();
3237
3238 if (BrowserSettings.StealthType.HasFlag(StealthType.ToStringOverride))
3239 ApplyToStringOverride();
3240 }
3241
3242 if (BrowserSettings.StealthType.HasFlag(StealthType.GoogleReferrer))
3243 {
3244 // set referer to google to make us look like we are coming in from search results click
3245 try
3246 {
3247 ApplyGoogleReferrer();
3248 }
3249 catch (GPALException)
3250 {
3251 throw;
3252 }
3253 catch (Exception ex)
3254 {
3255 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Google Referrer stealth skipped", this, GPALObjectType.Browser, ex);
3256 }
3257 }
3258 }
3259 BrowserSettings.StealthApplied = true;
3260 }
3261
3266 public IAllowBrowserActionOrAnySelector CloseTab(dynamic URLorTabId = null)
3267 {
3268 // special use case closing a tab; can't call previoustab because we aren't in a good window yet and it will try to top
3269 if (true == UsePuppeteer)
3270 {
3271 if (URLorTabId is string s)
3272 PuppeteerClient.CloseTab(s).Execute();
3273 else if (URLorTabId is GPALUrl url)
3274 PuppeteerClient.CloseTab(url).Execute();
3275 else if (URLorTabId is int i)
3276 PuppeteerClient.CloseTab(i).Execute();
3277 else
3278 PuppeteerClient.CloseTab().Execute();
3279 }
3280 else if (true == UseOttoMagic)
3281 {
3282 string currentUrl = BrowserSettings.CurrentURL;
3283
3284 int currentTabId = GetActiveTabId();
3285 TabTuple tabTuple = MagicHelper.CloseTab(URLorTabId); // tabtuple is new active tab
3286
3287 if (true == string.IsNullOrEmpty(URLorTabId))
3288 {
3289 BrowserSettings.TabIdsToURL.Remove(currentTabId);
3290 BrowserSettings.URLsToTabId.Remove(currentUrl);
3291 }
3292 else if (URLorTabId is int tabId)
3293 {
3294 BrowserSettings.URLsToTabId.Remove(BrowserSettings.TabIdsToURL[tabId].URL);
3295 BrowserSettings.TabIdsToURL.Remove(tabId);
3296 }
3297 else if (URLorTabId is string url)
3298 {
3299 BrowserSettings.URLsToTabId.Remove(url);
3300 BrowserSettings.TabIdsToURL.Remove(currentTabId);
3301 }
3302
3303 UpdateActiveTab(tabTuple);
3304 }
3305 else
3306 {
3307 if (true == string.IsNullOrWhiteSpace(URLorTabId))
3308 {
3309 ResolveGhostTabHandle(); // ensure Edge's phantom tab is known before we pick a tab to land on
3310
3311 // work out where we are going BEFORE closing, so the handle list is the one that still has
3312 // this tab in it and the index means what it says
3313 string closing = null;
3314 string landOn = null;
3315
3316 try
3317 {
3318 var before = BrowserDriver.WindowHandles;
3319 closing = BrowserDriver.CurrentWindowHandle;
3320
3321 // the tab to the left of the one closing, skipping Edge's ghost, else anything that is not us
3322 int closingIdx = before.IndexOf(closing);
3323 for (int idx = closingIdx - 1; 0 <= idx && null == landOn; idx--)
3324 if (before[idx] != GhostTabHandle)
3325 landOn = before[idx];
3326
3327 if (null == landOn)
3328 landOn = before.FirstOrDefault(h => h != closing && h != GhostTabHandle);
3329 }
3330 catch (Exception exRead)
3331 {
3332 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CloseTab: could not read the tab list before closing, will fall back to whatever the driver leaves us on.", this, GPALObjectType.Browser, exRead);
3333 }
3334
3335 // guarded: a flaky/off-screen EdgeDriver can hang the full 20s command timeout here and throw,
3336 // which must not crash the workflow - the tab is being torn down regardless.
3337 try { BrowserSettings.BrowserDriver.Close(); } // close current tab
3338 catch (Exception exClose) { GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CloseTab: driver failed to close the current tab, continuing.", this, GPALObjectType.Browser, exClose); }
3339
3340 // closing the last tab leaves nothing to land on, which is a legitimate end of the workflow
3341 if (false == string.IsNullOrEmpty(landOn))
3342 try
3343 {
3344 BrowserDriver.SwitchTo().Window(landOn);
3345 CurrentTabIdx = BrowserDriver.WindowHandles.IndexOf(landOn); // re-seat against the list we actually have now
3346
3347 }
3348 catch (Exception exSwitch)
3349 {
3350 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CloseTab: could not switch to tab [{landOn}] after closing.", this, GPALObjectType.Browser, exSwitch);
3351 }
3352 }
3353 else // close a tab with a particular url (we don't use handles on selenium side)
3354 {
3355 // the tab we are on now, so closing some other tab does not move the workflow. FindTab walks
3356 // the tabs to read their urls, so where the driver ends up afterwards is not where it started
3357 string staying = BrowserDriver.CurrentWindowHandle;
3358 var handle = BrowserHelper.FindTab(BrowserSettings, URLorTabId);
3359
3360 // no tab is sitting on that url any more, which happens whenever the tab has navigated on, a
3361 // download being the obvious case. Selenium answers a null handle with "'handle' must be a
3362 // string" and takes the workflow down, so say what happened instead
3363 if (true == string.IsNullOrEmpty(handle))
3364 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CloseTab: no tab is on [{URLorTabId}], nothing closed. Call .CloseTab() with no url to close the current tab.", this, GPALObjectType.Browser);
3365 else
3366 {
3367 BrowserSettings.BrowserDriver.SwitchTo().Window(handle);
3368
3369 // guarded for the same reason as the no-url case above
3370 try { BrowserSettings.BrowserDriver.Close(); }
3371 catch (Exception exClose) { GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CloseTab: driver failed to close tab [{URLorTabId}], continuing.", this, GPALObjectType.Browser, exClose); }
3372
3373 // go back to where the workflow was. Closing the tab we were on ourselves leaves nothing to
3374 // go back to, so take the last one open, and closing the only tab leaves nothing at all
3375 var after = BrowserDriver.WindowHandles;
3376 string landOn = true == after.Contains(staying) ? staying : after.LastOrDefault(h => h != GhostTabHandle);
3377
3378 if (false == string.IsNullOrEmpty(landOn))
3379 {
3380 BrowserDriver.SwitchTo().Window(landOn);
3381 CurrentTabIdx = after.IndexOf(landOn);
3382
3383 }
3384 }
3385 }
3386 }
3387
3388 // did that leave us anything to be on? ask the browser rather than reading our own tally, which cannot
3389 // see tabs a page opened or closed by itself. Firefox answers 1 here, it keeps the browser alive on an
3390 // about:blank tab, where chrome and edge answer 0 or have already torn the session down
3391 int tabsLeft = 0;
3392
3393 try
3394 {
3395 tabsLeft = RealTabCount();
3396
3397 if (0 < tabsLeft)
3398 GetSetCurrentUrl();
3399 }
3400 catch (Exception ex)
3401 {
3402 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Browser stopped answering after closing the tab.", this, GPALObjectType.Browser, ex);
3403 }
3404
3405 if (0 == tabsLeft)
3406 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Closed the last tab, the browser closed with it.", this, GPALObjectType.Browser);
3407
3408 tabsWeOpened--;
3409
3410 //CheckPersistentSelectors();
3411
3412 return this;
3413 }
3414
3424 private List<string> TabsInThisWindow(IList<string> handles, string current)
3425 {
3426 List<string> siblings = new List<string>();
3427
3428 // the tab we are on decides which window we are talking about, and it is its own window if this is the
3429 // first we have heard of it
3430 if (false == tabWindows.ContainsKey(current))
3431 tabWindows[current] = current;
3432
3433 string thisWindow = tabWindows[current];
3434
3435 foreach (string handle in handles)
3436 {
3437 if (false == tabWindows.ContainsKey(handle))
3438 tabWindows[handle] = thisWindow;
3439
3440 if (tabWindows[handle] == thisWindow && handle != GhostTabHandle)
3441 siblings.Add(handle);
3442 }
3443
3444 return siblings;
3445 }
3452 private void RecordTabWindow(string newHandle, string openedFrom)
3453 {
3454 if (true == string.IsNullOrEmpty(newHandle))
3455 return;
3456
3457 if (false == string.IsNullOrEmpty(openedFrom) && true == tabWindows.ContainsKey(openedFrom))
3458 tabWindows[newHandle] = tabWindows[openedFrom];
3459 else
3460 tabWindows[newHandle] = string.IsNullOrEmpty(openedFrom) ? newHandle : openedFrom;
3461 }
3469 internal string ResolveGhostTabHandle()
3470 {
3471 // only Selenium switches by handle, and only Edge injects the preload ghost
3472 if (false == UseSelenium || BrowserType.Edge != BrowserSettings.BrowserType || true == string.IsNullOrEmpty(GhostTabUrl))
3473 return GhostTabHandle;
3474
3475 // already found - cached for the life of the session
3476 if (false == string.IsNullOrEmpty(GhostTabHandle))
3477 return GhostTabHandle;
3478
3479 var handles = BrowserDriver.WindowHandles;
3480
3481 // nothing foreign present: no more open handles than tabs we opened ourselves
3482 if (handles.Count <= tabsWeOpened)
3483 return null;
3484
3485 string current;
3486 try { current = BrowserDriver.CurrentWindowHandle; }
3487 catch { return null; }
3488
3489 foreach (string handle in handles)
3490 {
3491 if (handle == current) continue; // the tab we are on is never the ghost
3492
3493 string handleUrl = null;
3494 try
3495 {
3496 BrowserDriver.SwitchTo().Window(handle);
3497 handleUrl = BrowserDriver.Url;
3498 }
3499 catch { /* can't read it, skip */ }
3500 finally { try { BrowserDriver.SwitchTo().Window(current); } catch { } }
3501
3502 // positive identity match against the launch signature - a real tab will not match.
3503 // Match on the base url (scheme+host+path) only: Edge's launch url and the settled ghost url
3504 // share the same NTP path but differ in query (prerender=1 vs startpage=1, space encoding, an
3505 // extra invalidaterender token), so a full-url compare would never match.
3506 if (false == string.IsNullOrEmpty(handleUrl) && true == UrlHelper.SameBaseUrl(handleUrl, GhostTabUrl))
3507 {
3508 GhostTabHandle = handle;
3509 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Ghost tab found, will always skip [{handleUrl}]", this, GPALObjectType.Browser);
3510 break;
3511 }
3512 }
3513
3514 return GhostTabHandle;
3515 }
3532 {
3533 get
3534 {
3535 // due to clicks opening new tabs and other reasons, we can't track open tab count unless we have our own browser extension (can't do it in javascript, only extension)
3536
3537 // browser will be topped below by selenium when we switch tabs
3538 // BrowserHelper.TopBrowser(this);
3539
3540 // next tab - control page-down
3541 // when we move away from selenium, we may use this
3542 // HardwareHelper.SendKey(GPAL.VK_NEXT, ModifierKeys.Control); // page down
3543
3544 // using selenium, it must know which tab we are on to function properly, it doesn't know we switched tabs
3545 if (true == UseOttoMagic)
3546 {
3547 TabTuple tabTuple = MagicHelper.NextTab();
3548 UpdateActiveTab(tabTuple);
3549 }
3550 else if (true == UsePuppeteer)
3551 {
3552 PuppeteerClient.NextTab().Execute();
3553 }
3554 else
3555 {
3556 ResolveGhostTabHandle(); // Edge injects a phantom NTP handle; identify it so we never land on it
3557
3558 var handles = BrowserDriver.WindowHandles;
3559 string current = BrowserDriver.CurrentWindowHandle;
3560
3561 // cycle the tabs of the window we are in, not the driver's flat list of every tab in every
3562 // window. Ask the driver where we are rather than remembering it: a remembered position points
3563 // at the wrong tab the moment any tab opens or closes
3564 List<string> siblings = TabsInThisWindow(handles, current);
3565
3566 int idx = siblings.IndexOf(current);
3567 if (0 > idx) idx = 0;
3568
3569 idx = (idx + 1) % siblings.Count;
3570
3571 CurrentTabIdx = handles.IndexOf(siblings[idx]); // reported to the workflow, never read back as our position
3572
3573 BrowserDriver.SwitchTo().Window(siblings[idx]);
3574 if (BrowserDriver.CurrentWindowHandle != siblings[idx])
3575 BrowserDriver.SwitchTo().Window(siblings[idx]);
3576
3577 }
3578
3579 Thread.Sleep(50);
3580 //BrowserHelper.CheckDocumentReady(this, true); // check document.ready
3581
3582 // get set url, set gpalcurrenturl
3583 // NOTE: add search gpalurllist and setting gpalcurrenturl
3584
3585 return this;
3586 }
3587 }
3588
3605 {
3606 BrowserSettings.OpenInTab = true;
3607 BrowserSettings.Browser.GoTo(url); // will set currentgpalurl and add url to list
3608 return this;
3609 }
3610
3625 {
3626 get
3627 {
3628 // due to clicks opening new tabs and other reasons, we can't track open tab count unless we have our own browser extension (can't do it in javascript, only extension)
3629
3630 // browser will be topped below by selenium when we switch tabs
3631 // BrowserHelper.TopBrowser(this);
3632
3633 // previous tab using ctrl-page up
3634 // when we move away from selenium, we may use this
3635 // HardwareHelper.SendKey(GPAL.VK_PRIOR, ModifierKeys.Control); // page up
3636
3637 // using selenium, it must know which tab we are on to function properly, it doesn't know we switched tabs above
3638 if (true == UseOttoMagic)
3639 {
3640 UpdateActiveTab(MagicHelper.PreviousTab());
3641 }
3642 else if (true == UsePuppeteer)
3643 {
3644 PuppeteerClient.PreviousTab().Execute();
3645 }
3646 else
3647 {
3648 ResolveGhostTabHandle(); // Edge injects a phantom NTP handle; identify it so we never land on it
3649
3650 var handles = BrowserDriver.WindowHandles;
3651 string current = BrowserDriver.CurrentWindowHandle;
3652
3653 // same as NextTab: the tabs of this window only, and the driver is asked where we are
3654 List<string> siblings = TabsInThisWindow(handles, current);
3655
3656 int idx = siblings.IndexOf(current);
3657 if (0 > idx) idx = 0;
3658
3659 // (i - 1 + n) % n - add the raw count before the modulo so a -1 wrap stays positive
3660 idx = (idx - 1 + siblings.Count) % siblings.Count;
3661
3662 CurrentTabIdx = handles.IndexOf(siblings[idx]); // reported to the workflow, never read back as our position
3663
3664 BrowserDriver.SwitchTo().Window(siblings[idx]);
3665 if (BrowserDriver.CurrentWindowHandle != siblings[idx])
3666 BrowserDriver.SwitchTo().Window(siblings[idx]);
3667
3668 }
3669
3670 //BrowserHelper.CheckDocumentReady(this, true); // check idle
3671
3672 return this;
3673 }
3674 }
3675
3682 {
3683 return LeftClick(ModifierKeys.NONE);
3684 }
3685
3691 public IAllowBrowserActionOrAnySelector LeftClick(ModifierKeys modifierKeys)
3692 {
3693 string priorUrl = BrowserSettings.CurrentURL;
3694 object priorDocument = BrowserHelper.MarkDocument(this); // the page we are clicking on, to tell later if we left it
3695
3696 // snapshot open handles before the click so we can tell if the click opened a new tab (target=_blank)
3697 var handlesBefore = (true == UseSelenium) ? new HashSet<string>(BrowserDriver.WindowHandles) : null;
3698
3699 // waitfor did not match if returns false, so we have no element to click
3700 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
3701 ElementHelper.LeftClick(this, modifierKeys);
3702
3703 if (true == UseSelenium)
3704 {
3705 ResolveGhostTabHandle(); // make sure Edge's phantom tab is identified before we count new tabs
3706
3707 // in case this opens a new tab
3708 var handles = BrowserDriver.WindowHandles.ToList();
3709 string currentHandle = BrowserDriver.CurrentWindowHandle;
3710
3711 CurrentTabIdx = handles.IndexOf(currentHandle);
3712
3713 // reliable our-tabs counter: add only genuinely new tabs this click opened, never Edge's ghost
3714 int opened = 0;
3715
3716 foreach (string handle in handles.Where(h => false == handlesBefore.Contains(h) && h != GhostTabHandle))
3717 {
3718 RecordTabWindow(handle, currentHandle); // a click opens into the window it was clicked in
3719 opened++;
3720 }
3721
3722 if (0 < opened) tabsWeOpened += opened;
3723 }
3724
3725 // the click returns as soon as the event is dispatched, so give the browser a moment to commit the
3726 // navigation before deciding it did not go anywhere. Reading the url once here saw the old page
3727 if (true == BrowserHelper.WaitForNavigationOrTime(this, priorUrl, priorDocument, BrowserSettings.NavigationGraceMs))
3728 {
3729 BrowserHelper.CheckDocumentReady(this);
3730
3731 // read the url again now the document is committed, not while it was still tearing down
3732 string currentUrl = GetSetCurrentUrl();
3733
3734 if (true == UseOttoMagic)
3735 {
3736 int tabId = GetActiveTabId();
3737 TabTuple tabTuple = new TabTuple() { TabId = tabId, Url = currentUrl };
3738 UpdateActiveTab(tabTuple);
3739 }
3740 else if (true == UsePuppeteer)
3741 PuppeteerClient.SwitchToDefaultContent().Execute();
3742 else
3743 BrowserDriver.SwitchTo().DefaultContent();
3744 }
3745
3746 return this;
3747 }
3748
3755 {
3756 if (null != selector)
3757 WithSelector(selector);
3758
3759 return LeftClick(ModifierKeys.NONE);
3760 }
3761
3767 public IAllowBrowserActionOrAnySelector SelectClick(SelectClickType selectClickType)
3768 {
3769 // waitfor did not match if returns false, so we have no element to click
3770 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
3771 {
3772 string priorUrl = BrowserSettings.CurrentURL;
3773 object priorDocument = BrowserHelper.MarkDocument(this); // the page we are clicking on, to tell later if we left it
3774 ElementHelper.GenericClick(this, selectClickType);
3775
3776 if (true == BrowserHelper.WaitForNavigationOrTime(this, priorUrl, priorDocument, BrowserSettings.NavigationGraceMs))
3777 BrowserHelper.CheckDocumentReady(this);
3778 }
3779
3780 GetSetCurrentUrl();
3781
3782 return this;
3783 }
3784
3790 {
3791 if (null != selector)
3792 WithSelector(selector);
3793
3794 // waitfor did not match if returns false, so we have no element to click
3795 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
3796 {
3797 // OttoMagic's ordinary click is dispatched in the page, so this asks the extension for the
3798 // trusted one instead. puppeteer's click already goes through the browser's input pipeline,
3799 // and we never enable runtime there, so it is left as it is
3800 if (true == UseOttoMagic)
3801 ElementHelper.LeftClick(this, trusted: true);
3802 else if (true == UsePuppeteer)
3803 ElementHelper.LeftClick(this);
3804 else if (BrowserType.FireFox != BrowserSettings.BrowserType)
3805 {
3806 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Runtime.disable", new Dictionary<string, object> { });
3807 ElementHelper.LeftClick(this);
3808 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Runtime.enable", new Dictionary<string, object> { });
3809 }
3810 else
3811 {
3812 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{BrowserSettings.BrowserType}] on [{BrowserSettings.AutomationEngine}] cannot disable the runtime around a click, so this is an ordinary left click", this, GPALObjectType.Browser);
3813 ElementHelper.LeftClick(this);
3814 }
3815 }
3816
3817 GetSetCurrentUrl();
3818
3819 return this;
3820 }
3821
3828 {
3829 // waitfor did not match if returns false
3830 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
3831 {
3832 ElementHelper.GenericClick(this, ClickType.LeftDoubleClick);
3833 BrowserHelper.CheckDocumentReady(this);
3834 }
3835 // NOTE: should we expect this to possibly change the url? how many websites do you interact that take double clicks? only a custom interface, i think.
3836 return this;
3837 }
3838
3845 {
3846 string priorUrl = BrowserSettings.CurrentURL;
3847
3848 // snapshot open handles before the click so we can tell which tab(s) the middle-click opened
3849 var handlesBefore = (true == UseSelenium) ? new HashSet<string>(BrowserDriver.WindowHandles) : null;
3850
3851 // waitfor did not match if returns false, so we have no element to click
3852 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
3853 ElementHelper.MiddleClick(this);
3854
3855 string currentUrl = GetSetCurrentUrl();
3856
3857 if (true == UseSelenium)
3858 {
3859 ResolveGhostTabHandle(); // make sure Edge's phantom tab is identified before we count new tabs
3860
3861 // in case this opens a new tab
3862 var handles = BrowserDriver.WindowHandles.ToList();
3863 string currentHandle = BrowserDriver.CurrentWindowHandle;
3864
3865 CurrentTabIdx = handles.IndexOf(currentHandle);
3866
3867 // reliable our-tabs counter: add only genuinely new tabs this middle-click opened, never Edge's ghost
3868 int opened = 0;
3869
3870 foreach (string handle in handles.Where(h => false == handlesBefore.Contains(h) && h != GhostTabHandle))
3871 {
3872 RecordTabWindow(handle, currentHandle); // a middle click opens into the window it was clicked in
3873 opened++;
3874 }
3875
3876 if (0 < opened) tabsWeOpened += opened;
3877 }
3878
3879 if (false == UrlHelper.AreEquivalent(priorUrl, currentUrl))
3880 {
3881 if (true == UseOttoMagic)
3882 {
3883 int tabId = GetActiveTabId();
3884 TabTuple tabTuple = new TabTuple() { TabId = tabId, Url = currentUrl };
3885 UpdateActiveTab(tabTuple);
3886 }
3887 else if (true == UsePuppeteer)
3888 PuppeteerClient.SwitchToDefaultContent().Execute();
3889 else
3890 BrowserDriver.SwitchTo().DefaultContent();
3891 }
3892
3893 return this;
3894 }
3895
3902 {
3903 if (null != selector)
3904 {
3905 // if we are using GET, we are headless, there is no hardware interaction, force selenium on any selector, overriding definitions
3906 if (true == BrowserSettings.UseHeadless)
3907 selector.InteractionType = InteractionType.Selenium;
3908 WithSelector(selector);
3909 }
3910 // waitfor did not match if returns false
3911 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
3912 {
3913 ElementHelper.GenericClick(this, ClickType.RightClick);
3914 BrowserHelper.CheckDocumentReady(this);
3915 }
3916 // NOTE: should we expect this to possibly change the url? how many websites do you interact that take double clicks? only a custom interface, i think.
3917 return this;
3918 }
3919
3929
3931 bool useEAvars = false;
3932 GPALElement EAelement = null;
3933 Selector EAselector = null;
3941 internal void LeftClickAndDownload(GPALFile gPalFile, GPALElement webElement, Selector selector)
3942 {
3943 useEAvars = true;
3944 EAelement = webElement;
3945 EAselector = selector;
3946
3947 LeftClickAndDownload(gPalFile);
3948
3949 }
3950 // long enough for a dialog that is opening to be there, short enough that finding none costs nothing
3951 internal const int UnexpectedSaveAsWaitSeconds = 2;
3952 internal const int ProfileRestoreWaitMs = 5000; // how long to give the browser to shut down before the profile goes back
3953
3961 {
3962 string fullFilePathToSave;
3963 HWND windowOpened = IntPtr.Zero;
3964 List<string> returnFilename = new List<string>();
3965 bool noSaveAsDialog = false;
3966 FileDownloaded = false;
3967 int rowCount = 0;
3968
3969 if (true == useEAvars)
3970 {
3971 DownloadWorkflow(EAelement, EAselector);
3972 return this;
3973 }
3974
3975 int DownloadWorkflow(GPALElement webElement, Selector selector)
3976 {
3977 GPALFile fileToSave = gPalFileToDownloadTo.Next;
3978 fullFilePathToSave = FileHelper.CleanUpFileDestination(fileToSave.Filename, out string newFilename, BrowserSettings.DeleteFileBeforeDownload);
3979 returnFilename.Add(fullFilePathToSave); // return the filenames generated
3980
3981 string currentUrl = null, currentUrl2 = null;
3982
3984
3985 FileDownloaded = null;
3986
3987 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Downloading to [{fullFilePathToSave}]", this, GPALObjectType.Browser);
3988
3989 if (true == BrowserSettings.UseDirectDownload)
3990 {
3991 // the page is asked first, on the connection the browser has already earned: its cookies, its
3992 // clearance. our own connection has none of that, which is what a 403 on a file behind a login
3993 // looks like, so a tab download is the fallback rather than HttpClient
3994 string directUrl = FileHelper.GetDownloadUrl(webElement);
3995
3996 if (true == string.IsNullOrWhiteSpace(directUrl))
3997 {
3998 // nothing on the element says where the file is, so there is no url to ask for. a button
3999 // that downloads by running script is the usual reason, and that one has to be clicked
4000 FileDownloaded = false;
4001 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{webElement?.TagName}] carries no url to download, so WithUseDirectDownload has nothing to ask for. Leave it off and the click is used instead", this, GPALObjectType.Browser);
4002 }
4003 else
4004 {
4005 FileDownloaded = FileHelper.FetchOrDownloadInTab(directUrl, fullFilePathToSave, this);
4006
4007 if (false == FileDownloaded)
4008 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{directUrl}] did not download to [{fullFilePathToSave}]", this, GPALObjectType.Browser);
4009 }
4010 }
4011 // no prompt means no Save As dialog is coming, so there is nothing to wait for or type into - watch
4012 // the download directory instead, the same as headless does. OpenPDFExternally is not part of this
4013 // any more: it is a profile preference neither cdp nor the extension can set, so it never changes
4014 else if (true == BrowserSettings.UseHeadless || false == BrowserSettings.PromptForDownload) // headless mode or no prompt for filename, monitor default download dir
4015 {
4016 // so assume the file will download
4017 string watchedDirectory = FileHelper.SetupDownloadWatcher(fullFilePathToSave, BrowserSettings);
4018
4019 // BOTH OF THESE do not check for completion, just sets download path and clicks
4020 if (true == BrowserSettings.UsePuppeteer)
4021 {
4022 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Element [{webElement.TagName}] for selector [{selector.Name}] [{selector.InteractionType.ToString()}][Left] clicked with modifiers [NONE].");
4023 PuppeteerClient.LeftClickAndDownload(webElement.ElementHandle).DownloadTo(fileToSave).Execute();
4024 }
4025 else if (true == BrowserSettings.UseOttoMagic)
4026 {
4027 // these two arms click through their own client rather than ElementHelper.Click, so the
4028 // click never reaches the event that method publishes and has to be said here. without it
4029 // a download that produced nothing cannot be told apart from a click that never landed
4030 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Element [{webElement.TagName}] for selector [{selector.Name}] [{selector.InteractionType.ToString()}][Left] clicked with modifiers [NONE].");
4031 MagicHelper.LeftClickAndDownload(webElement.Css, fileToSave);
4032 }
4033 else // selenium
4034 {
4036 ElementHelper.Click(this, selector, webElement, ClickType.LeftClick, ModifierKeys.NONE);
4037 }
4038
4039 Thread.Sleep(250);
4040 // NOTE: new scenario, some nag modal pops up before the download starts
4041 // define those as persistent selectors and check persistent selectors here after we click to automatically dismiss them
4042 string urlAfterClick = BrowserHelper.GetCurrentUrl(BrowserSettings);
4043
4044 // the persistent selectors belong to the page the click was made on. a click that navigated
4045 // left that page, so there is nothing of ours to look for where we landed
4046 if (true == UrlHelper.AreEquivalent(currentUrl, urlAfterClick))
4047 CheckPersistentSelectors();
4048
4049 if (false == UrlHelper.AreEquivalent(currentUrl, urlAfterClick)
4050 && true == fileToSave.Filename.EndsWith(".pdf")
4051 && true == BrowserHelper.TestUrlForPDF(this, urlAfterClick))
4052 {
4053 //GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{urlAfterClick}] is the pdf itself, fetching it rather than waiting on a Save As dialog.", this, GPALObjectType.Browser);
4054 FileDownloaded = BrowserHelper.DownloadPdfFile(urlAfterClick, fullFilePathToSave, this);
4055 }
4056
4057 // PromptForDownload is a claim about the browser's configuration that cannot be read back from
4058 // any of the three engines, and on ottomagic and puppeteer cannot be set either. so rather than
4059 // trust it, look: a dialog that did open blocks the browser for everything after it, and a
4060 // couple of seconds spent finding out beats losing the rest of the run to a modal nobody typed
4061 // into. nothing appears when the claim was right, which is the ordinary case
4062 if (false == BrowserSettings.UseHeadless)
4063 {
4064 HWND unexpectedDialog = WaitForCommonDialog(UnexpectedSaveAsWaitSeconds);
4065
4066 if (IntPtr.Zero != unexpectedDialog)
4067 {
4068 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{BrowserSettings.BrowserType}] asked where to save the file even though prompting was turned off. Answering the dialog with [{newFilename}].", this, GPALObjectType.Browser);
4069
4070 HardwareHelper.SendString(newFilename);
4071 HardwareHelper.SendKey(GPAL.VK_RETURN);
4072 }
4073 }
4074
4075 // null is the file handler not having reached an answer yet, which is the only thing worth
4076 // waiting on. true and false are both answers and it shuts the watcher down as it gives one,
4077 // so nothing can change after that.
4078 // DownloadTimeoutInSec bounds how long nothing happens, not how long the file takes. every time
4079 // the bytes on disk change the clock starts over, so a transfer is never cut off while it is
4080 // moving, and a wait where nothing is moving always ends - whether the download never started
4081 // or it died partway. stopping the clock on start instead left no way out of here at all
4082 Stopwatch sinceProgress = Stopwatch.StartNew();
4083 long lastSeen = long.MinValue;
4084
4085 // false is not final. these events arrive many times over one download and an early one can
4086 // conclude nothing while a later one is still resolving the file, so a false here is answered
4087 // by waiting rather than by giving up. what ends this loop is the allowance below
4088 while (null == FileDownloaded || false == FileDownloaded)
4089 {
4090 // NOTE: it is entirely possible, since we do not destroy the filewatcher, that the file could still come in and be handled, i've not seen it, but it's possible
4091 long progress = FileHelper.DownloadProgress(watchedDirectory, fullFilePathToSave);
4092
4093 if (progress != lastSeen)
4094 {
4095 lastSeen = progress;
4096 sinceProgress.Restart();
4097 }
4098 else if (sinceProgress.Elapsed.TotalSeconds > FileHelper.AllowanceFor(watchedDirectory, BrowserSettings))
4099 break;
4100
4101 Thread.Sleep(100); // Prevents 100% CPU usage
4102 }
4103
4105
4106 // but maybe it's a pdf opening in the browser itself, so check to see if that happened and if the url is a pdf file
4107 // of course it is possible the url won't contain .pdf and served up via a header? we might have to chack that
4108 if (false == (bool)(FileDownloaded ?? false) && false == BrowserSettings.OpenPDFExternally && true == fileToSave.Filename.EndsWith(".pdf"))
4109 {
4110 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Testing [{currentUrl2}] for PDF file", this, GPALObjectType.Browser);
4111 if (false == UrlHelper.AreEquivalent(currentUrl, currentUrl2) && true == BrowserHelper.TestUrlForPDF(this, currentUrl2))
4112 {
4113 FileDownloaded = BrowserHelper.DownloadPdfFile(currentUrl2, fullFilePathToSave, this);
4114 noSaveAsDialog = !(bool)FileDownloaded;
4115 }
4116 }
4117
4118 // we're headless, no fallback, if it didn't download it's not going someplace other than where we specified
4119 if (false == (bool)(FileDownloaded ?? false))
4120 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"File not downloaded to [{fullFilePathToSave}].", this, GPALObjectType.Browser);
4121 FileHelper.DiscardAbandonedDownload(BrowserSettings);
4122
4123 }
4124 else if (false == BrowserSettings.UseHeadless) // if we are not headless, we can handle the dialog or direct download
4125 {
4126 ElementHelper.Click(this, selector, webElement, ClickType.LeftClick, ModifierKeys.NONE);
4127
4128 string urlAfterClick = BrowserHelper.GetCurrentUrl(BrowserSettings);
4129
4130 // the persistent selectors belong to the page the click was made on. a click that navigated
4131 // left that page, so there is nothing of ours to look for where we landed
4132 // NOTE: new scenario, some nag modal pops up before the download starts
4133 // define those as persistent selectors and check persistent selectors here after we click to automatically dismiss them
4134 if (true == UrlHelper.AreEquivalent(currentUrl, urlAfterClick))
4135 CheckPersistentSelectors();
4136
4137 // the click landed on the pdf itself, so the browser is showing it and no Save As is ever coming.
4138 // fetching it now is the whole of the work, and skips the wait for a dialog that will not appear.
4139 // OpenPDFExternally is not consulted: a browser honoring it would have prompted or downloaded
4140 // rather than navigated, so arriving here on a pdf url says it did not take
4141 if (false == UrlHelper.AreEquivalent(currentUrl, urlAfterClick)
4142 && true == fileToSave.Filename.EndsWith(".pdf")
4143 && true == BrowserHelper.TestUrlForPDF(this, urlAfterClick))
4144 {
4145 //GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{urlAfterClick}] is the pdf itself, fetching it rather than waiting on a Save As dialog.", this, GPALObjectType.Browser);
4146 FileDownloaded = BrowserHelper.DownloadPdfFile(urlAfterClick, fullFilePathToSave, this);
4147 }
4148
4149 if (false == (bool)(FileDownloaded ?? false) && true == BrowserSettings.PromptForDownload)
4150 windowOpened = WaitForCommonDialog(BrowserSettings.WaitForWindowTimeoutInSeconds);
4151
4152 // nothing below applies once the pdf is already on disk. it is all recovery for a Save As that
4153 // never came, and running it would report a failure for a file we have and then reach for the
4154 // element the navigation took with it
4155 if (true == (bool)(FileDownloaded ?? false))
4156 noSaveAsDialog = false;
4157 // save as window didn't open in the allotted timeout, or we set to no prompt and a direct download
4158 else if (IntPtr.Zero != windowOpened)
4159 {
4160 HardwareHelper.SendString(newFilename);
4161 HardwareHelper.SendKey(GPAL.VK_RETURN);
4162
4163 // is the file save dialog still open? send ENTER again
4164 windowOpened = WaitForCommonDialog(1);
4165
4166 if (IntPtr.Zero != windowOpened)
4167 HardwareHelper.SendKey(GPAL.VK_RETURN);
4168
4169 string actualDownloadedFilename = null;
4170
4171 actualDownloadedFilename = FileHelper.WaitForDownloadToFinish(newFilename, BrowserSettings.DownloadTimeoutInSec);
4172
4173 FileDownloaded = true != string.IsNullOrEmpty(actualDownloadedFilename);
4174
4175 if (false == FileDownloaded)
4176 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"File not downloaded to [{fullFilePathToSave}]", this, GPALObjectType.Browser);
4177 FileHelper.DiscardAbandonedDownload(BrowserSettings);
4178
4179 }
4180 else
4181 {
4182 if (true == BrowserSettings.PromptForDownload)
4183 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to handle Save As... dialog [{fullFilePathToSave}][{selector.Name}]", BrowserSettings, GPALObjectType.Other);
4184
4185 noSaveAsDialog = true;
4186
4187 // NOTE: maybe it's a pdf opening in the browser itself, so check to see if that happened and if the url is a pdf file
4189
4190 // OpenPDFExternally is not consulted here either. the Save As wait has already come and gone
4191 // with nothing, so whatever the setting asked for is not what the browser did
4192 if (false == (bool)(FileDownloaded ?? false) && true == fileToSave.Filename.EndsWith(".pdf"))
4193 {
4194 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Testing [{currentUrl2}] for PDF file", this, GPALObjectType.Browser);
4195 if (false == UrlHelper.AreEquivalent(currentUrl, currentUrl2) && true == BrowserHelper.TestUrlForPDF(this, currentUrl2))
4196 {
4197 FileDownloaded = BrowserHelper.DownloadPdfFile(currentUrl2, fullFilePathToSave, this);
4198 noSaveAsDialog = !(bool)FileDownloaded;
4199 }
4200 }
4201 }
4202 }
4203
4204 // we didn't get a save as dialog within BrowserSettings.WaitForWindowTimeoutInSeconds which defaults to 30 seconds
4205 // so why? well, the website might have downloaded directly using the current browser default download directory
4206 // so we will attempt to see if a file was downloaded matching the file extension of the GPALFile the user specified
4207 if (true == noSaveAsDialog)
4208 {
4209 string downloadDirectory = FileHelper.GetDefaultDownloadDirectory(this);
4210 string downloadUrl = FileHelper.GetDownloadUrl(webElement);
4211 string filename = Path.GetFileName(downloadUrl) ?? fullFilePathToSave;
4212
4213 string wildcardFilename = "*" + Path.GetExtension(filename);
4214 string systemDownloadedTo = Path.Combine(downloadDirectory, wildcardFilename);
4215 string actualDownloadedFilename = null;
4216
4217 actualDownloadedFilename = FileHelper.WaitForDownloadToFinish(systemDownloadedTo, BrowserSettings.DownloadTimeoutInSec);
4218
4219 FileDownloaded = true != string.IsNullOrEmpty(actualDownloadedFilename);
4220
4221 if (true == FileDownloaded)
4222 {
4223 if (false == fullFilePathToSave.Equals(actualDownloadedFilename))
4224 {
4225 File.Move(actualDownloadedFilename, fullFilePathToSave);
4226 if (false == actualDownloadedFilename.Equals(fullFilePathToSave) && true == File.Exists(actualDownloadedFilename))
4227 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Downloaded [{actualDownloadedFilename}] unable to move to [{fullFilePathToSave}]", null, GPALObjectType.None);
4228 else
4229 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{actualDownloadedFilename}] moved to [{fullFilePathToSave}]", this, GPALObjectType.Browser);
4230 }
4231 }
4232 else
4233 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{filename}] not found in [{downloadDirectory}]", this, GPALObjectType.Browser);
4234 }
4235
4236 if (null != FileHelper.watcher) // we might not have disposed of watcher
4237 {
4238 try
4239 {
4240 FileHelper.watcher.Dispose();
4241 FileHelper.watcher = null;
4242 }
4243 catch { }
4244 }
4245
4246 if (false == BrowserSettings.UseHeadless && BrowserType.Edge == BrowserSettings.BrowserType) // dismiss the 'downloads' popup which can steal keys, clicks or get in the way
4247 {
4248 // KLUDGE: must be a hardware top to dismiss 'downloads' by clicking the browser chrome
4249 BrowserHelper.TopBrowser(this, false, true);
4250 }
4251
4252 if (int.MaxValue != CurrentUOW.WithAllThatMatch && ++rowCount == CurrentUOW.WithAllThatMatch)
4253 return -1;
4254 else
4255 return 0;
4256 }
4257
4258 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4259 {
4260 foreach (Selector selector in CurrentUOW.WithSelectorList)
4261 {
4262 if (SelectorType.Selector != selector.SelectorType)
4263 continue; // we don't look up data literals
4264
4265 ReadOnlyCollection<GPALElement> webelements = ElementHelper.FindWebElements(this, CurrentUOW, selector, out bool matchedAll, out List<GPALElement> matchedElements);
4266 if (null != webelements)
4267 {
4268 // the page these came from is the one to come back to. a download click that navigates
4269 // takes that document with it, and every element still waiting in the list belongs to it
4270 string pageOfTheElements = BrowserHelper.GetCurrentUrl(BrowserSettings);
4271 int matchCount = matchedElements.Count;
4272
4273 for (int elementIdx = 0; elementIdx < matchCount; elementIdx++)
4274 {
4276
4277 // back to where the elements live, and find them again - the ones in hand are gone with
4278 // their document, and asking one of them anything raises an unknown reference
4279 if (false == UrlHelper.AreEquivalent(pageOfTheElements, urlNow))
4280 {
4281 GoTo(pageOfTheElements);
4282 ElementHelper.FindWebElements(this, CurrentUOW, selector, out bool _, out matchedElements);
4283
4284 if (matchedElements.Count != matchCount)
4285 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{selector.Name}] matched [{matchedElements.Count}] on the way back rather than [{matchCount}], so the remaining downloads may not be the ones first found.", this, GPALObjectType.Browser);
4286 }
4287
4288 if (elementIdx >= matchedElements.Count)
4289 break;
4290
4291 // int.MaxValue tells us we have met our .WithAllThatMatch elements to process
4292 if (-1 == DownloadWorkflow(matchedElements[elementIdx], selector))
4293 break;
4294 }
4295 }
4296 }
4297
4298 foreach (string filepath in returnFilename)
4299 {
4300 gPalFileToDownloadTo.ReturnFilenames.Add(filepath); // return the filenames we generated
4301// GPAL.PublishSimpleEvent(GPALEventType.INFO, $"File saved to [{filepath}]");
4302 }
4303 }
4304
4305 GetSetCurrentUrl();
4306
4307 return this;
4308 }
4309
4316 internal void LeftClickAndUpload(GPALFile gPalFile, GPALElement webElement, Selector selector)
4317 {
4318 useEAvars = true;
4319 EAelement = webElement;
4320 EAselector = selector;
4321
4322 LeftClickAndUpload(gPalFile);
4323
4324 }
4333 {
4334 HWND windowOpened = IntPtr.Zero;
4335
4336 if (true == useEAvars)
4337 {
4338 UploadWorkflow(EAelement, EAselector, false); // NOTE: CAVEAT: not sure this makes sense, once again, trying to interpret the concept of gpalfile having more than one file
4339 return this;
4340 }
4341
4342 // NOTE: if you are providing a generic selector, say it finds 3 upload buttons, if you only supply 2 filenames in gg
4343 int UploadWorkflow(GPALElement webElement, Selector selector, bool uploadAllFiles)
4344 {
4345 string fileToUpload = null;
4346
4347 // asked once, here, while the element is still the one we just found. reading it inside the loop
4348 // reads a stale element, because the first SendKeys is what makes it stale.
4349 // the .multiple property rather than the attribute: multiple is a bare boolean attribute carrying
4350 // no value in the markup, and GetAttribute capitalises the name before asking the driver, which is
4351 // enough to miss the special case that would have turned it into "true"
4352 bool sendAllAtOnce = true == UseSelenium
4353 && 1 < gPalFileToUpload.Count
4354 && true == Convert.ToBoolean(BrowserHelper.ExecuteJavaScript(this, "return arguments[0].multiple;", webElement.WebElement) ?? false);
4355
4356 for (int fileCount = 0; fileCount < gPalFileToUpload.Count; fileCount++)
4357 {
4358 fileToUpload = gPalFileToUpload.Filenames[fileCount];
4359
4360 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Uploading [{fileToUpload}]", this, GPALObjectType.Browser);
4361
4362 // while we can deal with the input dialog, why bother? this is cleaner
4363 // if (true == BrowserSettings.UseHeadless || true == BrowserSettings.UseOttoMagic) // headless mode or no prompt for filename, alternate methods to add file to input
4364
4365 // if the user wants to use hardware input, we can do that, otherwise direct is best - but hardware works for selenium headful mode
4366 if (false == BrowserSettings.UseHardware && false == GPAL.GPALSettings.UseHardware && InteractionType.Hardware != selector.InteractionType)
4367 {
4368
4369 if (true == string.IsNullOrEmpty(fileToUpload))
4370 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "For some reason we have an empty filename", gPalFileToUpload, GPALObjectType.Other);
4371
4372 // BOTH OF THESE do not check for completion, just sets download path and clicks
4373 if (true == BrowserSettings.UsePuppeteer)
4374 {
4375 if (true == uploadAllFiles)
4376 {
4377 PuppeteerClient.LeftClickAndUpload(webElement.ElementHandle).UploadFrom(gPalFileToUpload).Execute();
4378 break;
4379 }
4380 PuppeteerClient.LeftClickAndUpload(webElement.ElementHandle).UploadFrom(fileToUpload).Execute();
4381 }
4382 else if (true == BrowserSettings.UseOttoMagic)
4383 MagicHelper.LeftClickAndUpload(webElement.Css, fileToUpload);
4384 else // selenium
4385 {
4386 // chromedriver appends a file on every SendKeys, geckodriver throws on the second one
4387 // instead. all of them take the whole set in one call with the paths newline separated,
4388 // so a multiple input is filled that way and there is nothing left to loop over
4389 string filesToSend = true == sendAllAtOnce ? string.Join("\n", gPalFileToUpload.Filenames) : fileToUpload;
4390
4391 try
4392 {
4393 ((IWebElement)webElement.iWebElement).SendKeys(filesToSend); // NOTE: special case in selenium sendkeys on input[type=files] adds a file
4394 }
4395 catch // stale element
4396 {
4397 ElementHelper.FindWebElements(this, CurrentUOW, selector, out bool _, out List<GPALElement> matchedElements);
4398 webElement = 0 < matchedElements.Count ? matchedElements[0] : null;
4399 webElement?.SendKeys(filesToSend.Replace("\\", "/"));
4400 }
4401
4402 if (true == sendAllAtOnce)
4403 break;
4404 }
4405
4406 Thread.Sleep(250);
4407 }
4408 else if (false == BrowserSettings.UseHeadless) // NOTE: HARDWARE insisted upon for this selector, so it is clickable
4409 {
4410 ElementHelper.Click(this, selector, webElement, ClickType.LeftClick, ModifierKeys.NONE); // click the input[type=file] button
4411
4412 windowOpened = WaitForCommonDialog(BrowserSettings.WaitForWindowTimeoutInSeconds);
4413
4414 // save as window didn't open in the allotted timeout, or we set to no prompt and a direct download
4415 if (IntPtr.Zero != windowOpened)
4416 {
4417 int filesToSend = 1;
4418
4419 if (true == webElement.GetAttribute("multiple")?.ToLower().Equals("true"))
4420 filesToSend = gPalFileToUpload.Count;
4421
4422 if (true == uploadAllFiles)
4423 for (int idx = 0; idx < gPalFileToUpload.Count; idx++)
4424 {
4425 fileToUpload = gPalFileToUpload.Filenames[idx];
4426 HardwareHelper.SendString($@"""{fileToUpload}"" ");
4427 }
4428 else
4429 HardwareHelper.SendString($@"""{fileToUpload}"" ");
4430
4431 HardwareHelper.SendKey(GPAL.VK_RETURN);
4432
4433 // is the file dialog still open? send ENTER again
4434 windowOpened = WaitForCommonDialog(1);
4435
4436 if (IntPtr.Zero != windowOpened)
4437 HardwareHelper.SendKey(GPAL.VK_RETURN);
4438
4439 if (true == uploadAllFiles)
4440 break; // we send all the filenames, so long as they are not too long
4441 }
4442 else
4443 {
4444 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to handle Open File dialog [{fileToUpload}][{selector.Name}] not upoloaded.", BrowserSettings, GPALObjectType.Other);
4445 return -1;
4446 }
4447 }
4448
4449 //if (false == BrowserSettings.UseHeadless && BrowserType.Edge == BrowserSettings.BrowserType) // dismiss the 'downloads' popup which can steal keys, clicks or get in the way
4450 //{
4451 // // KLUDGE: must be a hardware top to dismiss 'downloads' by clicking the browser chrome
4452 // bool tmpTop = GPAL.SeleniumTopsBrowser;
4453 // GPAL.WithSeleniumToppingBrowser(false);
4454 // BrowserHelper.TopBrowser(this);
4455 // GPAL.WithSeleniumToppingBrowser(tmpTop);
4456 //}
4457
4458 // NOTE: CAVEAT: not sure how to use WithAllThatMatch, seems cumbersome?
4459 if (int.MaxValue != CurrentUOW.WithAllThatMatch && fileCount == CurrentUOW.WithAllThatMatch && false == uploadAllFiles)
4460 return -1;
4461 else if (false == uploadAllFiles)
4462 return 0;
4463 }
4464
4465 return -1;
4466 }
4467
4468 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4469 {
4470 foreach (Selector selector in CurrentUOW.WithSelectorList)
4471 {
4472 if (SelectorType.Selector != selector.SelectorType)
4473 continue; // we don't look up data literals
4474
4475 ReadOnlyCollection<GPALElement> webelements = ElementHelper.FindWebElements(this, CurrentUOW, selector, out bool matchedAll, out List<GPALElement> matchedElements);
4476 if (null != webelements)
4477 {
4478 foreach (GPALElement webElement in matchedElements)
4479 {
4480 // int.MaxValue tells us we have met our .WithAllThatMatch elements to process
4481 if (-1 == UploadWorkflow(webElement, selector, 1 == matchedElements.Count))
4482 break;
4483 }
4484 }
4485 }
4486 }
4487
4488 GetSetCurrentUrl();
4489
4490 return this;
4491 }
4492
4503 {
4504 string fullFilePathToSave;
4505 HWND windowOpened = IntPtr.Zero;
4506 List<string> returnFilename = new List<string>();
4507 FileDownloaded = false;
4508
4509 // waitfor did not match if returns false
4510 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4511 {
4512 foreach (Selector selector in CurrentUOW.WithSelectorList)
4513 {
4514 if (SelectorType.Selector != selector.SelectorType)
4515 continue; // we don't look up data literals
4516
4517 ReadOnlyCollection<GPALElement> webelements = ElementHelper.FindWebElements(this, CurrentUOW, selector, out bool matchedAll, out List<GPALElement> matchedElements);
4518 if (null != webelements)
4519 {
4520 foreach (GPALElement webElement in matchedElements)
4521 {
4522 int rowCount = 0;
4523
4524 fullFilePathToSave = FileHelper.CleanUpFileDestination(filenames.Next, out string newFilename, BrowserSettings.DeleteFileBeforeDownload);
4525 returnFilename.Add(fullFilePathToSave); // return the filenames generated
4526
4527 // this will iterate over all selectors in the CurrentUOW and right click each and attempt to download from each
4528 // CAVEAT: this is untested with multiple selector elements
4529
4530 // CAVEAT: KLUDGE: Selenium cannot interact with context menu, so switch to javascript click, then back, altho it's prolly pointless to restore it at this point
4531 Enums.InteractionType interactionType = selector.InteractionType;
4532 if (Enums.InteractionType.Selenium == selector.InteractionType && "GPALElement" != webElement.TagName)
4533 selector.InteractionType = InteractionType.JavaScript;
4534 else if ("GPALElement" != webElement.TagName && false == BrowserSettings.UseHeadless)
4535 selector.InteractionType = InteractionType.Hardware;
4536
4537 if (false == BrowserSettings.UseHeadless && (true == BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware)) // if we are not headless, we will handle the dialog, otherwise set using selenium and click
4538 {
4539 // NOTE: CAVEAT: if DownloadToFile always works, why would we care about using the save as dialog?
4540 bool noSaveAsDialog = false;
4541 string currentUrl = null, currentUrl2 = null;
4542
4543 if (true == BrowserSettings.UseOttoMagic)
4544 currentUrl = MagicHelper.GetCurrentUrl();
4545 else if (true == BrowserSettings.UsePuppeteer)
4546 currentUrl = PuppeteerClient.GetCurrentUrl().Execute();
4547 else
4548 currentUrl = BrowserDriver.Url;
4549
4550 ElementHelper.Click(this, selector, webElement, ClickType.RightClick, ModifierKeys.NONE);
4551
4552 // MS EDGE and context menu is weird under driver control, it seems to need time to settle in because it resets to the top of the context menu
4553 // so we sleep 5 seconds
4554 // we also give it a home key to gobble up cause it's just unpredictable
4555 Thread.Sleep(5000);
4556
4557 // arrow down menu, then enter to select entry
4558 // 2 or 3 arrows down. if it goes too far, it copies the url to the clipboard, so if the save as... doesn't open, we will try with 3 arrow down
4559 HardwareHelper.SendKey(GPAL.VK_HOME); // home key press is first element
4560 Thread.Sleep(150);
4561 HardwareHelper.SendKey(GPAL.VK_DOWN);
4562 Thread.Sleep(150);
4563 HardwareHelper.SendKey(GPAL.VK_DOWN);
4564 Thread.Sleep(150);
4565 HardwareHelper.SendKey(GPAL.VK_DOWN);
4566 Thread.Sleep(150);
4567
4568 if (BrowserType.FireFox == BrowserSettings.BrowserType || BrowserType.Edge == BrowserSettings.BrowserType)
4569 HardwareHelper.SendKey(GPAL.VK_DOWN);
4570 Thread.Sleep(150);
4571 HardwareHelper.SendKey(GPAL.VK_RETURN);
4572
4573 // CAVEAT: KLUDGE - see above, restore to selenium
4574 selector.InteractionType = interactionType;
4575
4576 windowOpened = WaitForCommonDialog(BrowserSettings.WaitForWindowTimeoutInSeconds);
4577
4578 // firefox names the two dialogs differently: saving a link is "Enter name of
4579 // file to save to", saving the page itself is "Save As". The title is read
4580 // here rather than searched on, so a misclick is still reported where the
4581 // language allows it and the dialog is still found where it does not
4582 if (IntPtr.Zero != windowOpened
4583 && BrowserType.FireFox == BrowserSettings.BrowserType
4584 && "Save As" == FormHelper.TitleOf(windowOpened))
4585 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"GPAL thinks a misclick happened and the wrong item is saved. Please verify [{fullFilePathToSave}].", this, GPALObjectType.Browser);
4586
4587 if (IntPtr.Zero != windowOpened)
4588 {
4589 HardwareHelper.SendString(fullFilePathToSave);
4590 HardwareHelper.SendKey(GPAL.VK_RETURN);
4591
4592 // is the file save dialog still open? send ENTER again
4593 windowOpened = WaitForCommonDialog(5);
4594
4595 if (IntPtr.Zero != windowOpened)
4596 HardwareHelper.SendKey(GPAL.VK_RETURN);
4597
4598 // wait for file to download
4599 string actualDownloadedFilename2 = null;
4600
4601 actualDownloadedFilename2 = FileHelper.WaitForDownloadToFinish(fullFilePathToSave, BrowserSettings.DownloadTimeoutInSec);
4602
4603 FileDownloaded = true != string.IsNullOrEmpty(actualDownloadedFilename2);
4604
4605 if (BrowserType.Edge == BrowserSettings.BrowserType) // dismiss the 'downloads' popup which can steal keys, clicks or get in the way
4606 {
4607 // KLUDGE: must be a hardware top to dismiss 'downloads' by clicking the browser chrome
4608 BrowserHelper.TopBrowser(this, false, true);
4609 }
4610 }
4611 else
4612 {
4613 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to handle Save As... dialog [{fullFilePathToSave}][{selector.Name}]", BrowserSettings, GPALObjectType.Other);
4614 noSaveAsDialog = true;
4615
4616 // but maybe it's a pdf opening in the browser itself, so check to see if that happened and if the url is a pdf file
4617 // of course it is possible the url won't contain .pdf and served up via a header? we might have to chack that
4618
4619 if (true == BrowserSettings.UseOttoMagic)
4620 currentUrl2 = MagicHelper.GetCurrentUrl();
4621 else if (true == BrowserSettings.UsePuppeteer)
4622 currentUrl2 = PuppeteerClient.GetCurrentUrl().Execute();
4623 else
4624 currentUrl2 = BrowserDriver.Url;
4625
4626 // but maybe it's a pdf opening in the browser itself, so check to see if that happened and if the url is a pdf file
4627 // of course it is possible the url won't contain .pdf and served up via a header? we might have to chack that
4628 if (false == BrowserSettings.OpenPDFExternally && true == fullFilePathToSave.EndsWith(".pdf"))
4629 {
4630 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Testing [{currentUrl2}] for PDF file", this, GPALObjectType.Browser);
4631 if (false == UrlHelper.AreEquivalent(currentUrl, currentUrl2) && true == BrowserHelper.TestUrlForPDF(this, currentUrl2))
4632 {
4633 FileDownloaded = BrowserHelper.DownloadPdfFile(currentUrl2, fullFilePathToSave, this);
4634 noSaveAsDialog = !(bool)FileDownloaded;
4635 }
4636 }
4637 }
4638
4639 // we didn't get a save as dialog within BrowserSettings.WaitForWindowTimeoutInSeconds which defaults to 30 seconds
4640 // so why? well, the website might have downloaded directly using the current browser default download directory
4641 // so we will attempt to see if a file was downloaded matching the file extension of the GPALFile the user specified
4642 if (true == noSaveAsDialog)
4643 {
4644 string downloadDirectory = FileHelper.GetDefaultDownloadDirectory(this);
4645 string downloadUrl = FileHelper.GetDownloadUrl(webElement);
4646 string filename = Path.GetFileName(downloadUrl) ?? fullFilePathToSave;
4647
4648 string wildcardFilename = "*" + Path.GetExtension(filename);
4649 string systemDownloadedTo = Path.Combine(downloadDirectory, wildcardFilename);
4650 string actualDownloadedFilename2 = null;
4651
4652 actualDownloadedFilename2 = FileHelper.WaitForDownloadToFinish(systemDownloadedTo, BrowserSettings.DownloadTimeoutInSec);
4653
4654 FileDownloaded = true != string.IsNullOrEmpty(actualDownloadedFilename2);
4655
4656 if (true == FileDownloaded)
4657 {
4658 if (false == fullFilePathToSave.Equals(actualDownloadedFilename2))
4659 {
4660 File.Move(actualDownloadedFilename2, fullFilePathToSave);
4661 if (false == actualDownloadedFilename2.Equals(fullFilePathToSave) && true == File.Exists(actualDownloadedFilename2))
4662 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Downloaded file [{actualDownloadedFilename2}] unable to move to [{fullFilePathToSave}]", null, GPALObjectType.None);
4663 else
4664 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{actualDownloadedFilename2}] moved to [{fullFilePathToSave}]", this, GPALObjectType.Browser);
4665 }
4666 }
4667 }
4668 }
4669 else
4670 {
4671 // right click and Save As is what this emulates, so the file comes down the
4672 // browser's own connection. asked of the page first, then in a tab, and the
4673 // answer is read rather than assumed
4674 string rightClickUrl = FileHelper.GetDownloadUrl(webElement);
4675
4676 if (true == string.IsNullOrWhiteSpace(rightClickUrl))
4677 {
4678 FileDownloaded = false;
4679 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{webElement?.TagName}] carries no url to download, so there is nothing to save", this, GPALObjectType.Browser);
4680 }
4681 else
4682 {
4683 FileDownloaded = FileHelper.FetchOrDownloadInTab(rightClickUrl, fullFilePathToSave, this);
4684
4685 if (false == FileDownloaded)
4686 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{rightClickUrl}] did not download to [{fullFilePathToSave}]", this, GPALObjectType.Browser);
4687 }
4688 }
4689 /* selenium navigate the context menu, untested
4690 else
4691 {
4692 SetDownloadFilename(fullFilePath);
4693
4694 // Create an Actions object
4695 Actions actions = new Actions(browserSettings.BrowserDriver);
4696 actions.SendKeys(Keys.Home);
4697
4698 // Simulate pressing the down arrow key multiple times
4699 actions.SendKeys(Keys.ArrowDown);
4700 actions.SendKeys(Keys.ArrowDown);
4701 actions.SendKeys(Keys.ArrowDown);
4702
4703 // If using Firefox, simulate pressing the down arrow key one more time to get to FF command
4704 if (BrowserType.FireFox == BrowserSettings.BrowserType)
4705 actions.SendKeys(Keys.ArrowDown);
4706
4707 // Simulate pressing the return key
4708 actions.SendKeys(Keys.Enter);
4709
4710 // Simulate typing the filename
4711 if (BrowserType.FireFox == browserSettings.BrowserType || BrowserType.Edge == browserSettings.BrowserType)
4712 actions.SendKeys(Keys.ArrowDown);
4713
4714 // Simulate pressing the return key
4715 actions.SendKeys(Keys.Enter).Build().Perform();
4716 }
4717 */
4718
4719 // how many of the found elements are we downloading from?
4720 if (int.MaxValue != CurrentUOW.WithAllThatMatch && ++rowCount == CurrentUOW.WithAllThatMatch)
4721 break;
4722 }
4723 }
4724 else
4725 {
4726 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Failed to detect Save As open window.", this, GPALObjectType.Browser);
4727 }
4728 }
4729
4730 foreach (string filepath in returnFilename)
4731 {
4732 filenames.ReturnFilenames.Add(filepath); // return the filenames we generated
4733
4734 // the name was worked out either way, but saying a file was saved when the download failed
4735 // reads as success on the line after the failure. the file is what decides
4736 if (true == File.Exists(filepath))
4737 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"File saved to [{filepath}]", this, GPALObjectType.Browser);
4738 else
4739 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Nothing was saved to [{filepath}]", this, GPALObjectType.Browser);
4740 }
4741 }
4742
4743 return this;
4744 }
4745
4754 {
4755 DatabaseHelper.TokenizeDatabase(CurrentUOW, inputDatabase);
4756 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4757 BrowserHelper.FillInWithTokens(this, CurrentUOW.InputDatabase.Tokens, WriteMode.Append);
4758 return this;
4759 }
4760
4769 {
4770 DatabaseHelper.TokenizeDatabase(CurrentUOW, inputDatabase);
4771 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4772 BrowserHelper.FillInWithTokens(this, CurrentUOW.InputDatabase.Tokens, WriteMode.Insert);
4773 return this;
4774 }
4775
4784 {
4785 DatabaseHelper.TokenizeDatabase(CurrentUOW, inputDatabase);
4786 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4787 BrowserHelper.FillInWithTokens(this, CurrentUOW.InputDatabase.Tokens, WriteMode.Overwrite);
4788 return this;
4789 }
4790
4799 {
4800 ((IGPALFileInternal)inputFile).TokenList = FileHelper.TokenizeFile(CurrentUOW, inputFile);
4801 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4802 foreach (IGPALGrid<string> tokens in ((IGPALFileInternal)inputFile).TokenList)
4803 BrowserHelper.FillInWithTokens(this, tokens, WriteMode.Append);
4804 return this;
4805 }
4806
4815 {
4816 ((IGPALFileInternal)inputFile).TokenList = FileHelper.TokenizeFile(CurrentUOW, inputFile);
4817 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4818 foreach (IGPALGrid<string> tokens in ((IGPALFileInternal)CurrentUOW.InputFile).TokenList)
4819 BrowserHelper.FillInWithTokens(this, tokens, WriteMode.Overwrite);
4820 return this;
4821 }
4822
4831 {
4832 ((IGPALFileInternal)inputFile).TokenList = FileHelper.TokenizeFile(CurrentUOW, inputFile);
4833 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4834 foreach (IGPALGrid<string> tokens in ((IGPALFileInternal)inputFile).TokenList)
4835 BrowserHelper.FillInWithTokens(this, tokens, WriteMode.Insert);
4836 return this;
4837 }
4838
4846 public IAllowBrowserActionOrAnySelector AppendFrom(IGPALGrid<string> inputGrid)
4847 {
4848 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4849 BrowserHelper.FillInWithTokens(this, inputGrid, WriteMode.Append);
4850 return this;
4851 }
4852
4860 public IAllowBrowserActionOrAnySelector FillInFrom(IGPALGrid<string> inputGrid)
4861 {
4862 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4863 BrowserHelper.FillInWithTokens(this, inputGrid, WriteMode.Overwrite);
4864 return this;
4865 }
4866
4874 public IAllowBrowserActionOrAnySelector InsertFrom(IGPALGrid<string> inputGrid)
4875 {
4876 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4877 BrowserHelper.FillInWithTokens(this, inputGrid, WriteMode.Insert);
4878 return this;
4879 }
4880
4887 {
4888 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4889 ElementHelper.FillInFrom(this, text, WriteMode.Append);
4890
4891 return this;
4892 }
4893
4900 {
4901 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4902 ElementHelper.FillInFrom(this, text, WriteMode.Overwrite);
4903
4904 return this;
4905 }
4906
4914 {
4915 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4916 ElementHelper.FillInFrom(this, textToUse, WriteMode.Insert);
4917 return this;
4918 }
4919
4926 {
4927 if (null != selector)
4928 WithSelector(selector);
4929 // waitfor did not match if returns false
4930 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4931 ElementHelper.Focus(this);
4932 return this;
4933 }
4934
4941 {
4942 if (null != selector)
4943 WithSelector(selector);
4944
4945 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4946 ElementHelper.Hide(this);
4947
4948 return this;
4949 }
4950
4953 private string _pendingSetAttributeName;
4954
4963 {
4964 _pendingSetAttributeName = attribute;
4965 return this;
4966 }
4967
4974 {
4975 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4976 ElementHelper.SetAttribute(this, _pendingSetAttributeName, value);
4977
4978 return this;
4979 }
4980
4990 {
4991 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4992 ElementHelper.SetValueFromElement(this, selector);
4993
4994 return this;
4995 }
4996
5006 {
5007 return MoveTo(selector);
5008 }
5009
5020 {
5021 if (null != selector)
5022 WithSelector(selector);
5023
5024 // waitfor did not match if returns false
5025 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
5026 ElementHelper.MoveTo(this);
5027 return this;
5028 }
5029
5039 {
5040 ReadOnlyCollection<GPALElement> tmpElems;
5041
5042 if (true == CurrentUOW.ActionCalled)
5043 {
5044 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Action has already been called. This workflow has already run. Continuing.", this, GPALObjectType.Browser);
5045 return this;
5046 }
5047 else
5048 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Workflow starting, you should be careful if you follow this with actions. CallIf handlers will be invoked again.", this, GPALObjectType.Browser);
5049
5050 if (true == WorkflowSetup(WaitTime.Never < CurrentUOW.WaitForInMs, true))
5051 for (int pages = 0; pages < CurrentUOW.PageCount; pages++) // is start workflow is used, then we are using callif/callafter handlers with no main workflow
5052 {
5053 foreach (Selector sel in CurrentUOW.WithSelectorList)
5054 if (SelectorType.Selector == sel.SelectorType)
5055 tmpElems = ElementHelper.FindWebElements(this, CurrentUOW, sel, out bool matchedAll, out List<GPALElement> matchedElems);
5056
5057 if (pages < CurrentUOW.PageCount - 1)
5058 {
5059 if (false == BrowserSettings.AttachedRestApi)
5060 BrowserHelper.TopBrowser(this, false); // ensure the browser window is on top, can use selenium or hardware (mouse click) topping. firefox will only top with hardware
5061 if (false == BrowserHelper.GotoNextPage(this))
5062 {
5063 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No next page, breaking loop. Retrieved [{pages + 1}] out of [{CurrentUOW.PageCount}] pages.", this, GPALObjectType.Browser);
5064 break;
5065 }
5066 }
5067 }
5068
5069 return this;
5070 }
5071 public IAllowBrowserActionOrAnySelector GetElements(out List<GPALElement> elements)
5072 {
5073 elements = new List<GPALElement>();
5074
5075 if (true == CurrentUOW.ActionCalled)
5076 {
5077 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Action has already been called. Not retrieving elements.", this, GPALObjectType.Browser);
5078 return this;
5079 }
5080
5081 if (true == WorkflowSetup(WaitTime.Never < CurrentUOW.WaitForInMs, true))
5082 for (int pages = 0; pages < CurrentUOW.PageCount; pages++)
5083 {
5084 foreach (Selector sel in CurrentUOW.WithSelectorList)
5085 if (SelectorType.Selector == sel.SelectorType)
5086 {
5087 ElementHelper.FindWebElements(this, CurrentUOW, sel, out bool matchedAll, out List<GPALElement> matchedElems);
5088 if (sel.WebSelectorFoundResults != null)
5089 elements.AddRange(sel.WebSelectorFoundResults);
5090 }
5091
5092 if (pages < CurrentUOW.PageCount - 1)
5093 {
5094 BrowserHelper.TopBrowser(this, false);
5095 if (false == BrowserHelper.GotoNextPage(this))
5096 {
5097 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No next page, breaking loop. Retrieved [{pages + 1}] out of [{CurrentUOW.PageCount}] pages.", this, GPALObjectType.Browser);
5098 break;
5099 }
5100 }
5101 }
5102
5103 return this;
5104 }
5132 public IAllowWithHeaderOrFileActions GetGrid(ref IGPALGrid<string> returnGrid)
5133 {
5134 List<ReadOnlyCollection<GPALElement>> columns = new List<ReadOnlyCollection<GPALElement>>();
5135 List<ReadOnlyCollection<GPALElement>> tmpColumns = new List<ReadOnlyCollection<GPALElement>>();
5136 var selectorMeta = new List<(string Name, string AttributeName)>();
5137 List<GPALElement> matchedElems = new List<GPALElement>();
5138 dynamic foundElements = null;
5139
5140 // reading the grid is an action, so the unit of work is finished with and the next WithSelector
5141 // starts a new one. GetGridCalled is a separate thing: it tells WithHeader that the headers
5142 // following it belong to this grid, and SaveTo and WithHeader read the grid off this same unit of
5143 // work, so neither is disturbed by the unit of work being closed
5144 CurrentUOW.ActionCalled = true;
5145 CurrentUOW.GetGridCalled = true;
5146 CurrentUOW.RetGrid.Clear();
5147
5148 // Table-native path (engine-agnostic navigation). When a single selector resolves to a
5149 // <table> or its <tr> rows, the grid is extracted structurally below and we return early.
5150 // Everything else falls through to the unchanged column-per-selector zip.
5151 // a single selector that resolves to a <table> or to a set of <tr> rows is read structurally,
5152 // rows from the rows and columns from the cells, with the <th> row as the column names. Anything
5153 // else, which is every multi selector read and every literal column, falls through to the
5154 // column-per-selector zip below untouched
5155 if (TryGetGridFromTable(ref returnGrid))
5156 return this;
5157
5158 // Content-based row dedup, opt-in via .WithDedupeData(true) on the grid the caller passed in.
5159 // seenRows holds a key per row's extracted values. We seed it from the rows already in the caller's
5160 // grid so accumulation across GetGrid calls stays duplicate-free, not just within this one call.
5161 bool useDedupe = (returnGrid as GPALGrid<string>)?.UseDedupe ?? false;
5162 var seenRows = new HashSet<string>();
5163 if (true == useDedupe && null != returnGrid)
5164 foreach (List<string> existingRow in returnGrid)
5165 seenRows.Add(RowDedupeKey(existingRow));
5166 bool capReached = false;
5167
5168 for (int pages = 0; pages < CurrentUOW.PageCount && !capReached; pages++)
5169 {
5170 // With .InElement the selectors are relative and belong inside a container, so waiting on them
5171 // against the whole document says nothing and searches the page once for no reason. The container
5172 // is what the page is not ready without, and RowsFromContainers waits for that instead
5173 bool waitOnSelectors = null == CurrentUOW.ContextPath?
5174 .LastOrDefault(context => ElementType.Element == context.elementType && null != context.selector);
5175
5176 // check for persistent selectors on each new page
5177 if (!InAction(true == waitOnSelectors && WaitTime.Never < CurrentUOW.WaitForInMs))
5178 {
5179 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "WaitFor did not find the elements for the grid. Not retrieving anything.", this, GPALObjectType.Browser);
5180 return this;
5181 }
5182
5183 tmpColumns.Clear();
5184 columns.Clear();
5185 CurrentUOW.MatchedRowIndexes.Clear();
5186 selectorMeta.Clear();
5187
5188 BrowserHelper.CheckDocumentReady(this);
5189
5190 // iterate over selectors
5191 // .InElement matched containers, so a row is a container rather than a position across four
5192 // independent searches. Everything below this is untouched: it is handed the same columns,
5193 // the same selectorMeta and the same RowCount it would have built itself
5194 if (true == RowsFromContainers(tmpColumns, selectorMeta, pages))
5195 {
5196 columns = tmpColumns;
5197 }
5198 else
5199 foreach (Selector sel in CurrentUOW.WithSelectorList)
5200 {
5201 // cache is no longer used, but maybe the user might wanna look at tham for debugging?
5202 sel.WebSelectorFoundResults = null;
5203 sel.WebSelectorMatchedResults = null;
5204
5205 selectorMeta.Add((sel.Name, sel.AttributeName));
5206
5207 // we have selectors for elements and data/datafunction literals
5208 if (SelectorType.Selector == sel.selectorSettings.SelectorType)
5209 {
5210 bool matchedAll = false;
5211
5212 foundElements = ElementHelper.FindWebElements(this, CurrentUOW, sel, out matchedAll, out matchedElems);
5213
5214 // the only instance where matchedElems does not have what we want is if custom match doesn't return any...
5215 // if nothing matches, then create a psuedo "NOT FOUND" element for the grid
5216 if (matchedElems?.Count > 0)
5217 {
5218 foreach (GPALElement element in matchedElems)
5219 element.AttributeName = sel.AttributeName;
5220
5221 tmpColumns.Add(new ReadOnlyCollection<GPALElement>(matchedElems));
5222 }
5223 //else if (foundElements?.Count > 0) // silly check
5224 // tmpColumns.Add(new ReadOnlyCollection<GPALElement>(foundElements));
5225 else
5226 {
5227 var placeholder = new List<GPALElement>();
5228 int rows = CurrentUOW.RowCount > 0 ? CurrentUOW.RowCount : 0; // set from previous iteration, how many found for that selector?
5229 if (0 < rows)
5230 {
5231 for (int i = 0; i < rows; i++)
5232 placeholder.Add(new GPALElement(new Point(0, 0), new Size(1, 1), $"{GPAL.ErrorPlaceholder} : {sel.Name}", BrowserDriver, "GPALElement") { AttributeName = sel.AttributeName, Browser = this, Selector = sel });
5233 tmpColumns.Add(new ReadOnlyCollection<GPALElement>(placeholder));
5234 }
5235 }
5236
5237 if (matchedElems?.Count > 0)
5238 {
5239 CurrentUOW.ColCount++;
5240
5241 // set rowcount (found elements) for this iteration
5242 if (matchedAll) // found = matched
5243 CurrentUOW.RowCount = Math.Max(CurrentUOW.RowCount, matchedElems.Count);
5244 else if (matchedElems?.Count > 0) // matched match criteria
5245 {
5246 CurrentUOW.PartialMatch = true;
5247 int tmpIdx = 0;
5248 foreach (var elem in matchedElems)
5249 if ((tmpIdx = matchedElems.IndexOf(elem)) != -1 && !CurrentUOW.MatchedRowIndexes.ContainsKey(tmpIdx))
5250 CurrentUOW.MatchedRowIndexes.Add(tmpIdx, sel.AttributeName);
5251 CurrentUOW.RowCount = CurrentUOW.MatchedRowIndexes.Count;
5252 }
5253 else // we found no elements but need placeholders if the workflow specifies WithAllThatMatch (int.MaxValue means all, no end, limited by pages hopefully)
5254 CurrentUOW.RowCount = int.MaxValue != CurrentUOW.WithAllThatMatch ? CurrentUOW.WithAllThatMatch : 0;
5255 }
5256 }
5257 else if (SelectorType.Data == sel.selectorSettings.SelectorType) // use literal data
5258 {
5259 CurrentUOW.ColCount++;
5260 GPALElement tmpElement = GPAL.Element;
5261 tmpElement.Text = sel.SelectorPath;
5262 tmpColumns.Add(new ReadOnlyCollection<GPALElement>(new List<GPALElement>(1) { tmpElement }));
5263 }
5264 else if (SelectorType.DataFunc == sel.selectorSettings.SelectorType) // use literal data provided by function
5265 {
5266 CurrentUOW.ColCount++;
5267 GPALElement tmpElement = GPAL.Element;
5268 tmpElement.Text = sel.selectorSettings.DataFunction();
5269 tmpColumns.Add(new ReadOnlyCollection<GPALElement>(new List<GPALElement>(1) { tmpElement }));
5270 }
5271
5272 // one line per selector instead of one going in and one coming out. Not guarded on finding
5273 // something: a selector that found nothing is the case you most want to see in the log
5274 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Grid for [{sel.Name}] page [{pages + 1}] returned [{CurrentUOW.RowCount}] rows of [{tmpColumns.Count()}] columns", this, GPALObjectType.Browser);
5275 }
5276
5277 if (CurrentUOW.PartialMatch)
5278 {
5279 columns.Clear();
5280 foreach (var colElems in tmpColumns)
5281 {
5282 var newCol = new List<GPALElement>();
5283 // the matched indexes come from one selector, but they are applied to every column, and
5284 // columns are not all the same length yet - a literal data selector is one element, a
5285 // placeholder column can be shorter. an index past the end is always past the end, so
5286 // skipping it just leaves the tail short, and the padding loop below fills it out to
5287 // targetRows with placeholders exactly as it does for every other short column.
5288 foreach (var idx in CurrentUOW.MatchedRowIndexes.Keys)
5289 if (idx < colElems.Count)
5290 newCol.Add(colElems[idx]);
5291 columns.Add(new ReadOnlyCollection<GPALElement>(newCol));
5292 }
5293 }
5294 else if (0 == columns.Count)
5295 columns = tmpColumns;
5296
5297 // how many elements (rows of data) did we find on this page
5298 int pageRows = 0 < columns.Count ? columns.Max(c => c.Count) : 0;
5299 int targetRows = CurrentUOW.RowCount > 0 ? CurrentUOW.RowCount : pageRows;
5300
5301 // apply WithAllThatMatch per-page cap (only when n fits within one page)
5302 // total-cap and dedup are handled row-by-row during population below
5303 if (int.MaxValue != CurrentUOW.WithAllThatMatch && CurrentUOW.WithAllThatMatch <= pageRows)
5304 targetRows = Math.Min(targetRows, CurrentUOW.WithAllThatMatch);
5305
5306 // how many colums did we get? should be the number of selectors
5307 // now create our columns to iterate, whatever had the most, that is what we will fill all rows to
5308 for (int i = 0; i < columns.Count; i++)
5309 {
5310 var col = columns[i].ToList();
5311 while (col.Count < targetRows) // one of the selectors has more rows than this one, so create a placeholder
5312 {
5313 // for scrollintoview, we need something...
5314 var firstNonEmpty = columns.FirstOrDefault(c => c.Count > 0)?.FirstOrDefault();
5315 var meta = selectorMeta[i];
5316 col.Add(new GPALElement(new Point(0, 0), new Size(1, 1), $"{GPAL.ErrorPlaceholder} : {meta.Name}", BrowserDriver, "GPALElement") { AttributeName = meta.AttributeName, Browser = this, Css = firstNonEmpty?.Css, ElementBackendNodeId = (int)(firstNonEmpty?.ElementBackendNodeId) });
5317 }
5318 columns[i] = new ReadOnlyCollection<GPALElement>(col);
5319 }
5320 CurrentUOW.RowCount = targetRows;
5321
5322 // Populate RetGrid row-major. Dedup (when opted in) is content-based: we key each row by its
5323 // extracted values, so a row is dropped if its data duplicates one already accumulated (seenRows
5324 // is seeded from the caller's grid) or one seen earlier this call. This composes with the
5325 // grid-accumulation contract and needs no per-element attribute hashing.
5326 // WithAllThatMatch total-cap stops accumulation once we have enough unique rows
5327 for (int r = 0; r < CurrentUOW.RowCount && !capReached; r++)
5328 {
5329 var row = new List<string>();
5330 for (int c = 0; c < columns.Count; c++)
5331 {
5332 var elem = columns[c][r];
5333
5334 // whatever item we are scraping, bring it into view
5335 // for UX
5336 if (true == BrowserSettings.ScrollIntoView)
5337 if (false == BrowserSettings.UseHeadless && false == "GPALElement".Equals(elem.TagName))
5338 if (true == UseOttoMagic)
5339 {
5340 if (false == string.IsNullOrEmpty(elem.Css))
5341 MagicHelper.ScrollIntoView(elem.Css);
5342 }
5343 else if (true == UsePuppeteer)
5344 PuppeteerClient.ScrollIntoView(elem).Execute();
5345 else
5346 ElementHelper.ScrollIntoView(this, elem);
5347
5348 if (!string.IsNullOrEmpty(elem.AttributeName))
5349 row.Add(elem.GetAttribute(elem.AttributeName));
5350 else if (null != elem.TagName)
5351 {
5352 switch (elem.TagName?.ToLower())
5353 {
5354 case "a": row.Add(elem.GetAttribute("href")); break;
5355 case "img": row.Add(elem.GetAttribute("src")); break;
5356 default: row.Add(elem.Text); break;
5357 }
5358 }
5359 else // literal data
5360 row.Add(elem.Text);
5361 }
5362
5363 // DEDUPE: skip this row if its data matches one already in the grid or seen this call
5364 if (true == useDedupe && false == seenRows.Add(RowDedupeKey(row)))
5365 continue;
5366
5367 CurrentUOW.RetGrid.AddRow(row);
5368 if (int.MaxValue != CurrentUOW.WithAllThatMatch && CurrentUOW.WithAllThatMatch > pageRows
5369 && CurrentUOW.RetGrid.Count() >= CurrentUOW.WithAllThatMatch)
5370 capReached = true;
5371 }
5372
5373 if (!capReached && pages < CurrentUOW.PageCount - 1)
5374 {
5375 BrowserHelper.TopBrowser(this, false);
5376 if (!BrowserHelper.GotoNextPage(this))
5377 {
5378 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"No next page, breaking loop. Retrieved [{pages + 1}] out of [{CurrentUOW.PageCount}] pages.", this, GPALObjectType.Browser);
5379 break;
5380 }
5381 }
5382 }
5383
5384 // Hand back the results without taking ownership of a grid the caller passed in:
5385 // - null in -> the caller just wants a grid, so give them GPAL's internal one.
5386 // - grid in -> the caller owns it (and may have configured it, e.g. WithDedupeData); add our rows
5387 // into THEIR grid and keep their reference so repeated GetGrid calls can accumulate.
5388 if (null == returnGrid)
5389 returnGrid = (IGPALGrid<string>)CurrentUOW.RetGrid;
5390 else
5391 returnGrid.Add((IGPALGrid<string>)CurrentUOW.RetGrid);
5392
5393 return this;
5394 }
5395
5400 private static string RowDedupeKey(List<string> row)
5401 {
5402 return string.Join("\u001F", row);
5403 }
5404
5405 // ─────────────────────────────────────────────────────────────────────────────────────
5406 // Table-native GetGrid (engine-agnostic). Wedged into GetGrid above; does not touch the
5407 // column-per-selector zip. A single selector that resolves to a <table> (or its <tr> rows)
5408 // is walked structurally with the same ElementHelper.FindWebElements dispatch used
5409 // everywhere else, honoring colspan/rowspan, deriving headers from <th>, filling gaps with
5410 // GPAL.ErrorPlaceholder, and populating CurrentUOW.RetGrid so SaveTo*/pagination just work.
5411 // NOTE: OttoMagic/Puppeteer scope a child query by the parent element's Css, so the table
5412 // and its rows must carry a usable Css for those engines (Selenium scopes by IWebElement).
5413 // NOTE: deeply nested tables are a known limitation (descendant tr/td/th matches inner cells).
5414 // ─────────────────────────────────────────────────────────────────────────────────────
5415 private bool TryGetGridFromTable(ref IGPALGrid<string> returnGrid)
5416 {
5417 // Only a single element-selector request can be table-native. Multiple selectors are the
5418 // column-per-selector zip, and literal Data/DataFunc columns are never tables.
5419 if (1 != CurrentUOW.WithSelectorList.Count
5420 || SelectorType.Selector != CurrentUOW.WithSelectorList[0].selectorSettings.SelectorType)
5421 return false;
5422
5423 Selector sel = CurrentUOW.WithSelectorList[0];
5424
5425 // Probe once to learn the target type. If it is not a <table> or a set of <tr> rows,
5426 // this is not table-native; return false and let the untouched column-zip path run.
5427 bool matchedAll; List<GPALElement> matchedElems;
5428 ReadOnlyCollection<GPALElement> seeds = ElementHelper.FindWebElements(this, CurrentUOW, sel, out matchedAll, out matchedElems);
5429 string tag = (null != seeds && seeds.Count > 0) ? seeds[0].TagName?.ToLowerInvariant() : null;
5430 bool isTable = "table" == tag;
5431 bool isRows = "tr" == tag;
5432 if (!isTable && !isRows)
5433 return false;
5434
5435 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"GetGrid: [{sel.Name}] resolved to <{tag}>; extracting the table structurally.", this, GPALObjectType.Browser);
5436
5437 var seenRows = new HashSet<string>();
5438 List<string> headers = null;
5439 bool capReached = false;
5440 int cap = (int.MaxValue == CurrentUOW.WithAllThatMatch)
5441 ? int.MaxValue : CurrentUOW.WithAllThatMatch;
5442
5443 for (int page = 0; page < CurrentUOW.PageCount && !capReached; page++)
5444 {
5445 if (page > 0)
5446 {
5447 seeds = ElementHelper.FindWebElements(this, CurrentUOW, sel, out matchedAll, out matchedElems);
5448 if (null == seeds || 0 == seeds.Count)
5449 break;
5450 }
5451 BrowserHelper.CheckDocumentReady(this);
5452
5453 // Gather the body row elements for this page (and headers, once).
5454 var bodyRows = new List<GPALElement>();
5455 if (isTable)
5456 {
5457 foreach (GPALElement table in seeds)
5458 {
5459 var headerRows = FindWithin(table, "thead tr");
5460 var tableBody = FindWithin(table, "tbody tr");
5461 if (0 == tableBody.Count && 0 == headerRows.Count)
5462 {
5463 // no thead/tbody: treat a leading all-<th> row as the header
5464 var all = FindWithin(table, "tr");
5465 if (all.Count > 0 && RowIsAllHeader(all[0]))
5466 {
5467 headerRows.Add(all[0]);
5468 tableBody.AddRange(all.Skip(1));
5469 }
5470 else
5471 tableBody.AddRange(all);
5472 }
5473 else if (0 == headerRows.Count && 0 < tableBody.Count && true == RowIsAllHeader(tableBody[0]))
5474 {
5475 // a table that names its columns in a leading all-<th> row and has no thead. The
5476 // browser puts a tbody around loose rows itself, so that row arrives as the first
5477 // body row rather than as no body rows at all
5478 headerRows.Add(tableBody[0]);
5479 tableBody.RemoveAt(0);
5480 }
5481 if (null == headers && headerRows.Count > 0)
5482 {
5483 var hmatrix = BuildTableMatrix(headerRows);
5484 if (hmatrix.Count > 0)
5485 headers = hmatrix[0]; // first header row, span-aligned to the data
5486 }
5487 bodyRows.AddRange(tableBody);
5488 }
5489 }
5490 else // the seeds themselves are the rows
5491 {
5492 bodyRows.AddRange(seeds);
5493 }
5494
5495 // Build a rectangular, span-aware matrix of cell text for these rows.
5496 List<List<string>> matrix = BuildTableMatrix(bodyRows);
5497
5498 foreach (List<string> row in matrix)
5499 {
5500 string key = string.Join("␁", row);
5501 if (!seenRows.Add(key))
5502 continue; // duplicate row from a prior page/scroll pass
5503 CurrentUOW.RetGrid.AddRow(row);
5504 if (CurrentUOW.RetGrid.Count() >= cap)
5505 {
5506 capReached = true;
5507 break;
5508 }
5509 }
5510
5511 if (!capReached && page < CurrentUOW.PageCount - 1)
5512 {
5513 BrowserHelper.TopBrowser(this, false);
5514 if (!BrowserHelper.GotoNextPage(this))
5515 {
5516 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"GetGrid: no next page after page [{page + 1}].", this, GPALObjectType.Browser);
5517 break;
5518 }
5519 }
5520 }
5521
5522 // the table's own column names, kept apart from HeaderList the way the application side keeps a
5523 // control's: WithHeader clears HeaderList, and a name the table gave for itself still beats a
5524 // generated one, so ResolveHeaderColumns reads them from here
5525 if (null != headers && headers.Count > 0)
5526 {
5527 CurrentUOW.ControlHeaderList = headers;
5528
5529 if (null == CurrentUOW.HeaderList || 0 == CurrentUOW.HeaderList.Count)
5530 CurrentUOW.HeaderList = headers;
5531 }
5532
5533 // what the table called its columns, readable after the grid rather than only visible in a file
5534 ColumnNames = null == headers ? new List<string>() : new List<string>(headers);
5535
5536 returnGrid = (IGPALGrid<string>)CurrentUOW.RetGrid;
5537 return true;
5538 }
5539
5540 // Engine-agnostic scoped query: elements matching css *within* a parent element, using the
5541 // same ElementHelper.FindWebElements dispatch (Selenium/Puppeteer/OttoMagic) as the rest of GPAL.
5542 private List<GPALElement> FindWithin(GPALElement parent, string css)
5543 {
5544 Selector scoped = GPAL.Selector.WithCSS(css).ToGPALObject();
5545 bool matchedAll; List<GPALElement> matchedElems;
5546 ReadOnlyCollection<GPALElement> found = ElementHelper.FindWebElements(this, CurrentUOW, scoped, out matchedAll, out matchedElems, webElement: parent);
5547 return (null != found) ? found.ToList() : new List<GPALElement>();
5548 }
5549
5550 // True when a <tr>'s cells are all <th> (a header row when there is no <thead>).
5551 private bool RowIsAllHeader(GPALElement tr)
5552 {
5553 var cells = FindWithin(tr, "td, th");
5554 if (0 == cells.Count)
5555 return false;
5556 foreach (GPALElement c in cells)
5557 if (!"th".Equals(c.TagName, StringComparison.OrdinalIgnoreCase))
5558 return false;
5559 return true;
5560 }
5561
5562 // Turn <tr> elements into a rectangular grid of cell strings, honoring colspan and rowspan.
5563 // Spanned cells repeat their text across covered positions; structural gaps and short rows
5564 // are filled with GPAL.ErrorPlaceholder so every row has the same column count.
5565 private List<List<string>> BuildTableMatrix(List<GPALElement> rowElems)
5566 {
5567 var matrix = new List<List<string>>();
5568 // pending rowspans: column index -> (remaining future rows, text)
5569 var pending = new Dictionary<int, KeyValuePair<int, string>>();
5570
5571 foreach (GPALElement tr in rowElems)
5572 {
5573 var cells = FindWithin(tr, "td, th");
5574 var row = new List<string>();
5575 int col = 0, ci = 0;
5576
5577 while (ci < cells.Count || pending.Keys.Any(k => k >= col))
5578 {
5579 KeyValuePair<int, string> carry;
5580 if (pending.TryGetValue(col, out carry))
5581 {
5582 row.Add(carry.Value);
5583 int left = carry.Key - 1;
5584 if (left > 0) pending[col] = new KeyValuePair<int, string>(left, carry.Value);
5585 else pending.Remove(col);
5586 col++;
5587 continue;
5588 }
5589
5590 if (ci < cells.Count)
5591 {
5592 GPALElement cell = cells[ci++];
5593 string text = CellText(cell);
5594 int cspan = ParseSpan(cell, "colspan");
5595 int rspan = ParseSpan(cell, "rowspan");
5596 for (int c = 0; c < cspan; c++)
5597 {
5598 row.Add(text);
5599 if (rspan > 1) pending[col] = new KeyValuePair<int, string>(rspan - 1, text);
5600 col++;
5601 }
5602 }
5603 else
5604 {
5605 // a rowspan carry sits further right but there is no cell here: keep alignment
5606 row.Add(GPAL.ErrorPlaceholder);
5607 col++;
5608 }
5609 }
5610
5611 matrix.Add(row);
5612 }
5613
5614 // pad every row to the widest so the grid is rectangular
5615 int width = 0;
5616 foreach (var r in matrix) if (r.Count > width) width = r.Count;
5617 foreach (var r in matrix) while (r.Count < width) r.Add(GPAL.ErrorPlaceholder);
5618
5619 return matrix;
5620 }
5621
5622 private static int ParseSpan(GPALElement cell, string attr)
5623 {
5624 int n;
5625 string v = cell.GetAttribute(attr);
5626 return (int.TryParse(v, out n) && n > 0) ? n : 1;
5627 }
5628
5629 // Cell text read the same way the column-zip path reads cells, so both paths agree.
5630 private string CellText(GPALElement cell)
5631 {
5632 if (!string.IsNullOrEmpty(cell.AttributeName))
5633 return cell.GetAttribute(cell.AttributeName);
5634 switch (cell.TagName?.ToLowerInvariant())
5635 {
5636 case "a": return cell.GetAttribute("href");
5637 case "img": return cell.GetAttribute("src");
5638 default: return cell.Text ?? string.Empty;
5639 }
5640 }
5641
5692 public IAllowCallTemplate WithCallFilter(string urlFragment)
5693 {
5694 BrowserSettings.CallFilter = urlFragment;
5695
5696 return this;
5697 }
5698
5718 {
5719 template = null;
5720
5721 // waiting is pointless where nothing is recording, so this says so now rather than in thirty seconds
5722 if (false == CanCaptureCalls)
5723 {
5724 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"No template: capturing calls is not available on [{BrowserSettings.AutomationEngine}]", this, GPALObjectType.Browser);
5725
5726 return this;
5727 }
5728
5729 CaptureCalls(true);
5730
5731 GPALCall found = null;
5732 Stopwatch waited = Stopwatch.StartNew();
5733
5734 while (null == found && CallTemplateWaitMilliseconds > waited.ElapsedMilliseconds)
5735 {
5736 CaptureCalls(out List<GPALCall> calls);
5737
5738 // the last match, not the last call. the filter narrows what is recorded from here on, but
5739 // anything captured before it was set is still in the list, so it is applied again here
5740 foreach (GPALCall call in calls)
5741 if (true == string.IsNullOrEmpty(BrowserSettings.CallFilter) || true == call.Url?.Contains(BrowserSettings.CallFilter))
5742 found = call;
5743
5744 if (null == found)
5745 System.Threading.Thread.Sleep(250);
5746 }
5747
5748 waited.Stop();
5749
5750 if (null == found)
5751 {
5752 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"No call matching [{BrowserSettings.CallFilter}] in [{waited.ElapsedMilliseconds}]ms, so there is no template", this, GPALObjectType.Browser);
5753
5754 return this;
5755 }
5756
5757 template = (GPALRequest)GPAL.Request
5758 .WithPath(found.Url)
5759 .WithHttpMethod("POST".Equals(found.Method, StringComparison.OrdinalIgnoreCase) ? Enums.HttpVerb.Post : Enums.HttpVerb.Get)
5760 .WithBody(found.PostData)
5761 .WithName(BrowserSettings.CallFilter ?? found.Url);
5762
5763 // a browser sets these for itself and a fetch refuses to be told otherwise, so a template carrying
5764 // them would be a template that cannot be sent
5765 foreach (KeyValuePair<string, string> header in found.Headers)
5766 if (false == BrowserHelper.BrowserOwnedHeader(header.Key))
5767 template.WithHeader($"{header.Key}: {header.Value}");
5768
5769 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Template taken from the page's own [{found.Method}] to [{found.Url}] after [{waited.ElapsedMilliseconds}]ms, with [{template.Headers.Count}] of its headers", this, GPALObjectType.Browser);
5770
5771 return this;
5772 }
5773
5774 // how long to wait for the page to make the call a template is wanted from
5775 private const int CallTemplateWaitMilliseconds = 30_000;
5776
5777 // puppeteer reports requests over CDP, the extension watches with webRequest, and selenium records them
5778 // in the page and needs a driver to inject through. all three can, by different means
5779 private bool CanCaptureCalls => true == BrowserSettings.UsePuppeteer
5780 || true == BrowserSettings.UseOttoMagic
5781 || null != BrowserSettings.BrowserDriver;
5782
5783 public IAllowBrowserActionOrAnySelector CaptureCalls(bool capture = true)
5784 {
5785 // the browser reports its requests over CDP, which is Puppeteer's channel. selenium would need CDP
5786 // wired up the same way and the extension would need its own webRequest listener, so this says so
5787 // rather than recording nothing and looking like a page that asked for nothing
5788 // said once per browser, not once per ask. a workflow waiting for a template asks in a loop, and an
5789 // engine that cannot capture would otherwise say so a hundred times while it waits for nothing
5790 if (false == CanCaptureCalls)
5791 {
5792 if (false == BrowserSettings.CaptureUnavailableReported)
5793 {
5794 BrowserSettings.CaptureUnavailableReported = true;
5795
5796 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Capturing calls is not available on [{BrowserSettings.AutomationEngine}]. It needs a browser that reports its requests, and this one has none started", this, GPALObjectType.Browser);
5797 }
5798
5799 return this;
5800 }
5801
5802 if (true == BrowserSettings.UseOttoMagic)
5803 {
5804 // the extension watches with webRequest, from outside the page. nothing is injected, so this
5805 // survives a navigation on its own and sees the requests no page script issued
5806 BrowserSettings.CaptureCalls = capture;
5807
5808 MagicHelper.CaptureCalls(capture, BrowserSettings.CallFilter);
5809 }
5810 else if (false == BrowserSettings.UsePuppeteer && null != BrowserSettings.BrowserDriver)
5811 {
5812 // selenium has no CDP event stream, so the page records for itself. registered to run on every
5813 // document where chromium allows it, and injected into the page in hand either way, so saying
5814 // this after a navigation still starts recording
5815 BrowserSettings.CaptureCalls = capture;
5816
5817 if (true == capture)
5818 {
5819 if (BrowserType.Chrome == BrowserSettings.BrowserType || BrowserType.Edge == BrowserSettings.BrowserType)
5820 BrowserHelper.SeleniumAddScriptToEvaluateOnNewDocument(BrowserSettings, BrowserHelper.CallRecorderScript);
5821
5822 BrowserHelper.ExecuteJavaScript(this, BrowserHelper.CallRecorderScript);
5823 }
5824 }
5825 else
5826 {
5827 // recorded as a setting first. saying this before going anywhere is the ordinary way to write it,
5828 // and reaching for the communicator now would build one to talk to a browser that has not started
5829 BrowserSettings.CaptureCalls = capture;
5830
5831 // a communicator that exists is a browser that is up, so it can be told now. asking for one that
5832 // does not exist would build it, and the Puppeteer engine has no Selenium driver to test instead
5833 if (true == BrowserSettings.HasPuppeteerCommunicator)
5834 BrowserSettings.PuppeteerCommunicator.CaptureCalls(capture).GetAwaiter().GetResult();
5835 }
5836
5837 return this;
5838 }
5839
5855 public IAllowBrowserActionOrAnySelector CaptureCalls(out List<GPALCall> calls)
5856 {
5857 CaptureCalls(true);
5858
5859 calls = true == BrowserSettings.UsePuppeteer
5860 ? true == BrowserSettings.HasPuppeteerCommunicator
5861 ? new List<GPALCall>(BrowserSettings.PuppeteerCommunicator.CapturedCalls)
5862 : new List<GPALCall>()
5863 : true == BrowserSettings.UseOttoMagic
5864 ? ExtensionRecordedCalls()
5865 : RecordedCalls();
5866
5867 // debug, not info. taking a snapshot is something a workflow does in a loop while it waits, and a
5868 // line per attempt buries the run. the template says the thing worth reading
5869 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Handing back [{calls.Count}] captured calls.", this, GPALObjectType.Browser);
5870
5871 return this;
5872 }
5873
5885 {
5886 if (true == BrowserSettings.UseOttoMagic)
5887 // the recorder keeps recording, and clears what it holds on the way through
5888 MagicHelper.CaptureCalls(BrowserSettings.CaptureCalls, BrowserSettings.CallFilter, true);
5889 else if (true == BrowserSettings.UsePuppeteer)
5890 {
5891 if (true == BrowserSettings.HasPuppeteerCommunicator)
5892 BrowserSettings.PuppeteerCommunicator.ClearCapturedCalls();
5893 }
5894 else if (null != BrowserSettings.BrowserDriver)
5895 // the page holds its own, so the page is where it is emptied
5896 BrowserHelper.ExecuteJavaScript(this, "window.__gpalCalls = []; return true;");
5897
5898 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Forgot the calls captured so far.", this, GPALObjectType.Browser);
5899
5900 return this;
5901 }
5902
5903 // webRequest and CDP name the same kinds of request differently: main_frame against Document,
5904 // xmlhttprequest against XHR. a workflow that filters on the type should not have to know which engine
5905 // recorded it, so the extension's names are put into CDP's before anyone sees them
5906 private static string CdpResourceType(string webRequestType)
5907 {
5908 string retVal = webRequestType;
5909
5910 switch (webRequestType)
5911 {
5912 case "main_frame":
5913 case "sub_frame": retVal = "Document"; break;
5914 case "xmlhttprequest": retVal = "XHR"; break;
5915 case "script": retVal = "Script"; break;
5916 case "image":
5917 case "imageset": retVal = "Image"; break;
5918 case "stylesheet": retVal = "Stylesheet"; break;
5919 case "font": retVal = "Font"; break;
5920 case "media": retVal = "Media"; break;
5921 case "websocket": retVal = "WebSocket"; break;
5922 case "ping": retVal = "Ping"; break;
5923 case "csp_report": retVal = "CSPViolationReport"; break;
5924
5925 // webRequest puts both XHR and fetch under xmlhttprequest, where CDP tells them apart. a
5926 // workflow filtering for the site's own api wants both either way, so nothing is lost.
5927 // "other" is the catch-all for what fits nowhere else, and is left saying exactly that
5928 case "other": retVal = "Other"; break;
5929 }
5930
5931 return retVal;
5932 }
5933
5934 // what the extension recorded, read back as the same shape CDP produces. webRequest watches the browser
5935 // rather than the page, so unlike the selenium recorder this holds the documents, scripts and images too
5936 private List<GPALCall> ExtensionRecordedCalls()
5937 {
5938 List<GPALCall> retVal = new List<GPALCall>();
5939
5940 string json = MagicHelper.GetCapturedCalls();
5941
5942 if (true == string.IsNullOrWhiteSpace(json)) return retVal;
5943
5944 // GetCapturedCalls hands back the recorder's snapshot as the json text of one object
5945 json = json.Trim();
5946
5947 Newtonsoft.Json.Linq.JObject snapshot;
5948
5949 // a snapshot that cannot be read is the one failure that looks exactly like a page that made no
5950 // calls, so it says what it got rather than handing back an empty list
5951 try
5952 {
5953 snapshot = Newtonsoft.Json.Linq.JObject.Parse(json);
5954 }
5955 catch (GPALException)
5956 {
5957 throw;
5958 }
5959 catch (Exception ex)
5960 {
5961 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"The captured calls could not be read. [{json.Length}] characters starting [{json.Substring(0, Math.Min(120, json.Length))}]", this, GPALObjectType.Browser, ex);
5962
5963 return retVal;
5964 }
5965
5966 // the recorder keeps the newest and counts what fell off, so a workflow that finds nothing can tell
5967 // a page that never called it from a page that called too much
5968 int dropped = (int?)snapshot["dropped"] ?? 0;
5969
5970 if (0 < dropped)
5971 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"The recorder dropped [{dropped}] older calls to stay within its limit", this, GPALObjectType.Browser);
5972
5973 foreach (Newtonsoft.Json.Linq.JObject recorded in snapshot["calls"] ?? new Newtonsoft.Json.Linq.JArray())
5974 {
5975 GPALCall call = new GPALCall
5976 {
5977 Url = recorded["url"]?.ToString(),
5978 Method = recorded["method"]?.ToString(),
5979 ResourceType = CdpResourceType(recorded["type"]?.ToString()),
5980 PostData = recorded["postData"]?.ToString(),
5981 Initiator = recorded["initiator"]?.ToString(),
5982 Status = (int?)recorded["status"] ?? 0
5983 };
5984
5985 if (recorded["headers"] is Newtonsoft.Json.Linq.JObject headers)
5986 foreach (KeyValuePair<string, Newtonsoft.Json.Linq.JToken> header in headers)
5987 call.Headers[header.Key] = header.Value?.ToString();
5988
5989 retVal.Add(call);
5990 }
5991
5992 return retVal;
5993 }
5994
5995 // what the page recorded for itself, read back as the same shape CDP produces. the browser's own requests
5996 // for documents, scripts and images never went through fetch or XMLHttpRequest, so they are not here
5997 private List<GPALCall> RecordedCalls()
5998 {
5999 List<GPALCall> retVal = new List<GPALCall>();
6000
6001 if (null == BrowserSettings.BrowserDriver) return retVal;
6002
6003 string json = BrowserHelper.ExecuteJavaScript(this, "return JSON.stringify(window.__gpalCalls || []);");
6004
6005 if (true == string.IsNullOrWhiteSpace(json)) return retVal;
6006
6007 foreach (Newtonsoft.Json.Linq.JObject recorded in Newtonsoft.Json.Linq.JArray.Parse(json))
6008 {
6009 GPALCall call = new GPALCall
6010 {
6011 Url = recorded["url"]?.ToString(),
6012 Method = recorded["method"]?.ToString(),
6013 ResourceType = recorded["type"]?.ToString(),
6014 PostData = recorded["postData"]?.ToString(),
6015 Initiator = "script",
6016 Status = (int?)recorded["status"] ?? 0
6017 };
6018
6019 if (recorded["headers"] is Newtonsoft.Json.Linq.JObject headers)
6020 foreach (KeyValuePair<string, Newtonsoft.Json.Linq.JToken> header in headers)
6021 call.Headers[header.Key] = header.Value?.ToString();
6022
6023 retVal.Add(call);
6024 }
6025
6026 return retVal;
6027 }
6028
6029 public IAllowWithHeaderOrFileActions Fetch(GPALRequest request)
6030 {
6031 CurrentUOW.FetchResults.Clear();
6032
6033 if (null == request)
6034 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "No request given to fetch.", this, GPALObjectType.Browser);
6035 else
6036 BrowserHelper.FetchWithTokens(this, request);
6037
6038 InAction(false);
6039 return this;
6040 }
6048 {
6049 List<string> values = new List<string>();
6050
6051 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Handing back [{CurrentUOW.FetchResults.Count}] fetch responses.", this, GPALObjectType.Browser);
6052
6053 // the array has to parse whatever came back, or saving it was pointless. a json body is spliced in as
6054 // it stands, and anything else, an html deny page or a body carrying the http warning in front of it,
6055 // goes in as a json string. still readable, still an array
6056 foreach (string body in CurrentUOW.FetchResults)
6057 values.Add(true == StringFormatDetector.IsJson(body.Trim()) ? body : Newtonsoft.Json.JsonConvert.SerializeObject(body));
6058
6059 data = "[" + string.Join(",", values) + "]";
6060 return this;
6061 }
6063 {
6064 //GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Saving [{CurrentUOW.RetGrid?.Rows ?? 0}] rows of page data to [{file?.Filename}].", this, GPALObjectType.Browser);
6065
6066 var converter = GPAL.Converter.WithInput(CurrentUOW.RetGrid).WithColumnNames(ResolveHeaderColumns(file));
6067 // Naming headers via WithHeader is clear intent to write them; declare it so the output emits a
6068 // header row unless the output file explicitly turns it off (WithFirstLineIsColumnNames(false)).
6069 if (null != CurrentUOW.HeaderList && 0 < CurrentUOW.HeaderList.Count)
6070 converter.WithFirstLineHasColumnNames(true);
6071 converter.SaveTo(file);
6072 return this;
6073 }
6074
6080 {
6081 //GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Appending [{CurrentUOW.RetGrid?.Rows ?? 0}] rows of page data to [{file?.Filename}].", this, GPALObjectType.Browser);
6082
6083 GPAL.Converter.WithInput(CurrentUOW.RetGrid).WithColumnNames(ResolveHeaderColumns(file)).AppendTo(file);
6084 return this;
6085 }
6086
6087 // Resolve the output column names for a grid save, handed to the Converter as column metadata
6088 // (WithColumnNames), NOT injected as a data row. Precedence, highest first, with a short higher list
6089 // topped up from the next level down:
6090 // 1. CurrentUOW.HeaderList - the workflow's .WithHeader columns (override)
6091 // 2. the output GPALFile's ColumnList - the file's own columns (wildcard/.Next replication of the
6092 // first entry happens at expansion time, so entry 0 is representative here)
6093 // 3. selector names - the ultimate global, always defined
6094 // 4. the control's own column names, e.g. a UIA TablePattern's headers
6095 // 5. generated column1..columnN - so the list is never narrower than the widest row, because a
6096 // column with no name must never mean a column with no data
6097 // The result is then made unique: the writer keys cells by column name, so a repeated name would
6098 // collapse those columns into one holding the last value.
6099 // Whether the names are written is governed by the output file's FirstLineIsColumnNames.
6100 private List<string> ResolveHeaderColumns(GPALFile file)
6101 {
6102 List<string> selectorNames = CurrentUOW.WithSelectorList?.Where(s => null != s.Name).Select(s => s.Name).ToList()
6103 ?? new List<string>();
6104
6105 List<string> fileColumns = new List<string>();
6106 IGPALGrid<string> columnList = ((IGPALFileInternal)file).FileSettings.ColumnList;
6107 if (null != columnList && 0 < columnList.Count() && null != columnList[0])
6108 fileColumns = new List<string>(columnList[0]);
6109
6110 List<string> resolved = (null != CurrentUOW.HeaderList && 0 < CurrentUOW.HeaderList.Count)
6111 ? new List<string>(CurrentUOW.HeaderList)
6112 : new List<string>();
6113 for (int i = resolved.Count; i < fileColumns.Count; i++)
6114 resolved.Add(fileColumns[i]);
6115 for (int i = resolved.Count; i < selectorNames.Count; i++)
6116 resolved.Add(selectorNames[i]);
6117
6118 // a name the control gave for itself beats a made up one, and WithHeader clears HeaderList, so the
6119 // control's names are kept apart and read from here
6120 List<string> controlHeaders = CurrentUOW.ControlHeaderList ?? new List<string>();
6121
6122 for (int i = resolved.Count; i < controlHeaders.Count; i++)
6123 resolved.Add(controlHeaders[i]);
6124
6125 // the converter writes the columns it has names for, so a name list shorter than the data would
6126 // drop everything past the end of it. Data is never trimmed to fit the names, the names are made
6127 // up to fit the data, so every cell reaches the file whatever was named
6128 int widest = 0;
6129
6130 if (null != CurrentUOW.RetGrid)
6131 foreach (List<string> row in CurrentUOW.RetGrid)
6132 if (null != row && widest < row.Count)
6133 widest = row.Count;
6134
6135 for (int i = resolved.Count; i < widest; i++)
6136 resolved.Add($"column{i + 1}");
6137
6138 return UniqueColumns(resolved);
6139 }
6140
6153 private static List<string> UniqueColumns(List<string> columns)
6154 {
6155 var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
6156 var result = new List<string>();
6157
6158 for (int i = 0; i < columns.Count; i++)
6159 {
6160 string name = true == string.IsNullOrWhiteSpace(columns[i]) ? $"column{i + 1}" : columns[i].Trim();
6161 string candidate = name;
6162 int repeat = 1;
6163
6164 while (false == used.Add(candidate))
6165 {
6166 repeat++;
6167 candidate = $"{name}{repeat}";
6168 }
6169
6170 result.Add(candidate);
6171 }
6172
6173 return result;
6174 }
6184 public IAllowAfterWaitFor WaitFor(WaitTime waitForTimeInMs)
6185 {
6186 // we are done waiting on any selectors since we performed an action
6187 // so, we just wait/sleep
6188 if (true == CurrentUOW.ActionCalled)
6189 {
6190 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Sleeping for [{waitForTimeInMs}] ms", this, GPALObjectType.Browser);
6191 Thread.Sleep(waitForTimeInMs);
6192 }
6193 else
6194 CurrentUOW.WaitForInMs = waitForTimeInMs;
6195
6196 return this;
6197 }
6198
6203 public IAllowAfterWaitFor WaitFor(Selector waitForSelector)
6204 {
6205 bool weSetWaitFor = false;
6206 WaitTime safe = WaitTime.Never;
6207
6208 // never (can't wait on a selector 'never') so we will set a time and restore
6209 if (WaitTime.Never == CurrentUOW.WaitForInMs)
6210 {
6211 safe = CurrentUOW.WaitForInMs;
6212 CurrentUOW.WaitForInMs = WaitTime.Immediate;
6213 weSetWaitFor = true;
6214 }
6215
6216 WithSelector(waitForSelector); // this will create a new UOW with waittimeinms = never (or GPAL.WaitFor global setting)
6217 WorkflowSetup(true, true); // this will set CurrentUOW.ActionCalled = true, so this is it's own action
6218
6219 if (true == weSetWaitFor)
6220 CurrentUOW.WaitForInMs = safe;
6221
6222 return this;
6223 }
6224
6230 public IAllowAfterWaitFor WaitFor(ElementState elementState)
6231 {
6232 if (ElementState.NotSet != elementState)
6233 {
6234 CurrentUOW.WaitForElementState = elementState;
6235
6236 if (true == CurrentUOW.ActionCalled) // do this now, we are running the workflow
6237 ElementHelper.WaitFor(this, CurrentUOW.WaitForInMs, out bool matchedAll);
6238 }
6239 else
6240 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "ElementState NOTSET is not a valid wait condition. Please select a valid ElementState.", null, GPALObjectType.None);
6241
6242 return this;
6243 }
6244
6251 {
6252 FormHelper.WaitForWindow(waitForTitle, BrowserSettings.WaitForWindowTimeoutInSeconds);
6253 return this;
6254 }
6255
6262 {
6263 FormHelper.WaitForWindowRegex(waitForTitleRegex, BrowserSettings.WaitForWindowTimeoutInSeconds);
6264 return this;
6265 }
6266
6271 public IAllowBrowserActionOrAnySelector WithPageOrientation(PageOrientation pageOrientation)
6272 {
6273 BrowserSettings.PageOrientation = pageOrientation;
6274 return this;
6275 }
6276 // GPAL.Browser.GoTo("ebay.com").WithSelector(selectorToPrint).PrintToPDF("filename.pdf"); // print element and contents
6277 // GPAL.Browser.GoTo("ebay.com").WithSelector(first).WithSelector(second).PrintToPDF("filename.pdf"); // one pdf each
6278 // GPAL.Browser.GoTo("ebay.com").PrintToPDF("filename.pdf"); // print whole page
6289 {
6290 List<string> selectorPaths = new List<string>();
6291 bool named = false;
6292
6293 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
6294 {
6295 foreach (Selector selector in CurrentUOW.WithSelectorList)
6296 if (SelectorType.Selector == selector.SelectorType // we don't look up data literals
6297 && 0 < (selector.SelectorPaths?.Count ?? 0)
6298 && false == string.IsNullOrEmpty(selector.SelectorPaths[0].SelectorPath))
6299 {
6300 named = true;
6301
6302 // an image selector's path is the picture's file, so the element under the picture is what
6303 // gets printed
6304 // printing names its element by css, so an XPath selector hands over the css path of what it
6305 // matches
6306 string css = SelectorPathType.Image == selector.SelectorPaths[0].SelectorPathType
6307 ? ElementHelper.CssUnderImage(this, selector)
6308 : SelectorPathType.Xpath == selector.SelectorPaths[0].SelectorPathType
6309 ? ElementHelper.CssForXPath(this, selector)
6310 : selector.SelectorPaths[0].SelectorPath;
6311
6312 if (null != css)
6313 selectorPaths.Add(css);
6314 }
6315
6316 // nothing named an element, so the page is what gets printed. A selector that named one and could
6317 // not be found prints nothing rather than the whole page
6318 if (false == named)
6319 selectorPaths.Add("html");
6320
6321 foreach (string selectorPath in selectorPaths)
6322 PrintOneToPDF(selectorPath, gpalFile.Next);
6323 }
6324
6325 // no GetSetCurrentUrl here. printing reads the page and renders the pdf in a browser of its own, so
6326 // the browser being driven never leaves the page it is on
6327
6328 return this;
6329 }
6330
6335 private void PrintOneToPDF(string selectorPath, GPALFile outputFile)
6336 {
6337 string fullHtml = null;
6338
6339 if (true == BrowserSettings.UseOttoMagic)
6340 fullHtml = MagicHelper.GetContentAndCss(selectorPath);
6341 else
6342 {
6343 // ExecuteJavaScript rather than ExecuteJavaScriptObj: these scripts end in a top level return, which
6344 // selenium runs as a function body and puppeteer refuses as statements. ExecuteJavaScript runs it
6345 // again inside a function when that happens, where ExecuteJavaScriptObj handed puppeteer back null
6346 // and the ToString on it threw, so puppeteer never printed a page
6347 string elementContent = BrowserHelper.ExecuteJavaScript(BrowserSettings.Browser,
6348 $@"var div = document.querySelector('{selectorPath}'); return div.outerHTML;");
6349
6350 // Inject the div content & CSS used
6351 string cssContent = BrowserHelper.ExecuteJavaScript(BrowserSettings.Browser, $@"
6352 function getAllCss() {{
6353 var css = '';
6354 var stylesheets = document.styleSheets;
6355 for (var i = 0; i < stylesheets.length; i++) {{
6356 try {{
6357 var rules = stylesheets[i].cssRules;
6358 for (var j = 0; j < rules.length; j++) {{
6359 css += rules[j].cssText + ' ';
6360 }}
6361 }} catch (e) {{ /* Ignore cross-origin stylesheet errors */ }}
6362 }}
6363 return css;
6364 }}
6365
6366 function getElementCss(element) {{
6367 var css = '';
6368 var style = window.getComputedStyle(element);
6369 for (var i = 0; i < style.length; i++) {{
6370 css += style[i] + ': ' + style.getPropertyValue(style[i]) + '; ';
6371 }}
6372 return css;
6373 }}
6374
6375 var allCss = getAllCss();
6376 var div = document.querySelector('{selectorPath}');
6377 var elementCss = getElementCss(div);
6378
6379 return '<style>' + allCss + ' ' + elementCss + '</style>';
6380 ");
6381 fullHtml = $@"<html><head>{cssContent}</head><body>{elementContent}</body></html>";
6382 }
6383
6384 // a stylesheet served from another domain cannot be read by the page, so its rules were never copied and a
6385 // site styled from a cdn printed bare. the printer is given each stylesheet's address instead, to fetch for
6386 // itself, and the page's own address as the base, so relative addresses resolve from the file it prints.
6387 // on OttoMagic this runs through execute-javascript, which needs Allow user scripts; without it, or when
6388 // the page cannot answer, nothing is added and the page prints as it did
6389 // one expression, so OttoMagic and Puppeteer run it as it is. a top level return is a syntax error for
6390 // both and cost OttoMagic its answer; selenium runs a function body, so it alone is given the return
6391 string linksScript = @"(function () {
6392 var head = '<base href=""' + location.href.replace(/""/g, '&quot;') + '"">';
6393 var links = document.querySelectorAll('link[rel~=""stylesheet""][href]');
6394 for (var i = 0; i < links.length; i++)
6395 head += '<link rel=""stylesheet"" href=""' + links[i].href.replace(/""/g, '&quot;') + '"">';
6396 return head;
6397 })()";
6398 string pageLinks = BrowserHelper.ExecuteJavaScript(this, true == UseSelenium ? "return " + linksScript : linksScript);
6399
6400 if (true == pageLinks?.StartsWith("<base"))
6401 {
6402 int head = fullHtml?.IndexOf("<head>", StringComparison.OrdinalIgnoreCase) ?? -1;
6403
6404 fullHtml = 0 <= head
6405 ? fullHtml.Insert(head + "<head>".Length, pageLinks)
6406 : pageLinks + fullHtml;
6407 }
6408
6409#if DONOTUSECDP
6410 #region NEW get the browser to print to pdf without CDP
6411 // Step 1: the page settings go in the html. chrome's command line has --print-to-pdf and
6412 // --no-pdf-header-footer and nothing for orientation, margins or backgrounds, so @page and
6413 // print-color-adjust carry them. a style before <html> is moved into the head by the parser
6414 string pageCss = "<style>@page { size: "
6415 + (PageOrientation.Landscape == BrowserSettings.PageOrientation ? "landscape" : "portrait")
6416 + "; margin: 0; } html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }</style>";
6417 string outputHtmlPath = $"{outputFile.Filename}.html";
6418
6419 // Step 2: Save the HTML to a file
6420 File.WriteAllText(outputHtmlPath, pageCss + fullHtml);
6421
6422 // Step 3: a headless chromium of its own prints the file and exits. it gets a new temp profile because
6423 // a launch on the workflow's profile is handed to the browser already running there as a new tab,
6424 // which never prints. firefox has no print to pdf on its command line
6425 string printerPath = null;
6426 if (BrowserType.Chrome == BrowserSettings.BrowserType)
6427 printerPath = Puppeteer.GetChromePath();
6428 else if (BrowserType.Edge == BrowserSettings.BrowserType)
6429 printerPath = Puppeteer.GetEdgePath();
6430
6431 if (string.IsNullOrEmpty(printerPath) || false == File.Exists(printerPath))
6432 {
6433 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
6434 $"PrintToPDF needs Chrome or Edge, [{BrowserSettings.BrowserType}] cannot print [{outputFile.Filename}]",
6435 BrowserSettings.Browser, GPALObjectType.Browser);
6436 }
6437 else
6438 {
6439 string printerProfile = ChromeProfileManager.NewTempProfilePath();
6440 string fileUrl = $"file:///{Path.GetFullPath(outputHtmlPath).Replace("\\", "/")}";
6441 string printerArgs = "--headless --disable-gpu --no-first-run --no-default-browser-check"
6442 + $" --user-data-dir=\"{printerProfile}\""
6443 + " --no-pdf-header-footer"
6444 + $" --print-to-pdf=\"{Path.GetFullPath(outputFile.Filename)}\""
6445 + $" \"{fileUrl}\"";
6446
6447 System.Diagnostics.Process pdfPrinter = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(printerPath, printerArgs)
6448 {
6449 UseShellExecute = false,
6450 CreateNoWindow = true
6451 });
6452
6453 // Wait for natural exit instead of blind sleep + kill
6454 bool exitedCleanly = pdfPrinter.WaitForExit(30000); // generous timeout, e.g. 30s
6455
6456 if (!exitedCleanly)
6457 {
6458 try
6459 {
6460 if (!pdfPrinter.HasExited)
6461 pdfPrinter.Kill();
6462 }
6463 catch { }
6464
6465 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
6466 "PrintToPDF process did not exit cleanly (timeout)",
6467 BrowserSettings.Browser, GPALObjectType.Browser);
6468 }
6469 else if (pdfPrinter.ExitCode != 0)
6470 {
6471 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
6472 $"PrintToPDF exited with code [{pdfPrinter.ExitCode}]",
6473 BrowserSettings.Browser, GPALObjectType.Browser);
6474 }
6475
6476 ChromeProfileManager.RemoveTempUserProfile(printerProfile);
6477 }
6478
6479 // Step 4: Clean up temporary files
6480 try
6481 {
6482 File.Delete(outputHtmlPath);
6483 }
6484 catch (GPALException)
6485 {
6486 throw;
6487 }
6488 catch (Exception ex)
6489 {
6490 // Log warning if file deletion fails (e.g., file in use)
6491 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Failed to delete temporary files", BrowserSettings.Browser, GPALObjectType.Browser, ex);
6492 }
6493 #endregion NEW get the browser to print to pdf without CDP
6494#else
6495 #region OLD use CDP to page.PrintToPDF a window
6496 BrowserHelper.ExecuteJavaScript(@"
6497 var newWindow = window.open('', '_blank');
6498 newWindow.document.open();
6499 newWindow.document.write('<html><head>' + arguments[0] + '</head><body>' + arguments[1] + '</body></html>');
6500 newWindow.document.close();
6501 newWindow.focus();
6502 ", browserSettings.Browser, new { cssContent, elementContent });
6503
6504 // Wait for the new window to load
6505 System.Threading.Thread.Sleep(2000);
6506
6507 // Switch to the new window
6508 var windows = browserSettings.BrowserDriver.WindowHandles;
6509 browserSettings.BrowserDriver.SwitchTo().Window(windows[windows.Count - 1]);
6510
6511 // Define print settings
6512 var printSettings = new Dictionary<string, object>
6513 {
6514 { "printBackground", true },
6515 { "landscape", false }
6516 };
6517
6518 // Execute the print command
6519 dynamic result = null;
6520
6521 if (BrowserType.Chrome == browserSettings.BrowserType)
6522 result = ((OpenQA.Selenium.Chromium.ChromiumDriver)browserSettings.BrowserDriver).ExecuteCdpCommand("Page.printToPDF", printSettings);
6523 else if (BrowserType.Edge == browserSettings.BrowserType)
6524 result = ((OpenQA.Selenium.Edge.EdgeDriver)browserSettings.BrowserDriver).ExecuteCdpCommand("Page.printToPDF", printSettings);
6525 else
6526 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{browserSettings.BrowserType}] does not support Page.printToPDF", browserSettings.Browser, GPALObjectType.Browser);
6527
6528 var resultDict = result as IDictionary<string, object>;
6529
6530 if (resultDict != null && resultDict.ContainsKey("data"))
6531 {
6532 // Decode the PDF data
6533 var pdfData = Convert.FromBase64String(resultDict["data"].ToString());
6534 // Save the PDF to a file
6535 System.IO.File.WriteAllBytes(outputFile.Filename, pdfData);
6536 }
6537
6538 browserSettings.BrowserDriver.Close();
6539 windows = browserSettings.BrowserDriver.WindowHandles;
6540 browserSettings.BrowserDriver.SwitchTo().Window(windows[windows.Count - 1]);
6541 #endregion OLD use CDP to page.PrintToPDF a window
6542#endif
6543 }
6548 /// </summary>
6549 /// <param name="textToSend">Text to type</param>
6550 /// <returns>Fluent interface to write your workflow</returns>
6551 public IAllowBrowserActionOrAnySelector SendString(string textToSend)
6552 {
6553 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
6554 {
6555 ElementHelper.SendString(this, textToSend);
6556 }
6557
6558 return this;
6559 }
6577 public IAllowBrowserActionOrAnySelector PressModifierKey(ModifierKeys modifierKeys)
6578 {
6579 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Pressed [{GetModifierKeys(modifierKeys)}]", this, GPALObjectType.Browser);
6580
6581 if (ModifierKeys.NONE != modifierKeys)
6582 if (false == BrowserSettings.UseHeadless && (true == BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware))
6583 {
6584 HardwareHelper.PressModifierKey(modifierKeys);
6585 }
6586 else if (true == UseOttoMagic)
6587 {
6588 MagicHelper.PressModifierKey(modifierKeys);
6589 }
6590 else if (true == UsePuppeteer || (true == BrowserSettings.UseHeadless && (true == BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware)))
6591 {
6592 PuppeteerClient.PressModifierKey(modifierKeys);
6593 }
6594 else
6595 {
6596 Actions actions = new Actions(BrowserDriver);
6597 bool anyKeySet = false;
6598
6599 if (modifierKeys.HasFlag(ModifierKeys.Alt))
6600 {
6601 actions.KeyDown(Keys.Alt);
6602 anyKeySet = true;
6603 }
6604 if (modifierKeys.HasFlag(ModifierKeys.Control))
6605 {
6606 actions.KeyDown(Keys.Control);
6607 anyKeySet = true;
6608 }
6609 if (modifierKeys.HasFlag(ModifierKeys.Shift))
6610 {
6611 actions.KeyDown(Keys.Shift);
6612 anyKeySet = true;
6613 }
6614 if (modifierKeys.HasFlag(ModifierKeys.Windows))
6615 {
6616 actions.KeyDown(Keys.Meta);
6617 anyKeySet = true;
6619
6620 ModifierKeys unsupported = modifierKeys & (ModifierKeys.Application | ModifierKeys.ScrollLock);
6621 if (ModifierKeys.NONE != unsupported)
6622 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"PressModifierKey: [{GetModifierKeys(unsupported)}] is not supported via Selenium/WebDriver Actions and was ignored.", this, GPALObjectType.Browser);
6623
6624 if (true == anyKeySet)
6625 actions.Perform();
6626 }
6627
6628 return this;
6629 }
6647 public IAllowBrowserActionOrAnySelector ReleaseModifierKey(ModifierKeys modifierKeys)
6648 {
6649 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Released [{GetModifierKeys(modifierKeys)}]", this, GPALObjectType.Browser);
6650
6651 if (ModifierKeys.NONE != modifierKeys)
6652 if (false == BrowserSettings.UseHeadless && (true == BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware))
6653 {
6654 HardwareHelper.ReleaseModifierKeys(modifierKeys);
6655 }
6656 else if (true == UseOttoMagic)
6657 {
6658 MagicHelper.ReleaseModifierKey(modifierKeys);
6659 }
6660 else if (true == UsePuppeteer || (true == BrowserSettings.UseHeadless && (true == BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware)))
6661 {
6662 PuppeteerClient.ReleaseModifierKey(modifierKeys);
6663 }
6664 else
6665 {
6666 Actions actions = new Actions(BrowserDriver);
6667 bool anyKeySet = false;
6668
6669 if (modifierKeys.HasFlag(ModifierKeys.Alt))
6670 {
6671 actions.KeyUp(Keys.Alt);
6672 anyKeySet = true;
6673 }
6674 if (modifierKeys.HasFlag(ModifierKeys.Control))
6675 {
6676 actions.KeyUp(Keys.Control);
6677 anyKeySet = true;
6678 }
6679 if (modifierKeys.HasFlag(ModifierKeys.Shift))
6680 {
6681 actions.KeyUp(Keys.Shift);
6682 anyKeySet = true;
6683 }
6684 if (modifierKeys.HasFlag(ModifierKeys.Windows))
6685 {
6686 actions.KeyUp(Keys.Meta);
6687 anyKeySet = true;
6688 }
6689
6690 ModifierKeys unsupported = modifierKeys & (ModifierKeys.Application | ModifierKeys.ScrollLock);
6691 if (ModifierKeys.NONE != unsupported)
6692 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"ReleaseModifierKey: [{GetModifierKeys(unsupported)}] is not supported via Selenium/WebDriver Actions and was ignored.", this, GPALObjectType.Browser);
6693
6694 if (true == anyKeySet)
6695 actions.Perform();
6696 }
6697
6698 return this;
6699 }
6709 /// ...
6710 /// myBrowser
6711 /// .PressModifierKey(ModifierKeys.Control)
6712 /// .SendKey(GPAL.VK_HOME);
6713 /// .WaitFor(150)
6714 /// .ReleaseModifierKey(ModifierKeys.Control)
6715 /// </code>
6716 /// </example>
6717 /// <param name="VKCode">Virtual Keycode</param>
6718 /// <returns>Fluent interface to write your workflow</returns>
6719 public IAllowBrowserActionOrAnySelector SendKey(byte VKCode)
6720 {
6721 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
6722 {
6723 ElementHelper.SendKey(this, VKCode);
6724 }
6725
6726 return this;
6727 }
6732 /// <param name="scrollAmount">Positive integer scrolls right.<br/>Negative integer scolls left.</param>
6733 /// <returns>Fluent interface to write your workflow</returns>
6734 /// <remarks>
6735 /// Scrolling ends the unit of work, because scrolling can replace what is on the page. Read what you
6736 /// have before you scroll and name the selectors again afterwards.
6737 /// </remarks>
6738 public IAllowBrowserActionOrAnySelector ScrollWindowHorizontal(int scrollAmountInPixels)
6739 {
6740 InAction(false, false);
6741
6742 if (true == UseOttoMagic)
6743 MagicHelper.ScrollWindowHorizontal(scrollAmountInPixels);
6744 else if (true == UsePuppeteer)
6745 PuppeteerClient.ScrollWindowByHorizontal(scrollAmountInPixels).Execute();
6746 else
6747 BrowserHelper.ExecuteJavaScript(this, $"window.scrollBy({{left: {scrollAmountInPixels}, top: 0, behavior: 'instant'}})");
6748
6749 return this;
6750 }
6756 /// <returns>Fluent interface to write your workflow</returns>
6757 /// <remarks>
6758 /// Scrolling ends the unit of work, because scrolling can replace what is on the page. Read what you
6759 /// have before you scroll and name the selectors again afterwards.
6760 /// </remarks>
6761 public IAllowBrowserActionOrAnySelector ScrollWindowVertical(int scrollAmountInPixels)
6762 {
6763 InAction(false, false);
6764
6765 if (true == UseOttoMagic)
6766 MagicHelper.ScrollWindowVertical(scrollAmountInPixels);
6767 else if (true == UsePuppeteer)
6768 PuppeteerClient.ScrollWindowByVertical(scrollAmountInPixels).Execute();
6769 else
6770 BrowserHelper.ExecuteJavaScript(this, $"window.scrollBy({{left: 0, top: {scrollAmountInPixels}, behavior: 'instant'}})");
6771
6772 return this;
6773 }
6774 /// <summary>
6775 /// Scroll the element horizontally by number of pixels.<br/>
6776 /// Negative numbers scroll left. <b>JavaScript only</b>
6777 /// </summary>
6778 /// <param name="scrollAmountInPixels">Positive integer scrolls right.<br/>Negative integer scolls left.</param>
6779 /// <returns>Fluent interface to write your element handling workflow</returns>
6780 /// <remarks>
6781 /// This scrolls the selectors in the unit of work, so it ends the unit of work the way any other element
6782 /// action does and the next selector starts a new one. Scrolling the window does not, because it names
6783 /// no element: see <see cref="ScrollWindowHorizontal"/> and <see cref="PageDown"/>.
6784 /// </remarks>
6785 public IAllowBrowserActionOrAnySelector ScrollElementHorizontal(int scrollAmountInPixels)
6786 {
6787 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
6788 ElementHelper.ScrollElement(this, scrollAmountInPixels, ScrollTypes.Horizontal);
6789
6790 return this;
6791 }
6798 /// <remarks>
6799 /// This scrolls the selectors in the unit of work, so it ends the unit of work the way any other element
6800 /// action does and the next selector starts a new one. Scrolling the window does not, because it names
6801 /// no element: see <see cref="ScrollWindowVertical"/> and <see cref="PageDown"/>.
6802 /// </remarks>
6803 public IAllowBrowserActionOrAnySelector ScrollElementVertical(int scrollAmountInPixels)
6804 {
6805 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
6806 ElementHelper.ScrollElement(this, scrollAmountInPixels, ScrollTypes.Vertical);
6807
6808 return this;
6809 }
6810
6827 public IAllowBrowserActionOrAnySelector GoToTab(dynamic URLorTabTuple = null)
6828 {
6829 if (true == UseOttoMagic)
6830 {
6831 TabTuple gotoTabTuple = MagicHelper.GotoTab(URLorTabTuple);
6832 if (URLorTabTuple is TabTuple)
6833 UpdateActiveTab(URLorTabTuple);
6834 else
6835 UpdateActiveTab(gotoTabTuple);
6836 }
6837 else if (true == UsePuppeteer)
6838 {
6839 if (URLorTabTuple is GPALUrl url)
6840 PuppeteerClient.GoToTab(url.Url).Execute();
6841 else if (URLorTabTuple is int tabId)
6842 PuppeteerClient.GoToTab(tabId).Execute();
6843 }
6844 else
6846 foreach (var handle in this.BrowserDriver.WindowHandles)
6847 {
6848 string currentUrl = this.BrowserDriver.SwitchTo().Window(handle).Url;
6849 if (currentUrl == URLorTabTuple)
6850 {
6851 this.BrowserDriver.SwitchTo().Window(handle);
6852 BrowserSettings.CurrentURL = URLorTabTuple;
6853
6854 var handles = BrowserDriver.WindowHandles.ToList();
6855 string currentHandle = BrowserDriver.CurrentWindowHandle;
6856
6857 CurrentTabIdx = handles.IndexOf(currentHandle);
6858
6859 return this;
6860 }
6862 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to detect tab with URL [{URLorTabTuple}]", this, GPALObjectType.Browser);
6863 }
6864
6865 return this;
6866 }
6867
6873
6874 public IAllowBrowserActionOrAnySelector DragAndDrop(ModifierKeys modifierKeys)
6875 {
6876 // waitfor did not match if returns false, so we have no element to click
6877 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
6878 {
6879 ElementHelper.DragAndDrop(this, modifierKeys);
6880 }
6881 return this;
6882 }
6883
6884
6891 {
6892 if (null != selector)
6893 WithSelector(selector);
6894
6895 // waitfor did not match if returns false
6896 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
6897 {
6898 ElementHelper.DragAndDrop(this);
6899 }
6900 return this;
6901 }
6902
6909 {
6910 JavaScriptResultObj = BrowserHelper.ExecuteJavaScriptObj(javascript, this);
6911 return this;
6912 }
6913
6919 public IAllowBrowserActionOrAnySelector ExecuteJavaScriptStr(string javascript)
6920 {
6921 JavaScriptResultStr = BrowserHelper.ExecuteJavaScript(this, javascript);
6922 return this;
6923 }
6924
6925 private readonly List<string> _injectedScriptIdentifiers = new List<string>();
6926
6927 // Whether a script is registered with the driving engine, and so needs removing on close
6928 private bool _scriptsRegistered = false;
6929 private readonly List<string> _pendingInjectedScripts = new List<string>();
6930
6931 // the override goes on once. after that the answers are written to the page rather than registered again
6932 private bool _dialogRegistered = false;
6933
6934 // the answers and the page they were written to, so nothing is written twice for no reason
6935 private string _dialogPushed = null;
6936
6937 // counts up per registration so the newest override wins wherever the scripts run in
6938 private int _dialogSeq = 0;
6939
6944 private bool EngineReachable
6945 {
6946 get
6947 {
6948 bool retVal;
6949
6950 if (true == UseOttoMagic)
6951 retVal = false == string.IsNullOrEmpty(BrowserSettings.RestApiBaseUrl);
6952 else if (true == UsePuppeteer)
6953 retVal = false == string.IsNullOrEmpty(BrowserSettings.PuppeteerUrl);
6954 else // selenium
6955 retVal = null != BrowserSettings.BrowserDriver;
6956
6957 return retVal;
6958 }
6959 }
6960
6966 private void RegisterInjectedScript(string script)
6967 {
6968 _scriptsRegistered = true;
6969
6970 if (true == UseOttoMagic)
6971 {
6972 MagicHelper.InjectScript(script);
6973 }
6974 else if (true == UsePuppeteer)
6975 {
6976 PuppeteerCommunicator.InjectScript(script, null).GetAwaiter().GetResult();
6977 }
6978 else // selenium
6979 {
6980 var identifier = BrowserHelper.SeleniumAddScriptToEvaluateOnNewDocument(BrowserSettings, script);
6981 if (false == string.IsNullOrEmpty(identifier))
6982 _injectedScriptIdentifiers.Add(identifier);
6983 }
6984 }
6985
6988 /// Called from WorkflowSetup once the engine answers and before the first navigation, so a script queued
6989 /// ahead of .GoTo() still runs on the first document.
6990 /// </summary>
6991 internal void ApplyPendingInjectedScripts()
6992 {
6993 if (0 < _pendingInjectedScripts.Count && true == EngineReachable)
6994 {
6995 foreach (var script in _pendingInjectedScripts)
6996 RegisterInjectedScript(script);
6997
6998 _pendingInjectedScripts.Clear();
6999 }
7000 }
7001
7002
7008 /// Selenium and Puppeteer answer them at the browser: the driver does it as a capability and Puppeteer
7009 /// over CDP, and the page's own alert, confirm and prompt are untouched, so nothing about it is visible
7010 /// to the page. OttoMagic cannot do that, because an open dialog stops the page thread the extension
7011 /// talks through, so there GPAL replaces those three functions before the page runs and no dialog is
7012 /// ever raised. That replacement is the one thing here a page could notice, which is why it is only
7013 /// done on the engine that has no other way.
7014 /// </summary>
7015 /// <param name="accept">True to accept, false to dismiss.</param>
7016 /// <returns>Fluent interface to write your workflow</returns>
7018 {
7019 // the driver reads this as a capability when it starts, so a browser that is already up was built
7020 // Warned rather than silently ignored. Only applies while the driver answers the dialog: setting
7021 // prompt text moves Selenium onto the in-page path, where the answer can be changed
7022 if (true == UseSelenium && null != BrowserSettings.BrowserDriver && false == InPageDialogs)
7023 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Selenium takes this when the browser starts, and this one is already running, so dialogs are not answered. Call WithDialogsAccepted before the first GoTo", this, GPALObjectType.Browser);
7024
7025 BrowserSettings.DialogsAccepted = accept;
7026
7027 ArmDialogHandling();
7028
7029 return this;
7030 }
7031
7033 /// What a prompt hands back when dialogs are accepted. Left unsaid, the prompt's own default stands.
7034 /// </summary>
7035 /// <param name="text">The text to answer a prompt with.</param>
7036 /// <returns>Fluent interface to write your workflow</returns>
7038 {
7039 BrowserSettings.DialogText = text;
7040
7041 // a workflow that says what to answer has said to answer, so this turns it on rather than sitting
7042 // there doing nothing until WithDialogsAccepted is also called
7043 if (null == BrowserSettings.DialogsAccepted)
7044 BrowserSettings.DialogsAccepted = true;
7045
7046 ArmDialogHandling();
7047
7048 return this;
7049 }
7050
7063 {
7064 BrowserSettings.DialogsAccepted = null;
7065 BrowserSettings.DialogText = null;
7066
7067 ArmDialogHandling();
7068
7069 return this;
7070 }
7071
7076 internal void ArmDialogHandling()
7077 {
7078 // still runs with nothing to answer with, because turning it off is something the page has to be
7079 // told about too, and only if it was ever told to start
7080 if (false == EngineReachable || (null == BrowserSettings.DialogsAccepted && false == _dialogRegistered))
7081 return;
7082
7083 if (true == InPageDialogs)
7084 {
7085 if (false == _dialogRegistered && null != BrowserSettings.DialogsAccepted)
7086 {
7087 string script = DialogScript();
7088
7089 _dialogRegistered = true;
7090
7091 // registering covers every document from here on, which before the first navigation is all
7092 // of them. running it directly is for the other case, arming partway through a workflow,
7093 // where the page being stood on has already loaded and would otherwise be the one page not
7094 // covered
7095 InjectScript(script);
7096
7097 if (true == _navigated)
7098 BrowserHelper.ExecuteJavaScript(this, script);
7099
7100 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Answering dialogs in the page, accept [{BrowserSettings.DialogsAccepted}]", this, GPALObjectType.Browser);
7101 }
7102
7103 // every document runs its own copy of the script, which sets the answers back to the ones it was
7104 // registered with, so what is on the page is stale when the workflow changes its mind and stale
7105 // again when the page moves. both are known here without asking the browser anything
7106 string written = DialogAnswers() + "@" + BrowserSettings.CurrentURL;
7107
7108 if (true == _navigated && written != _dialogPushed)
7109 {
7110 _dialogPushed = written;
7111 PushDialogAnswers();
7112 }
7113
7114 // asked once, after the first navigation, because a script that never registered and one that
7115 // registered and never ran look the same from here: both leave the page raising real dialogs,
7116 // and the first one to appear stops the workflow with no idea why
7117 // OttoMagic only, and once. Registration goes through the extension and can be refused, which
7118 // is indistinguishable from success here. Selenium registers over CDP, which cannot refuse
7119 if (true == UseOttoMagic && true == _navigated && false == _dialogChecked)
7120 {
7121 _dialogChecked = true;
7122
7123 // An expression, not a statement. OttoMagic evaluates the last expression, and a top-level
7124 // return is a syntax error there
7125 string seen = BrowserHelper.ExecuteJavaScript(this, "window.__gpalDialogSeq");
7126
7127 if (true == string.IsNullOrEmpty(seen) || "null" == seen)
7128 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"The dialog override is not on the page, so dialogs will stop this workflow. The usual cause is Allow user scripts being off for the extension", this, GPALObjectType.Browser);
7129 }
7130 }
7131 else if (true == UsePuppeteer && null != BrowserSettings.DialogsAccepted && false == _dialogRegistered)
7132 {
7133 // Page.javascriptDialogOpening does not arrive until the domain is on, and the launch paths do
7134 // not turn it on
7135 _dialogRegistered = true;
7136
7137 PuppeteerCommunicator.EnablePageEvents().GetAwaiter().GetResult();
7138
7139 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Answering dialogs over CDP, accept [{BrowserSettings.DialogsAccepted}]", this, GPALObjectType.Browser);
7140 }
7141
7142 // selenium answering yes or no is armed at launch, because UnhandledPromptBehavior is a capability
7143 // on the driver options and nothing after that can change it
7144 }
7145
7155 private bool InPageDialogs => true == UseOttoMagic
7156 || (true == UseSelenium && null != BrowserSettings.DialogText);
7157
7158 // set when a navigation has finished rather than when one has been asked for. BrowserSettings.CurrentURL
7159 // is the url GoTo is heading to and is written before it goes, so it says nothing about what is on screen
7160 private bool _navigated = false;
7161
7162 // whether the override was found in place on the page after the first navigation
7163 private bool _dialogChecked = false;
7164
7170 private void PushDialogAnswers()
7171 {
7172 // only where the override is already installed, so this is never what puts the globals there
7173 BrowserHelper.ExecuteJavaScript(this, "if (window.__gpalDialogSeq) { " + DialogAnswers() + " }");
7174 }
7175
7181 private string DialogAnswers()
7182 {
7183 string accept = null == BrowserSettings.DialogsAccepted
7184 ? "null"
7185 : true == BrowserSettings.DialogsAccepted ? "true" : "false";
7186 string typed = null == BrowserSettings.DialogText ? "null" : JsonConvert.SerializeObject(BrowserSettings.DialogText);
7187
7188 return "window.__gpalDialogAccept = " + accept + "; window.__gpalDialogAnswer = " + typed + ";";
7189 }
7190
7197 private string DialogScript()
7198 {
7199 return
7200 "(function () {" +
7201 " var seq = " + (++_dialogSeq) + ";" +
7202 " if (window.__gpalDialogSeq >= seq) return;" +
7203 " window.__gpalDialogSeq = seq;" +
7204 " " + DialogAnswers() +
7205 " var asText = Function.prototype.toString;" +
7206 " function stand(name, fn) {" +
7207 " Object.defineProperty(fn, 'name', { value: name, configurable: true });" +
7208 // put where the real one is rather than where the spec says it should be. chrome carries
7209 // these as own properties of window, and one defined on Window.prototype sits behind the
7210 // native it was meant to replace: installed, never called. asking first also keeps the
7211 // shape a page would see, since ours ends up in the same place as the one it replaced
7212 " var onto = Object.getOwnPropertyDescriptor(window, name) ? window : Window.prototype;" +
7213 " Object.defineProperty(onto, name," +
7214 " { value: fn, writable: true, enumerable: true, configurable: true });" +
7215 " }" +
7216 // kept so turning it off can hand the page back what it started with, rather than leaving it
7217 // with three functions that answer nothing
7218 " var real = { alert: window.alert, confirm: window.confirm, prompt: window.prompt };" +
7219 " function off() { return null === window.__gpalDialogAccept; }" +
7220 " stand('alert', function (message) {" +
7221 " if (off()) return real.alert.call(window, message);" +
7222 " });" +
7223 " stand('confirm', function (message) {" +
7224 " if (off()) return real.confirm.call(window, message);" +
7225 " return true === window.__gpalDialogAccept;" +
7226 " });" +
7227 " stand('prompt', function (message, preset) {" +
7228 " if (off()) return real.prompt.call(window, message, preset);" +
7229 " if (true !== window.__gpalDialogAccept) return null;" +
7230 " return null !== window.__gpalDialogAnswer ? window.__gpalDialogAnswer : (preset || '');" +
7231 " });" +
7232 // a page that asks what these are reads what it would read of the real ones. it raises the bar
7233 // rather than clearing it: this is the part of the feature a page can still catch out
7234 " Function.prototype.toString = function () {" +
7235 " if (this === window.alert || this === window.confirm || this === window.prompt" +
7236 " || this === Function.prototype.toString)" +
7237 " return 'function ' + this.name + '() { [native code] }';" +
7238 " return asText.call(this);" +
7239 " };" +
7240 "})();";
7241 }
7242
7249 /// <example>
7250 /// <code>
7251 /// browser.GetWindowRectangle(out Rectangle window);
7252 /// </code>
7253 /// </example>
7254 /// <param name="window">Receives the window rectangle in screen coordinates.</param>
7255 /// <returns>Fluent interface to write your workflow</returns>
7256 public IAllowBrowserActionOrAnySelector GetWindowRectangle(out Rectangle window)
7257 {
7258 window = Rectangle.Empty;
7259
7260 if (true == InAction(false))
7261 {
7262 if (true == UsePuppeteer)
7263 window = PuppeteerCommunicator.GetWindowRectangle().GetAwaiter().GetResult();
7264 else
7265 ElementHelper.GetWindowRectangle(this, out window);
7266 }
7267
7268 return this;
7269 }
7270
7274
7279 {
7280 // no browser to inject into until the first .GoTo()/.Get() launches one, so the script is held
7281 // and registered the moment the engine answers, ahead of the first navigation
7282 if (false == EngineReachable)
7283 _pendingInjectedScripts.Add(script);
7284 else
7285 RegisterInjectedScript(script);
7286
7287 return this;
7288 }
7289
7298 {
7299 foreach (var filename in scriptFile.Filenames)
7300 InjectScript(File.ReadAllText(filename));
7301 return this;
7302 }
7303
7304
7309 public IAllowBrowserActionOrAnySelector ClearInjectedScripts()
7310 {
7311 // scripts still waiting on a browser go first, so one queued and then cleared never reaches the page
7312 _pendingInjectedScripts.Clear();
7313
7314 if (true == EngineReachable)
7315 {
7316 if (true == UseOttoMagic)
7317 {
7318 MagicHelper.ClearInjectedScripts();
7319 }
7320 else if (true == UsePuppeteer)
7321 {
7322 PuppeteerCommunicator.ClearInjectedScripts().GetAwaiter().GetResult();
7323 }
7324 else // selenium
7325 {
7326 foreach (var identifier in _injectedScriptIdentifiers)
7328 _injectedScriptIdentifiers.Clear();
7329 }
7330 }
7331 return this;
7332 }
7333
7340 public IAllowBrowserActionOrAnySelector SetRange(int rangeValue)
7341 {
7342 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
7343 foreach (Selector selector in CurrentUOW.WithSelectorList)
7344 {
7345 ElementHelper.FindWebElements(this, CurrentUOW, selector, out _, out List<GPALElement> elems);
7346 foreach (GPALElement elem in elems)
7347 {
7348 ElementHelper.ScrollIntoView(this, elem);
7349
7350 if (true == UseOttoMagic)
7351 MagicHelper.SetRange(elem.Css, rangeValue);
7352 else if (true == UsePuppeteer)
7353 PuppeteerClient.SetRange(elem.Css).WithRangeValue(rangeValue).Execute();
7354 else if (true == UseSelenium)
7356 // Set the value and dispatch the change events so the UI updates
7357 string script = @"
7358 arguments[0].value = arguments[1];
7359 arguments[0].dispatchEvent(new Event('input', { bubbles: true }));
7360 arguments[0].dispatchEvent(new Event('change', { bubbles: true }));
7361 ";
7362
7363 BrowserHelper.ExecuteJavaScript(this, script, elem.WebElement, rangeValue);
7365 }
7366 }
7367
7368 return this;
7369 }
7370 #endregion <Browser Actions>
7371 #region <Getters>
7372 private bool _started = false;
7376 internal bool Started
7377 {
7378 get => _started;
7379 set => _started = value;
7380 }
7383
7384 public int ServerResponseCode
7385 {
7386 get => BrowserSettings.ServerResponseCode;
7387 set => BrowserSettings.ServerResponseCode = value;
7388 }
7389
7390
7393 public string CurrentUrl
7394 {
7395 get => BrowserSettings.CurrentURL;
7396 internal set => BrowserSettings.CurrentURL = value;
7397 }
7400 /// </summary>
7401 public bool UseSelenium
7402 {
7403 get => BrowserSettings.UseSelenium;
7404 }
7405
7408 public bool UsePuppeteer
7409 {
7410 get => BrowserSettings.UsePuppeteer;
7411 }
7415 public bool UseOttoMagic
7416 {
7417 get => BrowserSettings.UseOttoMagic;
7418 }
7420
7422 public Process Process
7423 {
7424 get => BrowserSettings.Process;
7425 }
7430 {
7431 get => BrowserSettings.AutomationEngine;
7432 internal set => BrowserSettings.AutomationEngine = value;
7433 }
7437 public bool? FileDownloaded
7438 {
7439 get
7440 {
7441 return BrowserSettings.FileDownloaded;
7442 }
7443
7444 set
7445 {
7446 BrowserSettings.FileDownloaded = value;
7447 }
7448 }
7449
7454 {
7455 get
7456 {
7457 return BrowserSettings.BrowserType;
7458 }
7463 internal UnitOfWork CurrentUOW { get; set; } = null;
7467 internal WorkflowManager WorkflowManager { get; set; }
7471 internal string Name
7472 {
7473 get
7474 {
7475 return BrowserSettings.BrowserName;
7476 }
7477
7478 set
7479 {
7480 BrowserSettings.BrowserName = value;
7481 }
7482 }
7485 /// Only available after the first .GoTo<br/>
7486 /// <b>NOTE:</b> Only for extended Selenium control access. Be careful.
7487 /// </summary>
7488 public IWebDriver BrowserDriver
7489 {
7490 get
7491 {
7492 return BrowserSettings.BrowserDriver;
7493 }
7494
7495 internal set
7496 {
7497 BrowserSettings.BrowserDriver = value;
7498 }
7499 }
7504 public string RestApiBaseUrl
7505 {
7506 get
7507 {
7508 return BrowserSettings.RestApiBaseUrl;
7509 }
7515 {
7516 get
7517 {
7518 return _browserSettings;
7519 }
7520
7521 internal set
7522 {
7523 _browserSettings = value;
7524 }
7525 }
7529 internal string DownloadLocation
7530 {
7531 get
7532 {
7533 return BrowserSettings.DownloadLocation;
7534 }
7535 }
7538 /// </summary>
7540 {
7541 get => BrowserSettings.PuppeteerCommunicator;
7542 internal set => BrowserSettings.PuppeteerCommunicator = value;
7543 }
7544
7545 public bool IsAlive
7547 get
7548 {
7549 // nothing of ours was launched, so ask its GPALRestAPI instead. that exits with the browser
7550 // it belongs to, so an answer means the browser is still there
7551 if (true == BrowserSettings.AttachedRestApi)
7552 return BrowserHelper.RestApiAnswering(BrowserSettings.RestApiBaseUrl);
7553
7554 if (BrowserSettings.Process != null)
7555 return !BrowserSettings.Process.HasExited;
7556 if (BrowserSettings.ServiceDriverPid != 0)
7557 {
7558 try { System.Diagnostics.Process.GetProcessById(BrowserSettings.ServiceDriverPid); return true; }
7559 catch (ArgumentException) { return false; }
7560 }
7561 return true;
7562 }
7563 }
7567 public IPuppeteerClient PuppeteerClient
7568 {
7569 get => BrowserSettings.PuppeteerClient;
7570 internal set => BrowserSettings.PuppeteerClient = value;
7571 }
7576 {
7577 get => (MagicHelper)BrowserSettings.MagicHelper;
7578 }
7579
7586 {
7587 get
7588 {
7589 if (false == BrowserSettings.UseOttoMagic)
7590 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
7591 $"No OttoMagic REST API on [{BrowserSettings.AutomationEngine}]. Only the OttoMagic engines run one. Use PuppeteerClient on a Puppeteer engine, or the browser's own actions",
7592 this, GPALObjectType.Browser);
7593
7594 return MagicHelper.Client;
7595 }
7596 }
7597
7603 public IHiddenDesktop HiddenDesktop => true == BrowserSettings.HiddenDesktop
7604 ? new GPALHiddenDesktop(BrowserSettings.HiddenDesktopName)
7605 : null;
7606 #endregion <Getters>
7607 #region <Helpers>
7608 #region <Private>
7615 bool ScrollPage(string direction)
7616 {
7617 try
7618 {
7619 // Validate direction
7620 if (direction != "up" && direction != "down")
7621 {
7622 return false;
7623 }
7624
7625 // JavaScript code with direction parameter
7626 string script = @"
7627 (function(direction) {
7628 function scrollPage(direction) {
7629 try {
7630 const isDown = direction === 'down';
7631 const scrollAmount = isDown ? window.innerHeight : -window.innerHeight;
7632 const keyName = isDown ? 'PageDown' : 'PageUp';
7633 const keyCode = isDown ? 34 : 33;
7634
7635 // Check overflow
7636 const overflow = getComputedStyle(document.documentElement).overflow;
7637
7638 // Try window.scrollBy if overflow is not 'hidden'
7639 const initialScrollY = window.scrollY;
7640 if (overflow !== 'hidden') {
7641 window.scrollBy({ left: 0, top: scrollAmount, behavior: 'instant' });
7642 if (window.scrollY !== initialScrollY) {
7643 return {
7644 status: 'success',
7645 method: 'window.scrollBy',
7646 scrollPosition: window.scrollY,
7647 elementClass: 'window'
7648 };
7649 }
7650 }
7651
7652 // Try scrolling the scrollable element
7653 const scrollable = Array.from(document.querySelectorAll('*')).find(
7654 el => el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
7655 ) || document.body;
7656 const initialScrollTop = scrollable.scrollTop;
7657 scrollable.scrollBy({ left: 0, top: scrollAmount, behavior: 'instant' });
7658 const elementClass = scrollable.className || 'body';
7659
7660 if (scrollable.scrollTop !== initialScrollTop) {
7661 return {
7662 status: 'success',
7663 method: 'element.scrollBy',
7664 scrollPosition: scrollable.scrollTop,
7665 elementClass
7666 };
7667 }
7668
7669 // Fallback to Page Up/Down key press
7670 scrollable.focus();
7671 const event = new KeyboardEvent('keydown', {
7672 key: keyName,
7673 code: keyName,
7674 keyCode,
7675 which: keyCode,
7676 bubbles: true,
7677 cancelable: true
7678 });
7679 scrollable.dispatchEvent(event);
7680
7681 if (scrollable.scrollTop !== initialScrollTop) {
7682 return {
7683 status: 'success',
7684 method: keyName,
7685 scrollPosition: scrollable.scrollTop,
7686 elementClass
7687 };
7688 }
7689
7690 // No scroll method worked
7691 return {
7692 status: 'failed',
7693 message: 'No scroll method worked',
7694 scrollPosition: scrollable.scrollTop,
7695 elementClass
7696 };
7697 } catch (error) {
7698 return {
7699 status: 'error',
7700 message: error.message,
7701 scrollPosition: 0,
7702 elementClass: 'none'
7703 };
7704 }
7705 }
7706 return scrollPage(direction);
7707 })('" + direction + "')";
7708
7709 // Execute the script
7710 var result = BrowserHelper.ExecuteJavaScript(this, script);
7711
7712 // Parse result (adjust based on BrowserHelper's return type)
7713 dynamic response = result; // Assumes result is a dynamic object or JSON
7714 return response.status == "success";
7715 }
7716 catch
7717 {
7718 return false;
7719 }
7720 }
7727 private bool ScrollToPosition(string position)
7728 {
7729 try
7730 {
7731 // Validate position
7732 if (position != "top" && position != "end")
7733 {
7734 return false;
7735 }
7736
7737 // JavaScript code with position parameter
7738 string script = @"
7739 (function(position) {
7740 function scrollToPosition(position) {
7741 try {
7742 const isEnd = position === 'end';
7743 const scrollTarget = isEnd ? document.body.scrollHeight : 0;
7744 const keyName = isEnd ? 'End' : 'Home';
7745 const keyCode = isEnd ? 35 : 36;
7746
7747 // Check overflow
7748 const overflow = getComputedStyle(document.documentElement).overflow;
7749
7750 // Try window.scrollTo if overflow is not 'hidden'
7751 const initialScrollY = window.scrollY;
7752 if (overflow !== 'hidden') {
7753 window.scrollTo({ left: 0, top: scrollTarget, behavior: 'instant' });
7754 if (window.scrollY !== initialScrollY) {
7755 return {
7756 status: 'success',
7757 method: 'window.scrollTo',
7758 scrollPosition: window.scrollY,
7759 elementClass: 'window'
7760 };
7761 }
7762 }
7763
7764 // Try scrolling the scrollable element
7765 const scrollable = Array.from(document.querySelectorAll('*')).find(
7766 el => el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
7767 ) || document.body;
7768 const initialScrollTop = scrollable.scrollTop;
7769 const elementScrollTarget = isEnd ? scrollable.scrollHeight : 0;
7770 scrollable.scrollTo({ left: 0, top: elementScrollTarget, behavior: 'instant' });
7771 const elementClass = scrollable.className || 'body';
7772
7773 if (scrollable.scrollTop !== initialScrollTop) {
7774 return {
7775 status: 'success',
7776 method: 'element.scrollTo',
7777 scrollPosition: scrollable.scrollTop,
7778 elementClass
7779 };
7780 }
7781
7782 // Fallback to Home/End key press
7783 scrollable.focus();
7784 const event = new KeyboardEvent('keydown', {
7785 key: keyName,
7786 code: keyName,
7787 keyCode,
7788 which: keyCode,
7789 bubbles: true,
7790 cancelable: true
7791 });
7792 scrollable.dispatchEvent(event);
7793
7794 if (scrollable.scrollTop !== initialScrollTop) {
7795 return {
7796 status: 'success',
7797 method: keyName,
7798 scrollPosition: scrollable.scrollTop,
7799 elementClass
7800 };
7801 }
7802
7803 // No scroll method worked
7804 return {
7805 status: 'failed',
7806 message: 'No scroll method worked',
7807 scrollPosition: scrollable.scrollTop,
7808 elementClass
7809 };
7810 } catch (error) {
7811 return {
7812 status: 'error',
7813 message: error.message,
7814 scrollPosition: 0,
7815 elementClass: 'none'
7816 };
7817 }
7818 }
7819 return scrollToPosition(position);
7820 })('" + position + "')";
7821
7822 // Execute the script
7823 var result = BrowserHelper.ExecuteJavaScript(this, script);
7824
7825 // Parse result (adjust based on BrowserHelper's return type)
7826 dynamic response = result; // Assumes result is a dynamic object or JSON
7827 return response.status == "success";
7828 }
7829 catch
7830 {
7831 return false;
7832 }
7833 }
7839 private static void SelectorListMaintenanceSweep(UnitOfWork CurrentUOW)
7840 {
7841 for (int idx = 0; idx < CurrentUOW.WithSelectorList.Count; idx++)
7842 if (true == CurrentUOW.WithSelectorList[idx].DeleteMe)
7843 CurrentUOW.WithSelectorList.RemoveAt(idx);
7844
7845 for (int idx = 0; idx < CurrentUOW.InSelectorList.Count; idx++)
7846 if (true == CurrentUOW.InSelectorList[idx].DeleteMe)
7847 CurrentUOW.InSelectorList.RemoveAt(idx);
7848 }
7853 private void CheckPersistentSelectors()
7854 {
7855 UnitOfWork safeUOW = CurrentUOW;
7856
7857 // all we have to do is walk the list, try to find the element and the callif callbacks will do the work
7858 if (0 < (persistentUOW.WithSelectorList?.Count ?? 0))
7859 {
7860 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Checking [{persistentUOW.WithSelectorList?.Count}] persistent selector(s)");
7861
7862 foreach (Selector selector in persistentUOW.WithSelectorList)
7863 {
7864 if (SelectorType.Selector != selector.SelectorType)
7865 continue; // we don't look up data literals
7866
7867 // we cannot use cached results knowing we are a new uow/action
7868 selector.WebSelectorFoundResults = null;
7869 selector.WebSelectorMatchedResults = null;
7870 CurrentUOW.CurrentSelector = selector;
7871
7872
7873 ElementHelper.FindWebElements(this, persistentUOW, selector, out _, out _, null, true, true);
7874 }
7875 }
7876 CurrentUOW = safeUOW;
7877 }
7882 private bool CheckWaitFor()
7883 {
7884 bool matchFound = true;
7885 List<GPALElement> foundElements = null;
7886
7887 // an earlier action on this same unit of work already waited these selectors in successfully, so
7888 // waiting again is redundant - the page cannot have moved under us, since any selector added after
7889 // an action starts a fresh unit of work. the action still resolves its own elements either way.
7890 if (null != CurrentUOW.WaitForSatisfied)
7891 {
7892 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Selectors for this unit of work already found at [{CurrentUOW.WaitForSatisfied:HH:mm:ss.fff}], skipping redundant wait.", this, GPALObjectType.Browser);
7893 return true;
7894 }
7895
7896 // -1 means wait forever
7897 // -2 never
7898 if (WaitTime.Never < CurrentUOW.WaitForInMs)
7899 {
7900 // we are waiting on selector(s)
7901 foundElements = ElementHelper.WaitFor(this, CurrentUOW.WaitForInMs, out matchFound);
7902
7903 if (0 < foundElements?.Count)
7904 CurrentUOW.WaitForSatisfied = DateTime.Now;
7905 }
7906
7907 return (0 < foundElements?.Count);
7908 }
7917 internal bool WorkflowSetup(bool checkWaitFor, bool doMaintenance, GPALUrl URL = null)
7918 {
7919 // settled before anything is launched, because the engine chosen here decides which of the branches
7920 // below runs
7921 if (true == BrowserSettings.HiddenDesktop && false == BrowserSettings.HiddenDesktopReported)
7922 {
7923 BrowserSettings.HiddenDesktopReported = true;
7924
7925 // SendInput reaches the desktop the user is on and no other, so a hardware engine on a desktop of
7926 // its own would be clicking at nothing. it drives through its protocol instead, and the workflow
7927 // is told which engine it is actually running rather than finding out from the results
7928 if (true == BrowserSettings.UseHardware)
7929 {
7930 AutomationEngine steppedDownTo = HardwareStepDown(BrowserSettings.AutomationEngine);
7931
7932 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{BrowserSettings.AutomationEngine}] cannot drive a hidden desktop, so this browser runs on [{steppedDownTo}]", this, GPALObjectType.Browser);
7933
7934 BrowserSettings.AutomationEngine = steppedDownTo;
7935 }
7936
7937 // a desktop that cannot be made is a browser that opens in front of the user, so the workflow is
7938 // told and this browser runs on the desktop it can have
7939 if (false == Desktops.Ensure(BrowserSettings.HiddenDesktopName))
7940 {
7941 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No hidden desktop could be made, so this browser opens on the desktop you are working on", this, GPALObjectType.Browser);
7942
7943 BrowserSettings.HiddenDesktop = false;
7944 }
7945 }
7946
7947 // also checked in WithProfileDataDirectory as vital for that operation, but if that is not specified then this is the best location to set this
7948 // NOTE: CAVEAT: it's not clear this is even required here, but we may find a use for it
7949 //if (null == BrowserSettings.ProfileDataDirectory)
7950 // BrowserSettings.ProfileDataDirectory = FileHelper.EnsureDirectoryEndsWithBackslash(BrowserHelper.GetBrowserProfileDirectory(BrowserSettings, true));
7951
7952 // first time init is the only time URL is used
7953 // but due to window tracking, we will always go to https://google.com first then an explicit goto
7954 if (true == UseSelenium)
7955 {
7956 // instatiate a selenium driver and launch chrome headless
7957 if (null == BrowserDriver)
7958 {
7959 if (null == (BrowserDriver = LaunchWhereAsked(() => BrowserHelper.GetBrowserDriver(BrowserSettings))))
7960 return false; // NOTE: CAVEAT: will never return false, getbrowserdiver will now throw an exception and end the workflow if it can't start the browser
7961
7962 // selenium allows runtime setting of prompt for download and for chromium browsers, we can turn it off runtime via CDP
7963 // if we are running under a temp profile (none specified) we write out preferences to handle prompting to save
7964 // but if a profile is loaded and it doesn't have it set in the profile, we have to toggle it based upon the workflow setting
7965 // firefox has no runtime equivalent, so it is set in the profile prefs when the driver is built(BrowserHelper GetBrowserDriver).
7966 if (false == BrowserSettings.PromptForDownload && BrowserType.FireFox != BrowserSettings.BrowserType)
7967 BrowserHelper.AllowDownloads(BrowserSettings, BrowserSettings.DownloadLocation ?? FileHelper.GetDefaultDownloadDirectory(this));
7968 }
7969 }
7970 // an attached browser is not ours to start. nothing was launched so there is no Process, and without
7971 // this that missing Process read as "not started yet" and launched one on every action
7972 else if (null == BrowserSettings.Process && false == BrowserSettings.AttachedRestApi && false == BrowserSettings.UseExistingBrowser) // use browser extension/puppeteer to control browser, not selenium
7973 {
7974 if (true == UseOttoMagic)
7975 // we have to ensure we always open to a page that loads a content page.
7976 BrowserSettings.Process = LaunchWhereAsked(() => MagicHelper.LaunchBrowser(this, URL));
7977 else
7978 BrowserSettings.Process = LaunchWhereAsked(() => Puppeteer.LaunchBrowser(this, URL));
7979 }
7980
7981 // the desktop is asked what is on it, once, after the first launch. a driver that did not inherit the
7982 // desktop starts the browser back on the visible one and reports a clean launch either way, so the
7983 // only thing that tells the two apart is looking at where the window actually went
7984 if (true == BrowserSettings.HiddenDesktop && false == BrowserSettings.HiddenDesktopChecked)
7985 {
7986 BrowserSettings.HiddenDesktopChecked = true;
7987
7988 if (false == Desktops.HasWindows(BrowserSettings.HiddenDesktopName, 10_000))
7989 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Nothing arrived on [{BrowserSettings.HiddenDesktopName}], so this browser is on the desktop you are working on", this, GPALObjectType.Browser);
7990 }
7991
7992 // topping a window means nothing on a desktop nobody is looking at, and the hardware path it can fall
7993 // back to cannot reach one
7994 if (false == BrowserSettings.HiddenDesktop)
7995 BrowserHelper.TopBrowser(this, false); // ensure the browser window is on top, can use selenium or hardware (mouse click) topping. firefox will only top with hardware
7996
7997 CurrentUOW.ActionCalled = true;
7998
7999 if (true == doMaintenance)
8000 {
8001 SelectorListMaintenanceSweep(CurrentUOW); // purge any nodes marked 'deleteme' before iterating list
8002 SelectorListMaintenanceSweep(persistentUOW); // purge any nodes marked 'deleteme' before iterating list
8003 CheckPersistentSelectors(); // check if there are persistent selectors to deal with
8004 }
8005
8006 if (true == checkWaitFor)
8007 CheckWaitFor();
8008
8009 if (true == UsePuppeteer && null == BrowserSettings.PuppeteerUrl)
8010 {
8011 BrowserSettings.PuppeteerUrl = $"http://127.0.0.1:{BrowserSettings.DebugPort}";
8012 _ = PuppeteerCommunicator; // init
8013
8014 // Selenium writes download.prompt_for_download as a profile preference on every launch, but the
8015 // puppeteer path only gets it when LaunchBrowser built the profile itself. Setting it here covers
8016 // every profile, and being browser scoped it holds for tabs opened later
8017 if (false == BrowserSettings.PromptForDownload)
8018 {
8019 PuppeteerCommunicator.SetDownloadBehavior(BrowserSettings.DownloadLocation ?? FileHelper.GetDefaultDownloadDirectory(this)).GetAwaiter().GetResult();
8020 BrowserSettings.DownloadBehaviorSet = true;
8021 }
8022 }
8023
8024 // every engine is reachable and GoTo has not navigated yet, so held scripts registered here still
8025 // run on the first document
8026 ApplyPendingInjectedScripts();
8027 ArmDialogHandling();
8028
8029 return true;
8030 }
8031 bool firstRun { get; set; } = true;
8032 bool inAction = false;
8045 internal bool InAction(bool checkWaitFor, bool doMaintenance = true, GPALUrl URL = null)
8046 {
8047 bool retval = true;
8048 if (false == inAction)
8049 {
8050 inAction = true;
8051 FileDownloaded = false; // any action will clear out this flag, but we can leftclickanddownload and then .WIthSelector(noDataSelector) and preserve it.
8052
8053 // we have to return true otherwise callIfFound/NotFound are never invoked - waitfor just waits for the element to show up, but doesn't abend the workflow
8054 // getBrowserDriver should not return null now, should throw, so this should always return true
8055 retval = WorkflowSetup(checkWaitFor, doMaintenance, URL);
8056
8057 inAction = false;
8058 }
8059 return retval; // NOTE: CAVEAT: useless anymore
8060 }
8078 private void BackOutOfSelfClosedTab(GPALUrl URL, string priorUrl, GPALUrl priorGPALUrl, TabTuple priorTab, string priorHandle)
8079 {
8080 tabsWeOpened--;
8081
8082 // each engine finds its way back the way it already knows how. The tab we came from never navigated,
8083 // so it is still open and still sitting on the url it was on
8084 if (true == UseOttoMagic)
8085 GoToTab(priorTab);
8086 else if (true == UsePuppeteer)
8087 PuppeteerClient.GoToTab(priorGPALUrl).Execute();
8088 else
8089 {
8090 // back to the exact tab we left, by id. It never navigated, so it is still open unless the user
8091 // closed it themselves, in which case the last tab in the list is as good an answer as any
8092 var handles = BrowserDriver.WindowHandles;
8093 string landOn = true == handles.Contains(priorHandle) ? priorHandle : handles[handles.Count - 1];
8094
8095 BrowserDriver.SwitchTo().Window(landOn);
8096 CurrentTabIdx = handles.IndexOf(landOn);
8097 }
8098
8099 BrowserSettings.CurrentURL = priorUrl;
8100 BrowserSettings.CurrentGPALUrl = priorGPALUrl;
8101
8102 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{URL?.Url}] downloaded rather than opening a page, so its tab closed itself. Back on [{priorUrl}].", this, GPALObjectType.Browser);
8103 }
8113 internal int RealTabCount()
8114 {
8115 int realCount;
8116
8117 if (true == UseOttoMagic)
8118 realCount = MagicHelper.TabCount();
8119 else if (true == UsePuppeteer)
8120 realCount = PuppeteerCommunicator.TabCount().GetAwaiter().GetResult();
8121 else
8122 // the plural handles command does not need a live current window, unlike CurrentWindowHandle, so
8123 // this still answers after a tab has closed itself, which is the whole point of asking
8124 realCount = BrowserDriver.WindowHandles.Count;
8125
8126 return realCount;
8127 }
8128 private int GetActiveTabId()
8129 {
8130 foreach(var key in BrowserSettings.TabIdsToURL.Keys.Where(k => BrowserSettings.TabIdsToURL[k].ActiveTab == true))
8131 return key;
8132
8133 return 0;
8134 }
8139 private void UpdateActiveTab(TabTuple activeTabTuple)
8140 {
8141 string priorUrl = null;
8142 if (null != activeTabTuple)
8143 {
8144 // First, update or add the current tabId with its tabInfo
8145 if (BrowserSettings.TabIdsToURL.ContainsKey(activeTabTuple.TabId))
8146 {
8147 priorUrl = BrowserSettings.TabIdsToURL[activeTabTuple.TabId].URL;
8148 BrowserSettings.TabIdsToURL[activeTabTuple.TabId].URL = activeTabTuple.Url ?? BrowserSettings.TabIdsToURL[activeTabTuple.TabId].URL;
8149 BrowserSettings.TabIdsToURL[activeTabTuple.TabId].ActiveTab = true;
8150 }
8151 else
8152 {
8153 priorUrl = activeTabTuple.Url;
8154 BrowserSettings.TabIdsToURL.Add(activeTabTuple.TabId, new TabInfo { URL = activeTabTuple.Url, ActiveTab = true });
8155 }
8156
8157 // Second, set ActiveTab = false for all other tabs
8158 foreach (var key in BrowserSettings.TabIdsToURL.Keys.Where(k => k != activeTabTuple.TabId))
8159 BrowserSettings.TabIdsToURL[key].ActiveTab = false;
8160
8161 if (null != activeTabTuple.Url)
8162 {
8163 URLInfo uRLInfo = new URLInfo { TabId = activeTabTuple.TabId, ActiveTab = true };
8164 // Next, update or add the current URL with its urlInfo
8165 if (BrowserSettings.URLsToTabId.ContainsKey(priorUrl))
8166 {
8167 BrowserSettings.URLsToTabId[activeTabTuple.Url] = uRLInfo;
8168 BrowserSettings.URLsToTabId[activeTabTuple.Url].ActiveTab = true;
8169 if (false == priorUrl.Equals(activeTabTuple.Url))
8170 BrowserSettings.URLsToTabId.Remove(priorUrl);
8171 }
8172 else
8173 BrowserSettings.URLsToTabId.Add(activeTabTuple.Url, new URLInfo { TabId = activeTabTuple.TabId, ActiveTab = true });
8174
8175 // last, set ActiveTab = false for all other tabs
8176 foreach (var key in BrowserSettings.URLsToTabId.Keys.Where(k => k != activeTabTuple.Url))
8177 BrowserSettings.URLsToTabId[key].ActiveTab = false;
8178
8179 BrowserSettings.CurrentURL = activeTabTuple.Url;
8180 // NOTE: add search gpalurllist and setting gpalcurrenturl
8181 }
8182 }
8183 else
8184 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "ActiveTabTuple from last operation is null", this, GPALObjectType.Browser);
8185 }
8186 // NOTE: not implemented. the concept was TabIds for ottomagic, for precise tab switching where the
8187 // server rewrites urls
8195 internal bool TryDecodeNumberToIpAddress(string number, out string ipAddress)
8196 {
8197 bool retVal = false;
8198 uint numberUInt;
8199
8200 ipAddress = null;
8201
8202 if (true == UInt32.TryParse(number, out numberUInt))
8203 {
8204
8205 // Convert number to IPv4 bytes (network byte order)
8206 byte[] bytes = BitConverter.GetBytes(numberUInt);
8207 if (BitConverter.IsLittleEndian)
8208 Array.Reverse(bytes); // Ensure big-endian for IP
8209
8210 // Create IP address from bytes
8211 try
8212 {
8213 var ip = new System.Net.IPAddress(bytes);
8214 ipAddress = ip.ToString(); // e.g., "192.168.1.1"
8215
8216 // Validate: Ensure it's a valid IPv4 address (4 octets, 0-255 range)
8217 var octets = ipAddress.Split('.');
8218 if (octets.Length != 4) return false;
8219 if (octets.All(o => byte.TryParse(o, out byte value) && value <= 255))
8220 {
8221 try
8222 {
8223 var host = System.Net.Dns.GetHostEntry(ip);
8224 // GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{number}] resolves to [{ip}][{host.HostName}].", this, GPALObjectType.Browser);
8225 retVal = true;
8226 }
8227 catch (GPALException)
8229 throw;
8230 }
8231 catch (Exception ex)
8232 {
8233 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{number}][{ip}] doesn't resolve to a hostname.", this, GPALObjectType.Browser, ex);
8234 }
8235 }
8236
8237 }
8238 catch (GPALException)
8239 {
8240 throw;
8241 }
8242 catch (Exception ex)
8243 {
8244 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"[{number}] doesn't resolve to an IP address", this, GPALObjectType.Browser, ex);
8245 }
8246 }
8247
8248 return retVal;
8249 }
8250 #endregion <Private>
8257 public void Close(bool killWebDrivers = false)
8258 {
8259 InAction(false, false);
8260
8261 tabsWeOpened = CurrentTabIdx = 0;
8262 BrowserLaunched = null;
8263
8264 // tidying up is asked of the browser over its own connection, and a browser whose page has gone cannot
8265 // answer: its last tab closed by the workflow, or crashed. that threw out of Close before the browser was
8266 // ended, and a chromium profile with extensions keeps running without a window, so it stayed holding the
8267 // profile. what cannot be tidied is said, and the browser is ended regardless
8268 try
8269 {
8270 // Scripts registered through the extension live in the profile, not the browser, and persist after
8271 // it closes. The next browser on that profile would run them, so they are removed here
8272 if (true == UseOttoMagic && true == _scriptsRegistered)
8274
8275 StopCasting();
8276
8277 // the download directory was pointed at ours for the workflow, and it is the user's browser
8278 if (true == BrowserSettings.DownloadBehaviorSet)
8279 {
8280 BrowserSettings.DownloadBehaviorSet = false;
8281 PuppeteerCommunicator.RestoreDownloadBehavior().GetAwaiter().GetResult();
8282 }
8283 }
8284 catch (Exception ex)
8285 {
8286 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"[{BrowserSettings.BrowserType}] could not be tidied up before closing, so it is closed as it is", this, GPALObjectType.Browser, ex);
8287 }
8288
8289 // someone else started it and may still be using it, so the window goes and nothing else. that is the
8290 // whole difference an attached browser makes: it is not ours to end, whichever engine drives it
8291 if (true == BrowserSettings.AttachedRestApi || true == BrowserSettings.UseExistingBrowser)
8292 CloseOurWindowOnly();
8293 else if (1 == GPAL.Browsers.Count || true == killWebDrivers)
8294 {
8295 if (true == UsePuppeteer)
8296 BrowserSettings.PuppeteerCommunicator._isRunning = false;
8297
8298 // kill all removes temp profile directory if it exists
8299 BrowserHelper.KillAllRunningProcesses(killWebDrivers, this);
8300 }
8301 else
8302 CloseOurWindowOnly();
8303
8304 // firefox rewrites its prefs.js on the way out the same as chrome does, so this waits for the same
8305 // reason: the process has to be gone first
8306 if (null != BrowserSettings.PreviousFirefoxPreferences)
8307 {
8308 BrowserHelper.WaitForBrowserToExit(BrowserSettings, ProfileRestoreWaitMs);
8309 FirefoxProfileManager.RestoreUserJs(BrowserSettings.ProfileDataDirectory, BrowserSettings.PreviousFirefoxPreferences);
8310 BrowserSettings.PreviousFirefoxPreferences = null;
8311 }
8312
8313 // chrome writes Preferences as it shuts down, so anything we put back while it is still running is
8314 // overwritten. the profile only goes back once the process is gone
8315 if (null != BrowserSettings.PreviousDownloadPreferences)
8316 {
8317 BrowserHelper.WaitForBrowserToExit(BrowserSettings, ProfileRestoreWaitMs);
8318 ChromeProfileManager.RestoreDownloadPreferences(BrowserSettings.ProfileDataDirectory, BrowserSettings.PreviousDownloadPreferences);
8319 BrowserSettings.PreviousDownloadPreferences = null;
8320 }
8321
8322 // a chromium browser hosting an extension's background page does not stop when its last window
8323 // closes; it starts a windowless replacement that holds the profile open. ours started it, so ours
8324 // ends it, rather than leaving it for the next run to fail on
8325 BrowserHelper.EndWindowlessSuccessor(BrowserSettings);
8326
8327 BrowserSettings.Process = null;
8328
8329 GPAL.Browsers.Remove(this);
8330 }
8337 private void CloseOurWindowOnly()
8338 {
8339 if (true == UseSelenium)
8340 BrowserDriver.Close();
8341 else if (true == UseOttoMagic && true == IsAlive)
8342 MagicHelper.CloseWindow(BrowserSettings.CurrentURL);
8343 else if (true == UsePuppeteer)
8344 PuppeteerClient.CloseWindow().Execute();
8345 }
8346
8378 internal void ResolveInteractionType(Selector selector)
8379 {
8380 InteractionType engineDefault = true == UseOttoMagic ? InteractionType.OttoMagic
8381 : true == UsePuppeteer ? InteractionType.Puppeteer
8382 : InteractionType.Selenium;
8383 InteractionType resolved = engineDefault;
8384 bool wantsHardware = InteractionType.Hardware == selector.RequestedInteractionType || true == BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware;
8385
8386 if (true == wantsHardware)
8387 {
8388 // SendInput reaches the desktop the user is on and no other. the engine is stepped down in
8389 // WorkflowSetup, which covers a HW engine, but GPAL.WithUseHardware and a selector's own
8390 // WithHardware are asked for here and reach no further than the engine does
8391 if (true == BrowserSettings.HiddenDesktop)
8392 {
8393 if (false == BrowserSettings.HiddenDesktopHardwareReported)
8394 {
8395 BrowserSettings.HiddenDesktopHardwareReported = true;
8396
8397 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Hardware input cannot reach [{BrowserSettings.HiddenDesktopName}] while it is hidden, so [{engineDefault}] is used instead. Peek at the desktop to use hardware there.", this, GPALObjectType.Browser);
8398 }
8399 }
8400 else if (false == BrowserSettings.UseHeadless)
8401 resolved = InteractionType.Hardware;
8402 else if (InteractionType.Hardware == selector.RequestedInteractionType)
8403 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Selector [{selector.Name}] asked for hardware interaction, which a headless browser has no mouse or keyboard to perform. Using [{engineDefault}].", this, GPALObjectType.Browser);
8404 }
8405 else if (InteractionType.JavaScript == selector.RequestedInteractionType || true == BrowserSettings.UseJavaScript)
8406 // ottomagic already drives the page through injected javascript, so its own interaction is the answer
8407 resolved = true == UseOttoMagic ? InteractionType.OttoMagic : InteractionType.JavaScript;
8408 else if (InteractionType.Selenium == selector.RequestedInteractionType && false == UseSelenium)
8409 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Selector [{selector.Name}] asked for selenium interaction, which the [{AutomationEngine}] engine has no driver for. Using [{engineDefault}].", this, GPALObjectType.Browser);
8410
8411 selector.InteractionType = resolved;
8412 }
8413 public void RemoveCallIfHandlerEverywhere(CallIfDelegate func)
8414 {
8415 // soft delete. the handler stays in the list and gets skipped when the chain runs, which keeps a
8416 // reused selector's handlers intact for the next workflow and keeps us from mutating a list
8417 // CallIfHandlers is in the middle of iterating - this is nearly always called from inside a handler
8418 foreach (Selector selector in CurrentUOW.WithSelectorList)
8419 if (false == selector.RemovedMethods.Contains(func))
8420 selector.RemovedMethods.Add(func);
8421
8422 foreach (Selector selector in CurrentUOW.InSelectorList)
8423 if (false == selector.RemovedMethods.Contains(func))
8424 selector.RemovedMethods.Add(func);
8425
8426 foreach (Selector selector in persistentUOW.WithSelectorList)
8427 if (false == selector.RemovedMethods.Contains(func))
8428 selector.RemovedMethods.Add(func);
8429 }
8440 public bool IsEndOfPage()
8441 {
8442 if (true == UseOttoMagic)
8443 return MagicHelper.IsEndOfPage();
8444 else if (true == UsePuppeteer)
8445 {
8446 try
8447 {
8448 bool tmp = PuppeteerClient.IsEndOfPage().Execute<bool>();
8449 return tmp;
8450 }
8451 catch
8452 {
8453 return true;
8454 }
8455 }
8456 else
8457 {
8458 try
8459 {
8460 // JavaScript code to check if at end of page
8461 string script = @"
8462 return (function() {
8463 try {
8464 const overflow = getComputedStyle(document.documentElement).overflow;
8465 if (overflow !== 'hidden') {
8466 return (window.innerHeight + window.scrollY) >= document.body.scrollHeight;
8467 }
8468 const scrollable = Array.from(document.querySelectorAll('*')).find(
8469 el => el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
8470 ) || document.body;
8471 return (scrollable.clientHeight + scrollable.scrollTop) >= scrollable.scrollHeight;
8472 } catch {
8473 return false;
8474 }
8475 })()";
8476
8477 // Execute the script
8478 ExecuteJavaScriptObj(script);
8479
8480 // Parse result
8481 if (bool.TryParse(JavaScriptResultObj?.ToString(), out bool val))
8482 {
8483 return val;
8484 }
8485 return false;
8486 }
8487 catch
8488 {
8489 return false;
8490 }
8491 }
8492 }
8493 #endregion <Helpers>
8494 #region Window Handling
8499 public IAllowBrowserActionOrAnySelector NextWindow
8500 {
8501 get
8502 {
8503 InAction(false);
8504 if (true == UseOttoMagic)
8505 {
8506 WindowTuple window = MagicHelper.NextWindow();
8507 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8508 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8509 BrowserSettings.WindowIdsToURL[window.WindowId] = new WindowInfo { URL = window.Url, ActiveWindow = true };
8510 BrowserSettings.CurrentURL = window.Url;
8511 }
8512 else if (true == UsePuppeteer)
8513 {
8514 PuppeteerClient.NextWindow().Execute();
8515 }
8516 else // Selenium
8517 {
8518 // Get all tracked windows from your dictionary
8519 var trackedWindows = BrowserSettings.WindowIdsToURL.Keys.ToList();
8520
8521 if (trackedWindows.Count <= 1)
8522 return this; // Nothing to cycle to
8523
8524 // Find the current active one
8525 string currentHandle = trackedWindows.FirstOrDefault(h =>
8526 BrowserSettings.WindowIdsToURL[h].ActiveWindow);
8527
8528 // If none marked active (edge case), fall back to Selenium's current handle
8529 if (string.IsNullOrEmpty(currentHandle))
8530 currentHandle = BrowserDriver.CurrentWindowHandle;
8531
8532 // Find its index in the list
8533 int currentIndex = trackedWindows.IndexOf(currentHandle);
8534 if (currentIndex == -1) currentIndex = 0; // safety
8535
8536 // Calculate next index (cycle)
8537 int nextIndex = (currentIndex + 1) % trackedWindows.Count;
8538 string nextHandle = trackedWindows[nextIndex];
8539
8540 // Switch to it in Selenium
8541 BrowserDriver.SwitchTo().Window(nextHandle);
8542
8543 // Update active flags
8544 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8545 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8546
8547 BrowserSettings.WindowIdsToURL[nextHandle].ActiveWindow = true;
8548
8549 // Optional: refresh URL in case page navigated independently
8550 BrowserSettings.WindowIdsToURL[nextHandle].URL = BrowserDriver.Url;
8551
8552 BrowserSettings.CurrentURL = BrowserDriver.Url;
8553 }
8554
8555 GetSetCurrentUrl();
8556 return this;
8557 }
8558 }
8559
8565 public IAllowBrowserActionOrAnySelector OpenWindow(GPALUrl URL)
8566 {
8567 InAction(false);
8568
8569 string urlString = MagicHelper.GetFullUrl(URL?.Url, this, out _areRobotsAllowed);
8570
8571 if (false == AreRobotsAllowed && true == BrowserSettings.ObeyRobotsTxt)
8572 {
8573 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Visiting [{URL?.Url}] is disallowed by robots.txt and your request to honor it. Not going to URL. Workflow will fail.", this, GPALObjectType.Browser);
8574 URL.ForUrl("https://google.com");
8575 }
8576
8577 if (UseOttoMagic)
8578 {
8579 WindowTuple window = MagicHelper.OpenWindow(urlString);
8580
8581 // Deactivate all others
8582 foreach (var key in BrowserSettings.WindowIdsToURL.Keys.ToList())
8583 {
8584 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8585 }
8586
8587 // Add or update the new one
8588 BrowserSettings.WindowIdsToURL[window.WindowId] = new WindowInfo
8589 {
8590 URL = window.Url,
8591 ActiveWindow = true
8592 };
8593
8594 BrowserSettings.CurrentURL = window.Url ?? urlString;
8595 }
8596 else if (UsePuppeteer)
8597 {
8598 PuppeteerClient.OpenWindow(urlString).Execute();
8599 // Assume Puppeteer updates CurrentURL internally, or fetch it later
8600 BrowserSettings.CurrentURL = urlString; // fallback if not updated elsewhere
8601 }
8602 else // Selenium
8603 {
8604 string originalHandle = BrowserDriver.CurrentWindowHandle;
8605
8606 // This creates a new top-level window and switches to it automatically
8607 BrowserDriver.SwitchTo().NewWindow(WindowType.Window);
8608
8609 // Now we're in the new window
8610 string newHandle = BrowserDriver.CurrentWindowHandle;
8611
8612 tabWindows[newHandle] = newHandle; // a window is its own window, and the first tab in it
8613
8614 // Navigate to the desired URL
8615 BrowserHelper.SeleniumGoToUrl(urlString, BrowserSettings);
8616
8617 // Update your internal tracking
8618 foreach (var key in BrowserSettings.WindowIdsToURL.Keys.ToList())
8619 {
8620 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8621 }
8622
8623 BrowserSettings.WindowIdsToURL[newHandle] = new WindowInfo
8624 {
8625 URL = urlString,
8626 ActiveWindow = true
8627 };
8628
8629 BrowserSettings.CurrentURL = BrowserDriver.Url; // should be the url now
8630 }
8631
8632 URL = GetSetCurrentUrl();
8633
8634 // NOTE: use evaluation shortcuiting, if it's not set, don't even check, it has a performance cost
8635 if (true == BrowserSettings.RespectRobotMetaTags && false == UrlHelper.CheckRobotMetaTags(URL, this))
8636 {
8637 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Visiting [{BrowserSettings.CurrentURL}] is disallowed by robot meta tags and your request to honor them..");
8638 URL.ForUrl("https://google.com");
8639
8640 if (true == UseOttoMagic)
8641 {
8642 TabTuple tabTuple = MagicHelper.GoTo(URL);
8643 UpdateActiveTab(tabTuple);
8644 }
8645 else if (true == UsePuppeteer)
8646 {
8648 PuppeteerClient.GoTo(URL).Execute();
8649 }
8650 else
8651 {
8652 BrowserDriver.SwitchTo().DefaultContent();
8653 BrowserHelper.SeleniumGoToUrl(URL, BrowserSettings);
8654 }
8655 }
8656
8657 BrowserSettings.GPALUrlList.Add(URL);
8658
8659 return this;
8660 }
8661
8666 public IAllowBrowserActionOrAnySelector PreviousWindow
8667 {
8668 get
8669 {
8670 InAction(false);
8671
8672 if (UseOttoMagic)
8673 {
8674 WindowTuple window = MagicHelper.PreviousWindow();
8675
8676 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8677 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8678
8679 BrowserSettings.WindowIdsToURL[window.WindowId] = new WindowInfo
8680 {
8681 URL = window.Url,
8682 ActiveWindow = true
8683 };
8684
8685 BrowserSettings.CurrentURL = window.Url;
8686 }
8687 else if (UsePuppeteer)
8688 {
8689 PuppeteerClient.PreviousWindow().Execute();
8690 }
8691 else // Selenium
8692 {
8693 // Get all tracked windows from dictionary (preserves insertion order)
8694 var trackedWindows = BrowserSettings.WindowIdsToURL.Keys.ToList();
8695
8696 if (trackedWindows.Count <= 1)
8697 return this; // Nothing to cycle to
8698
8699 // Find the current active one
8700 string currentHandle = trackedWindows.FirstOrDefault(h =>
8701 BrowserSettings.WindowIdsToURL[h].ActiveWindow);
8702
8703 // Fallback to Selenium's current handle if none marked active (safety)
8704 if (string.IsNullOrEmpty(currentHandle))
8705 currentHandle = BrowserDriver.CurrentWindowHandle;
8706
8707 // Find its index
8708 int currentIndex = trackedWindows.IndexOf(currentHandle);
8709 if (currentIndex == -1) currentIndex = 0;
8710
8711 // Calculate previous index (wrap around to the last one if at the beginning)
8712 int previousIndex = (currentIndex - 1 + trackedWindows.Count) % trackedWindows.Count;
8713 string previousHandle = trackedWindows[previousIndex];
8714
8715 // Switch to it in Selenium
8716 BrowserDriver.SwitchTo().Window(previousHandle);
8717
8718 // Update active flags
8719 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8720 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8721
8722 BrowserSettings.WindowIdsToURL[previousHandle].ActiveWindow = true;
8723
8724 // Optional: refresh stored URL in case the page changed
8725 BrowserSettings.WindowIdsToURL[previousHandle].URL = BrowserDriver.Url;
8726
8727 BrowserSettings.CurrentURL = BrowserDriver.Url;
8728 }
8729
8730 GetSetCurrentUrl();
8731 return this;
8732 }
8733 }
8734
8740 public IAllowBrowserActionOrAnySelector GoToWindow(dynamic urlOrId)
8741 {
8742 InAction(false);
8743 if (true == UseOttoMagic)
8744 {
8745 string windowId = null;
8746 WindowTuple window = null;
8747
8748 if (urlOrId is GPALUrl url)
8749 {
8750 windowId = BrowserSettings.WindowIdsToURL.FirstOrDefault(x => x.Value.URL.Contains(url.Url) || url.Url.Contains(x.Value.URL)).Key;
8751 BrowserSettings.CurrentGPALUrl = url;
8752 }
8753 else if (urlOrId is string urlStr)
8754 windowId = BrowserSettings.WindowIdsToURL.FirstOrDefault(x => x.Value.URL.Contains(urlStr) || urlStr.Contains(x.Value.URL)).Key;
8755
8756 if (true == string.IsNullOrEmpty(windowId))
8757 window = MagicHelper.GoToWindow(urlOrId);
8758 else
8759 window = MagicHelper.GoToWindow(windowId);
8760
8761 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8762 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8763
8764 if (false == BrowserSettings.WindowIdsToURL.ContainsKey(window.WindowId))
8765 BrowserSettings.WindowIdsToURL[window.WindowId] = new WindowInfo { URL = window.Url, ActiveWindow = true };
8766 else
8767 BrowserSettings.WindowIdsToURL[window.WindowId].ActiveWindow = true;
8768
8769 BrowserSettings.CurrentURL = window.Url;
8770 }
8771 else if (true == UsePuppeteer)
8772 {
8773 string windowId = null;
8774 WindowTuple window = null;
8775
8776 if (urlOrId is GPALUrl url)
8777 {
8778 windowId = BrowserSettings.WindowIdsToURL.FirstOrDefault(x => x.Value.URL.Contains(url.Url) || url.Url.Contains(x.Value.URL)).Key;
8779 BrowserSettings.CurrentGPALUrl = url;
8780 }
8781 else if (urlOrId is string urlStr)
8782 windowId = BrowserSettings.WindowIdsToURL.FirstOrDefault(x => x.Value.URL.Contains(urlStr) || urlStr.Contains(x.Value.URL)).Key;
8783
8784 if (true == string.IsNullOrEmpty(windowId))
8785 window = PuppeteerClient.GoToWindow(urlOrId).Execute<WindowTuple>();
8786 else if (true == Int32.TryParse(windowId, out int windowIdInt))
8787 window = PuppeteerClient.GoToWindow(windowIdInt).Execute<WindowTuple>();
8788
8789 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8790 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8791
8792 if (false == BrowserSettings.WindowIdsToURL.ContainsKey(window.WindowId))
8793 BrowserSettings.WindowIdsToURL[window.WindowId] = new WindowInfo { URL = window.Url, ActiveWindow = true };
8794 else
8795 BrowserSettings.WindowIdsToURL[window.WindowId].ActiveWindow = true;
8796
8797 BrowserSettings.CurrentURL = window.Url;
8798 }
8799 else if (true == UseSelenium)
8800 {
8801 string theUrl = null;
8802 string targetHandle = null;
8803
8804 if (urlOrId is GPALUrl url)
8805 theUrl = url.Url;
8806
8807 foreach (var handle in BrowserDriver.WindowHandles)
8809 BrowserDriver.SwitchTo().Window(handle);
8810 if (urlOrId is string str && (handle == str || BrowserDriver.Url.Contains(str)))
8811 {
8812 targetHandle = handle;
8813 break;
8814 }
8815 }
8816
8817 if (targetHandle != null)
8818 {
8819 BrowserDriver.SwitchTo().Window(targetHandle);
8820 BrowserSettings.CurrentURL = BrowserDriver.Url;
8821 }
8822 else
8823 {
8824 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to detect window with URL/ID [{urlOrId}]", this, GPALObjectType.Browser);
8825 }
8826 }
8827
8828 GetSetCurrentUrl();
8829 return this;
8830 }
8831
8837 public IAllowBrowserActionOrAnySelector CloseWindow(dynamic urlOrId = null)
8838 {
8839 // NOTE: CAVEAT: we aren't currently using the id we planned for with ottomagic, but this will cause problems if we do
8840 urlOrId = MagicHelper.GetFullUrl(urlOrId, this, out bool _);
8841
8842 InAction(false);
8843
8844 BrowserSettings.CurrentGPALUrl = null;
8845
8846 if (true == UseOttoMagic)
8847 {
8848 string windowIdToClose = null;
8849
8850 if (urlOrId is GPALUrl url)
8851 {
8852 if (false == string.IsNullOrEmpty(url.Url))
8853 // Find by partial URL match
8854 windowIdToClose = BrowserSettings.WindowIdsToURL
8855 .FirstOrDefault(x => x.Value.URL.Contains(url.Url) || url.Url.Contains(x.Value.URL)) // NOTE: CAVEAT: is doing contains both ways safe?
8856 .Key;
8857 }
8858 else if (urlOrId is string urlStr)
8859 {
8860 if (false == string.IsNullOrEmpty(urlStr))
8861 // Find by partial URL match
8862 windowIdToClose = BrowserSettings.WindowIdsToURL
8863 .FirstOrDefault(x => x.Value.URL.Contains(urlStr) || urlStr.Contains(x.Value.URL)) // NOTE: CAVEAT: is doing contains both ways safe?
8864 .Key;
8865 }
8866 else
8867 {
8868 // Default: close the currently active window
8869 windowIdToClose = BrowserSettings.WindowIdsToURL
8870 .FirstOrDefault(x => true == x.Value.ActiveWindow)
8871 .Key;
8872 }
8873
8874 if (string.IsNullOrEmpty(windowIdToClose))
8875 {
8876 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
8877 $"No window found to close (URL containing [{urlOrId}])", this, GPALObjectType.Browser);
8878 return this;
8879 }
8880
8881 // Let MagicHelper close it and return the new active window
8882 WindowTuple newActiveWindow = MagicHelper.CloseWindow(windowIdToClose);
8883
8884 // Remove from tracking
8885 BrowserSettings.WindowIdsToURL.Remove(windowIdToClose);
8886
8887 // Update active status for the new one (if any windows remain)
8888 if (newActiveWindow != null && !string.IsNullOrEmpty(newActiveWindow.WindowId))
8889 {
8890 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8891 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8892
8893 BrowserSettings.WindowIdsToURL[newActiveWindow.WindowId] = new WindowInfo
8894 {
8895 URL = newActiveWindow.Url,
8896 ActiveWindow = true
8897 };
8898
8899 BrowserSettings.CurrentURL = newActiveWindow.Url;
8900 }
8901 else if (BrowserSettings.WindowIdsToURL.Count == 0)
8902 {
8903 BrowserSettings.CurrentURL = null;
8904 }
8905 }
8906 else if (true == UsePuppeteer)
8907 {
8908 // Puppeteer handles its own tracking and current URL update
8909 if (urlOrId is GPALUrl url)
8910 PuppeteerClient.CloseWindow(url).Execute();
8911 else if (urlOrId is string urlStr)
8912 PuppeteerClient.CloseWindow(urlStr).Execute();
8913 else if (urlOrId is int id)
8914 PuppeteerClient.CloseWindow(id).Execute();
8915 else
8916 PuppeteerClient.CloseWindow().Execute();
8917 }
8918 else // Selenium
8919 {
8920 string handleToClose = null;
8921
8922 if (urlOrId is string urlStr)
8923 {
8924 // Search by partial URL match
8925 handleToClose = BrowserSettings.WindowIdsToURL
8926 .FirstOrDefault(x => x.Value.URL.Contains(urlStr) || urlStr.Contains(x.Value.URL))
8927 .Key;
8928 }
8929 else
8930 {
8931 // Default: close the currently active window
8932 handleToClose = BrowserSettings.WindowIdsToURL
8933 .FirstOrDefault(x => x.Value.ActiveWindow)
8934 .Key;
8935
8936 // Fallback to Selenium's current handle if not marked
8937 if (string.IsNullOrEmpty(handleToClose))
8938 handleToClose = BrowserDriver.CurrentWindowHandle;
8939 }
8940
8941 if (string.IsNullOrEmpty(handleToClose) ||
8942 !BrowserSettings.WindowIdsToURL.ContainsKey(handleToClose))
8943 {
8944 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
8945 $"No window found to close (URL containing [{urlOrId}])", this, GPALObjectType.Browser);
8946 return this;
8947 }
8948
8949 // Switch to the target window and close it
8950 BrowserDriver.SwitchTo().Window(handleToClose);
8951 BrowserDriver.Close(); // Closes the current window/tab
8952
8953 // Remove from internal tracking
8954 BrowserSettings.WindowIdsToURL.Remove(handleToClose);
8955
8956 // If no windows left
8957 if (BrowserDriver.WindowHandles.Count == 0)
8958 {
8959 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "No windows remain after closing.", this, GPALObjectType.Browser);
8960 return this;
8961 }
8962
8963 // Determine which window Selenium switched to automatically
8964 string newActiveHandle = BrowserDriver.CurrentWindowHandle;
8965
8966 // Update active flags
8967 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8968 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8970 if (BrowserSettings.WindowIdsToURL.ContainsKey(newActiveHandle))
8971 {
8972 BrowserSettings.WindowIdsToURL[newActiveHandle].ActiveWindow = true;
8973 BrowserSettings.WindowIdsToURL[newActiveHandle].URL = BrowserDriver.Url; // refresh
8974 }
8975 else
8976 {
8977 // Fallback: pick first remaining and mark active (shouldn't happen normally)
8978 var firstRemaining = BrowserSettings.WindowIdsToURL.Keys.FirstOrDefault();
8979 if (firstRemaining != null)
8980 {
8981 BrowserSettings.WindowIdsToURL[firstRemaining].ActiveWindow = true;
8982 BrowserDriver.SwitchTo().Window(firstRemaining);
8983 }
8984 }
8985 }
8986
8987 GetSetCurrentUrl();
8988 return this;
8989 }
8990
8991 #endregion Window Handling
8992 #region WithWorkFlow, Run, While, Until, Generated by Grok
8998 public IAllowBrowserActionOrAnySelector Run()
8999 {
9000 WorkflowManager.Run();
9001 return this;
9002 }
9006 /// browser handed to each is this one.
9007 /// </summary>
9008 /// <remarks>
9009 /// The list is what <see cref="While"/> and <see cref="Until(Selector)"/> repeat, which is what makes it
9010 /// worth having: a page of results and a Next button is one workflow run until the button is gone.
9011 /// <br/><br/>
9012 /// To run several workflows at once, each with a browser of its own and possibly a different browser or
9013 /// engine, use <see cref="GPAL.Workflow"/>. This browser runs its workflows sequentially and always has.
9014 /// </remarks>
9015 /// <example>
9016 /// Page through search results, gathering each page, until the Next button is no longer there:
9017 /// <code>
9018 /// browser
9019 /// .GoTo("https://www.example.com/search?q=laptop")
9020 /// .WithWorkflow(b =&gt;
9021 /// {
9022 /// b.WithSelector(GPAL.CssSelector(".result-title"))
9023 /// .WithAllThatMatch()
9024 /// .GetElements(out List&lt;GPALElement&gt; found);
9025 ///
9026 /// results.AddRange(found);
9027 ///
9028 /// b.WithSelector(GPAL.CssSelector("a.next")).LeftClick();
9029 /// })
9030 /// .Until(b =&gt; 0 == b.CurrentUOW.WebSelectorFoundResults.Count);
9031 /// </code>
9032 /// </example>
9033 /// <param name="workflow">The workflow to run. The browser passed in is this browser.</param>
9034 /// <returns>Fluent interface to add another workflow, or to run them</returns>
9035 public IAllowWorkflowExecution WithWorkflow(Action<IBrowser> workflow)
9036 {
9037 return WorkflowManager.WithWorkflow(workflow);
9038 }
9039
9045 public IAllowWorkflowExecution WhileLoopTimeout(int timeoutMs)
9046 {
9047 if (timeoutMs <= 0)
9048 {
9049 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Invalid timeout ([{timeoutMs}]ms) specified. Using default [{BrowserSettings.WhileLoopTimeoutMs}]ms.", this, GPALObjectType.Browser);
9050 }
9051 else
9052 {
9053 BrowserSettings.WhileLoopTimeoutMs = timeoutMs;
9054 }
9055 return this;
9056 }
9057
9063 public IAllowWorkflowExecution WhileLoopMaxIterations(int maxIterations)
9065 if (maxIterations <= 0)
9066 {
9067 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Invalid max iterations ([{maxIterations}]) specified. Using default [{BrowserSettings.WhileLoopMaxIterations}].", this, GPALObjectType.Browser);
9068 }
9069 else
9070 {
9071 BrowserSettings.WhileLoopMaxIterations = maxIterations;
9072 }
9073 return this;
9074 }
9079
9082 public IAllowBrowserActionOrAnySelector While(Func<Browser, bool> predicate)
9083 {
9084 return WorkflowManager.While(predicate);
9085 }
9091
9095 return WorkflowManager.Until(selector);
9096 }
9097
9099
9102 /// <param name="predicate">A function evaluated against the <see cref="Browser"/> after each iteration; the loop stops once it returns true</param>
9103 /// <returns>Fluent interface to write your workflow</returns>
9104 public IAllowBrowserActionOrAnySelector Until(Func<Browser, bool> predicate)
9105 {
9106 return WorkflowManager.Until(predicate);
9107 }
9108 #endregion WithWorkFlow, Run, While, Until, Generated by Grok
9109 #region OCR
9110 #endregion OCR
9111 #region Getters/Setters
9115 public List<string> RobotsTxt
9116 {
9117 get => BrowserSettings.RobotsTxt;
9118 internal set => BrowserSettings.RobotsTxt = value;
9119 }
9123 public object JavaScriptResultObj
9124 {
9125 get => BrowserSettings.JavaScriptResultObj;
9126 internal set => BrowserSettings.JavaScriptResultObj = value;
9127 }
9131 public string JavaScriptResultStr
9132 {
9133 get => BrowserSettings.JavaScriptResultStr;
9134 internal set => BrowserSettings.JavaScriptResultStr = value;
9135 }
9139 public string BrowserVersion
9140 {
9141 get => BrowserSettings.Version;
9142 internal set => BrowserSettings.Version = value;
9143 }
9144
9145 #endregion
9146 #region Helpers
9152 internal static string GetModifierKeys(ModifierKeys modifierKeys)
9153 {
9154 if (modifierKeys == ModifierKeys.NONE)
9155 return "None";
9156
9157 var setFlags = Enum.GetValues(typeof(ModifierKeys))
9158 .Cast<ModifierKeys>()
9159 .Where(flag => flag != ModifierKeys.NONE && modifierKeys.HasFlag(flag))
9160 .Select(flag => flag.ToString());
9161
9162 return string.Join(", ", setFlags);
9163 }
9169 internal string GetSetCurrentUrl()
9170 {
9171 string currentUrl = null;
9172
9173 try
9174 {
9175 if (true == BrowserSettings.UseOttoMagic)
9176 currentUrl = MagicHelper.GetCurrentUrl();
9177 else if (true == UsePuppeteer)
9178 currentUrl = BrowserSettings.Browser.PuppeteerClient.GetCurrentUrl().Execute<dynamic>();
9179 else
9180 // read the url of whatever tab Selenium currently has active - do NOT switch by our own
9181 // CurrentTabIdx, which if stale would switch to the wrong tab and read the wrong url
9182 currentUrl = BrowserDriver.SwitchTo().Window(BrowserDriver.CurrentWindowHandle).Url;
9183
9184 currentUrl = currentUrl?.Trim('"');
9185
9186 if (false == UrlHelper.AreEquivalent(currentUrl, BrowserSettings.CurrentURL))
9187 {
9188 BrowserSettings.CurrentURL = currentUrl;
9189
9190 // invalidate cached elements, we are on a new page
9191 foreach (Selector selector in this.CurrentUOW.WithSelectorList)
9192 {
9193 selector.WebSelectorFoundResults = null;
9194 selector.WebSelectorMatchedResults = null;
9195 }
9196 }
9197 }
9198 catch (GPALException)
9199 {
9200 throw;
9201 }
9202 catch (Exception ex)
9203 {
9204 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to get URL, returning [{BrowserSettings.CurrentURL}]", this, GPALObjectType.Browser, ex);
9205 }
9206 return BrowserSettings.CurrentURL;
9207 }
9212 internal void SetReferrer()
9213 {
9214 string message = $"[{BrowserSettings.Referrer}] referrer applied.";
9215 var parms = new Dictionary<string, object>
9216 {
9217 {
9218 "headers", new Dictionary<string, object>
9219 {
9220 { "Referer", BrowserSettings.Referrer } // NOTE: the referrer misspelling is baked into the http/1.0 protocol spec
9221 }
9222 }
9223 };
9224
9225 if (null != BrowserSettings.Referrer)
9226 {
9227 bool applied = true;
9228
9229 if (true == UsePuppeteer)
9230 PuppeteerClient.OverrideReferrer(BrowserSettings.Referrer).Execute();
9231 else if (true == BrowserSettings.UseOttoMagic)
9232 MagicHelper.OverrideReferrer(BrowserSettings.Referrer);
9233 else if (BrowserType.FireFox != BrowserSettings.BrowserType)
9234 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Network.setExtraHTTPHeaders", parms);
9235 else
9236 applied = false;
9237
9238 // clear it, then ignore it.
9239 if (true == string.Empty.Equals(BrowserSettings.Referrer))
9240 {
9241 BrowserSettings.Referrer = null;
9242 message = $"Cleared referrer.";
9243 }
9244
9245 if (true == applied)
9246 GPAL.PublishSimpleEvent(GPALEventType.INFO, message, this, GPALObjectType.Browser);
9247 else
9248 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{BrowserSettings.BrowserType}] on [{BrowserSettings.AutomationEngine}] cannot set a header, so the referrer was not applied", this, GPALObjectType.Browser);
9249 }
9250 }
9255 internal void SetUserAgent()
9256 {
9257 string message = $"[{BrowserSettings.OverrideUserAgent}] user agent applied.";
9258 var parms = new Dictionary<string, object> { { "userAgent", BrowserSettings.OverrideUserAgent } };
9259
9260 if (null != BrowserSettings.OverrideUserAgent)
9261 {
9262 bool applied = true;
9263
9264 if (true == UsePuppeteer)
9265 PuppeteerClient.SetUserAgent(BrowserSettings.OverrideUserAgent).Execute();
9266 else if (true == BrowserSettings.UseOttoMagic)
9267 MagicHelper.SetUserAgent(BrowserSettings.OverrideUserAgent);
9268 else if (BrowserType.FireFox != BrowserSettings.BrowserType)
9269 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Network.setUserAgentOverride", parms);
9270 else
9271 applied = false;
9272
9273 // clear it, then ignore it.
9274 if (true == string.Empty.Equals(BrowserSettings.OverrideUserAgent))
9275 {
9276 BrowserSettings.OverrideUserAgent = null;
9277 message = $"Cleared user agent override.";
9278 }
9279
9280 if (true == applied)
9281 GPAL.PublishSimpleEvent(GPALEventType.INFO, message, this, GPALObjectType.Browser);
9282 else
9283 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{BrowserSettings.BrowserType}] on [{BrowserSettings.AutomationEngine}] cannot set a user agent, so the override was not applied", this, GPALObjectType.Browser);
9284 }
9285 }
9286 #endregion Helpers
9287 #region Sitep Operations
9293 public IAllowBrowserActionOrAnySelector GetHydratedData(out NextJsHydrationResult hydrationResult)
9294 {
9295 hydrationResult = CachedHydration();
9296 return this;
9297 }
9298
9303 private NextJsHydrationResult CachedHydration()
9304 {
9305 string key = GetSetCurrentUrl();
9306
9307 if (false == string.IsNullOrEmpty(key) && true == _hydrationCache.TryGetValue(key, out NextJsHydrationResult cached))
9308 {
9309 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Hydrated data cache hit for [{UrlHelper.GetOrigin(CurrentUrl)}]", this, GPALObjectType.Browser);
9310 return cached;
9311 }
9312
9313 // Read from the document. Nothing here parses markup
9314 NextJsHydrationResult result = NextJsHydrationExtractor.ExtractFromScripts(this, GetHydrationScripts());
9315
9316 if (false == string.IsNullOrEmpty(key))
9317 _hydrationCache[key] = result;
9318
9319 return result;
9320 }
9326 /// leave it unparseable. The peel stops as soon as real structural quotes appear, so the page's own
9327 /// escaped JSON is preserved intact and valid.
9328 /// </summary>
9329 /// <summary>
9330 /// The current document title, its __NEXT_DATA__ payload and any React Flight chunks, as one json
9331 /// string.<br/>
9332 /// No markup crosses the transport, so no escaping has to be undone. OttoMagic uses its own endpoint
9333 /// rather than a script, which would require the profile to allow user scripts.
9334 /// </summary>
9335 /// <returns>Json holding title, nextData and flightChunks, or empty when the page could not answer</returns>
9336 private string GetHydrationScripts()
9337 {
9338 string reported;
9339
9340 if (true == UseOttoMagic)
9341 reported = MagicHelper.GetHydrationScripts();
9342 else if (true == UsePuppeteer)
9343 // Runtime.evaluate takes an expression, so the function is self-invoking
9344 reported = BrowserHelper.ExecuteJavaScriptObj(NextJsHydrationExtractor.HydrationScript, this)?.ToString();
9345 else
9346 // ExecuteScript takes a statement list and needs a return
9347 reported = BrowserHelper.ExecuteJavaScriptObj("return " + NextJsHydrationExtractor.HydrationScript + ";", this)?.ToString();
9348
9349 return reported ?? string.Empty;
9350 }
9356 {
9357 NextJsHydrationExtractor.SaveToFile(CachedHydration(), hydrationFile.Filename);
9358
9359 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Saved hydrated data [{hydrationFile.Filename}]", this, GPALObjectType.Browser);
9360
9361 return this;
9362 }
9372 public IAllowBrowserActionOrAnySelector GetLLMDigest(out LLMDigestResult digestResult, string ruleSetName = null)
9373 {
9374 digestResult = CachedDigest(ruleSetName);
9375 return this;
9376 }
9377
9380 /// GetLLMDigest followed by SaveLLMDigest does not fetch and convert the page twice.
9381 /// </summary>
9382 private LLMDigestResult CachedDigest(string ruleSetName)
9383 {
9384 string key = GetSetCurrentUrl() + "|" + (ruleSetName ?? string.Empty);
9385
9386 if (true == _digestCache.TryGetValue(key, out LLMDigestResult cached))
9387 {
9388 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"LLM Digest cache hit for [{UrlHelper.GetOrigin(CurrentUrl)}]", this, GPALObjectType.Browser);
9389 return cached;
9390 }
9391
9392 GetPageSource(out string pageSource);
9393 LLMDigestResult digestResult = HtmlToOptimizedMarkdown.CreateDigest(this, pageSource, ruleSetName);
9394 _digestCache[key] = digestResult;
9395
9396 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"LLM Digest created for [{UrlHelper.GetOrigin(CurrentUrl)}]", this, GPALObjectType.Browser);
9397
9398 return digestResult;
9399 }
9409 public IAllowBrowserActionOrAnySelector SaveLLMDigest(GPALFile llmDigestFile, string ruleSetName = null)
9410 {
9411 HtmlToOptimizedMarkdown.SaveToFile(CachedDigest(ruleSetName), llmDigestFile.Filename);
9412
9413 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Saved LLM Digest [{llmDigestFile.Filename}]", this, GPALObjectType.Browser);
9414
9415 return this;
9416 }
9422 public IAllowBrowserActionOrAnySelector GetPageSource(out string pageSource)
9423 {
9424 pageSource = string.Empty;
9425
9426 if (true == UseOttoMagic)
9427 {
9428 pageSource = MagicHelper.GetPageSource();
9429 }
9430 else if (true == UsePuppeteer)
9431 {
9432 pageSource = PuppeteerClient.GetPageSource().Execute();
9433 }
9434 else
9435 {
9436 try
9437 {
9438 pageSource = BrowserDriver.PageSource;
9439 }
9440 catch (GPALException)
9441 {
9442 throw;
9443 }
9444 catch (Exception ex)
9445 {
9446 GPAL.PublishSimpleEvent(
9447 GPALEventType.ERROR,
9448 "Failed to read page source in Selenium [" + ex.Message + "]",
9449 this,
9450 GPALObjectType.Browser);
9451 }
9452 }
9453
9454 pageSource = NormalizePageSource(pageSource);
9455
9456 Uri currUrl = new Uri(CurrentUrl, UriKind.Absolute);
9457
9458 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Retrieved page source for [{UrlHelper.NormalizePath(currUrl.AbsolutePath)}]", this, GPALObjectType.Browser);
9459
9460 return this;
9461 }
9470 private static string NormalizePageSource(string raw)
9471 {
9472 if (string.IsNullOrWhiteSpace(raw))
9473 return string.Empty;
9474
9475 // JS/JSON-unescape first so the XML-viewer marker (real quotes) is detectable
9476 // regardless of how the engine transported the source (e.g. OttoMagic returns it escaped).
9477 string working = SafeJsUnescape(raw.Trim().Trim('\"').Trim('\''));
9478
9479 if (working.IndexOf("webkit-xml-viewer-source-xml", StringComparison.Ordinal) != -1)
9480 {
9481 string fileSource = ConverterHelper.ExtractRawXmlFromBrowserSource(working);
9482 if (!string.IsNullOrWhiteSpace(fileSource))
9483 {
9484 // Strip control characters only; do NOT HTML-decode, that would corrupt escaped
9485 // entities (e.g. &amp;) that are a genuine part of the recovered file source.
9486 return Regex.Replace(fileSource.Trim(), @"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "");
9487 }
9488 }
9489
9490 // Ordinary page: return the honest page source with the standard cleanup.
9491 return SafeCleanPageSource(raw);
9492 }
9498 private static string SafeCleanPageSource(string input)
9499 {
9500 if (string.IsNullOrWhiteSpace(input))
9501 return string.Empty;
9502
9503 string working = input.Trim().Trim('\"').Trim('\'');
9504
9505 // Peel the transport's JSON-string escaping FIRST. OttoMagic sends outerHTML as a JSON string
9506 // (leading quote, \" for attribute quotes, \n for newlines); HtmlDecode must run AFTER this,
9507 // because decoding entities injects raw quotes that make the still-escaped payload un-peelable.
9508 working = SafeJsUnescape(working);
9509
9510 working = System.Web.HttpUtility.HtmlDecode(working);
9511
9512 // Remove control characters only
9513 working = Regex.Replace(working, @"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "");
9514
9515 return working.Trim();
9516 }
9517
9523 private static string SafeJsUnescape(string input)
9524 {
9525 if (string.IsNullOrEmpty(input))
9526 return input;
9527
9528 // Step 1: OttoMagic's native messaging hands the source over as one JSON string (leading quote, \"
9529 // for attribute quotes, \n for newlines), so that one layer is decoded with a real parser. Selenium
9530 // and Puppeteer return clean HTML, which is not a JSON string, so the decode leaves it as it is.
9531 string result = TryJsonStringDecode(input) ?? input;
9532
9533 // Step 2: ALWAYS run the lenient character-level unescape on the result, so every engine converges
9534 // on identical output. Without this, OttoMagic (whose peel succeeds) would keep the single-level \"
9535 // left in the page's own embedded strings (e.g. __NEXT_DATA__ JSON), while Selenium/Puppeteer
9536 // (whose peel is a no-op) collapse those \" here - producing different source for the same page.
9537
9538 // Basic escapes - but PROTECT newlines and carriage returns so they stay escaped for JSON
9539 result = result.Replace(@"\""", "\"")
9540 .Replace(@"\'", "'")
9541 .Replace(@"\\", "\\") // keep single backslash
9542 .Replace(@"\/", "/");
9543
9544 // Safe unicode only
9545 result = Regex.Replace(result, @"\\u([0-9a-fA-F]{4})", m =>
9546 {
9547 try
9548 {
9549 int code = int.Parse(m.Groups[1].Value, System.Globalization.NumberStyles.HexNumber);
9550 if (code < 0 || code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF))
9551 return m.Value; // keep original if invalid
9552 return char.ConvertFromUtf32(code);
9553 }
9554 catch
9555 {
9556 return m.Value;
9557 }
9558 });
9559
9560 // Remove unknown escapes (but protect " \ ' \ / \b \f \t)
9561 result = Regex.Replace(result, @"\\‍([^""'\\/bfnrtu])", "$1");
9562
9563 return result;
9565
9569
9573 private static string TryJsonStringDecode(string s)
9574 {
9575 if (string.IsNullOrEmpty(s))
9576 return null;
9577
9578 try
9579 {
9580 return JsonConvert.DeserializeObject("\"" + s + "\"") as string;
9581 }
9582 catch
9583 {
9584 return null;
9585 }
9586 }
9593 public IAllowBrowserActionOrAnySelector GetSiteMap(out string sitemapXML)
9594 {
9595 GetPageSource(out sitemapXML);
9596 return this;
9597 }
9598
9599 Dictionary<string, List<string>> siteMaps = new Dictionary<string, List<string>>();
9600 List<string> suggestedSitemaps = new List<string>();
9601
9602 // Result caches so a Save* reuses what a preceding Get* already produced (and so we do not repeatedly
9603 // walk the same site). The sitemap is site-wide, keyed by origin; the digest/hydration results are
9604 // page-level, keyed by the full URL. Instance-scoped, so each browser/engine still does the work once;
9605 // call ClearResultCache() to force a refresh.
9606 private readonly Dictionary<string, List<string>> _sitemapCache = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
9607 private readonly Dictionary<string, LLMDigestResult> _digestCache = new Dictionary<string, LLMDigestResult>(StringComparer.OrdinalIgnoreCase);
9608 private readonly Dictionary<string, NextJsHydrationResult> _hydrationCache = new Dictionary<string, NextJsHydrationResult>(StringComparer.OrdinalIgnoreCase);
9609
9616 public string SiteMapSource { get; private set; } = string.Empty;
9617
9624 public IReadOnlyList<string> ColumnNames { get; private set; } = new List<string>();
9625
9626 // why a walk came back with no urls, kept beside the cache and keyed the same way, so SaveSiteMapUrls
9627 // writes the reason into the file rather than an empty one
9628 private readonly Dictionary<string, SitemapNote> _sitemapNotes = new Dictionary<string, SitemapNote>(StringComparer.OrdinalIgnoreCase);
9629
9630 // what the walk that just ran has to say for itself, null when it found urls
9631 private SitemapNote _lastWalkNote = null;
9632
9633 // the result node and the sentence that goes with it, as they are written to the file
9634 private class SitemapNote
9635 {
9636 internal string Result { get; set; }
9637 internal string Message { get; set; }
9638 }
9639
9642
9645 public IAllowBrowserActionOrAnySelector ClearResultCache()
9646 {
9647 _sitemapCache.Clear();
9648 _sitemapNotes.Clear();
9649 _digestCache.Clear();
9650 _hydrationCache.Clear();
9651 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Cleared sitemap/digest/hydration result cache.", this, GPALObjectType.Browser);
9652 return this;
9653 }
9654
9666 public IAllowBrowserActionOrAnySelector GetSiteMapUrls(out List<string> sitemapUrls, string sitemapUrl = null)
9667 {
9668 sitemapUrls = CachedSitemapUrls(sitemapUrl);
9669 return this;
9670 }
9671
9676 private List<string> CachedSitemapUrls(string sitemapUrl = null)
9677 {
9678 // one site serves many sitemaps, so the answer is cached against the sitemap that was read. Keying
9679 // it on the origin alone handed the first walk back for every later sitemap on the same site
9680 string key = false == string.IsNullOrWhiteSpace(sitemapUrl) ? sitemapUrl : UrlHelper.GetOrigin(CurrentUrl);
9681
9682 if (false == string.IsNullOrEmpty(key) && true == _sitemapCache.TryGetValue(key, out List<string> cached))
9683 {
9684 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Sitemap cache hit for [{key}] ([{cached.Count}] URLs).", this, GPALObjectType.Browser);
9685
9686 // a cached walk that found nothing still has its reason, so a save after a get says the same thing
9687 _sitemapNotes.TryGetValue(key, out _lastWalkNote);
9688
9689 // nothing was read, so there is no text to answer with
9690 SiteMapSource = string.Empty;
9691
9692 return cached;
9693 }
9694
9695 List<string> walked = WalkSitemapUrls(sitemapUrl);
9696
9697 if (false == string.IsNullOrEmpty(key))
9698 {
9699 _sitemapCache[key] = walked;
9700
9701 if (null == _lastWalkNote)
9702 _sitemapNotes.Remove(key);
9703 else
9704 _sitemapNotes[key] = _lastWalkNote;
9705 }
9706
9707 return walked;
9708 }
9709
9721 private string ReadSitemap(string url)
9722 {
9723 string retVal = string.Empty;
9724
9725 // the browser may already be standing on this sitemap, from the pass above or from the workflow.
9726 // then the page it is on is the answer: fetching it again fails anyway, because an xml document
9727 // has no page for the extension to issue a fetch from, and navigating to it again pays a load for
9728 // something already read
9729 bool alreadyThere = true == UrlHelper.AreEquivalent(CurrentUrl, url);
9730
9731 // a page may only fetch its own origin, so a sitemap on another one is not asked for at all: the
9732 // request never leaves the browser and the walk pays a refusal for every sitemap before navigating
9733 // anyway. harrods is the example: the page is on www.harrods.com and robots.txt names
9734 // harrods.com, which is a different origin, for all 422 of its sitemaps.
9735 // the browser's rule, not GPAL's: www.harrods.com and harrods.com are two origins, where
9736 // UrlHelper.AreEquivalent would call them one
9737 // and under the extension a fetch needs a page to be issued from. after reading robots.txt or a
9738 // sitemap the browser is standing on a document it rendered, where no content script runs, and
9739 // that is what "Failed to fetch" on a same-origin url means there. Selenium and puppeteer evaluate
9740 // in the document itself, so they are not limited this way and are left to try
9741 string onNow = (CurrentUrl ?? string.Empty).Split('?', '#')[0].TrimEnd('/').ToLower();
9742 bool onADocument = true == UseOttoMagic
9743 && (true == onNow.EndsWith(".xml") || true == onNow.EndsWith(".txt") || true == onNow.EndsWith(".gz"));
9744
9745 bool sameOrigin = false == alreadyThere
9746 && false == onADocument
9747 && true == string.Equals(UrlHelper.GetOrigin(CurrentUrl), UrlHelper.GetOrigin(url), StringComparison.OrdinalIgnoreCase);
9748
9749 byte[] fetched = true == sameOrigin
9750 ? BrowserHelper.FetchBytes(this, url, null, out string _)
9751 : null;
9752
9753 // the fetch records its status on the browser the way a navigation does. a refusal still carries a
9754 // body, and that body is a challenge page or an error page, never the sitemap it was asked for
9755 bool answered = true == sameOrigin && 200 <= ServerResponseCode && 300 > ServerResponseCode;
9756
9757 if (true == answered && null != fetched && 0 < fetched.Length)
9758 retVal = System.Text.Encoding.UTF8.GetString(fetched);
9759
9760 // the markers first, so a sitemap of a hundred thousand urls is not parsed here and again in the
9761 // extractor. the heuristic is for the html sitemaps, which carry neither marker
9762 bool looksLikeASitemap = false == string.IsNullOrWhiteSpace(retVal)
9763 && (true == retVal.Contains("<urlset")
9764 || true == retVal.Contains("<sitemapindex")
9765 || true == SitemapExtractor.IsValidSitemapContent(retVal));
9766
9767 // what came back has to look like a sitemap to be taken for one. a refusal, a challenge page, or an
9768 // html sitemap whose links the page writes for itself all go to the browser, which is the proven way
9769 if (true == alreadyThere)
9770 {
9771 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"The browser is already on [{url}], so its page is the sitemap", this, GPALObjectType.Browser);
9772
9773 GetPageSource(out retVal);
9774 }
9775 else if (false == looksLikeASitemap)
9776 {
9777 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, true == sameOrigin
9778 ? $"The page did not hand over [{url}], HTTP [{ServerResponseCode}], so the browser goes there to read it"
9779 : true == onADocument
9780 ? $"The browser is on [{CurrentUrl}], a document rather than a page, which the extension cannot fetch from, so it goes to [{url}] instead"
9781 : $"[{url}] is not on the origin the page is on, which a page may not fetch, so the browser goes there to read it",
9782 this, GPALObjectType.Browser);
9783
9784 GoTo(url);
9785 GetPageSource(out retVal);
9786 }
9787
9788 return retVal;
9789 }
9790
9791 private List<string> WalkSitemapUrls(string sitemapUrl = null)
9792 {
9793 List<string> sitemapUrls = new List<string>();
9794
9795 // where the browser actually is, which is where it is put back at the end. A named sitemap is read
9796 // rather than visited, so it is never somewhere to return to: going there aborts on a gzip
9797 string startedOn = CurrentUrl;
9798
9799 // the sitemap the caller named, or the page the browser is on when it named none
9800 string currentUrl = false == string.IsNullOrWhiteSpace(sitemapUrl) ? sitemapUrl : startedOn;
9801 bool isFromRobotsTxt = false;
9802 SitemapExtractor sme = new SitemapExtractor(this);
9803 List<string> potentialSitemapUrls = sme.GetSitemapUrl(currentUrl, out isFromRobotsTxt);
9804 bool urlIsSitemap = false;
9805 IGPALGrid<string> retGrid = new GPALGrid<string>();
9806 int idx = 0;
9807 string pageSource = string.Empty;
9808
9809 _lastWalkNote = null;
9810
9811 do
9812 {
9813 string potentialSitemapUrl = string.Empty;
9814 if (idx < potentialSitemapUrls.Count)
9815 potentialSitemapUrl = potentialSitemapUrls[idx++];
9816
9817 // before we walk to any potential sitemaps, is the current page the user directed us to a sitemap?
9818 // get the source and check if it looks like it, if so, follow it
9819 // if not, try the sitemaps we came up with, which might be from robots.txt
9820 if (false == isFromRobotsTxt)
9821 {
9822 // a .gz current page is a binary download, not a page - fetch + inflate it instead of reading the tab
9823 if (true == SitemapExtractor.IsGzipSitemapUrl(currentUrl))
9824 pageSource = SitemapExtractor.FetchAndInflateGzipSitemap(currentUrl, this);
9825 else
9826 pageSource = ReadSitemap(potentialSitemapUrl);
9827 }
9828
9829 // IF NOT sitemap in robots.txt, and if the url comtains the literal word "sitemap" treat the current url as a sitemap
9830 // else run a heuristic upon it to see if it looks like one, it's not great...
9831 // NOTE: CAVEAT: if there is a sitemap in robots.txt, the page we are on is not examined to be a sitemap
9832 if (true == isFromRobotsTxt ||
9833 (false == currentUrl.ToLower().Contains("sitemap") && // does url contain 'sitemap' it just might be
9834 false == currentUrl.ToLower().EndsWith(".xml") && // does the url end in .xml, it better be a sitemap
9835 false == SitemapExtractor.IsValidSitemapContent(pageSource) && // no sitemap name, no .xml, last check does it look like a sitemap? 10 links and 2 two end in xml? // NOTE: not sure of the heuristic
9836 false == suggestedSitemaps.Contains(potentialSitemapUrl) // we've already suggestted this and decided against it...
9837 ))
9838 {
9839 // gzipped sitemaps (often the big ones) can't be navigated to - the browser downloads the
9840 // binary and the WebDriver session hangs. Download + inflate directly instead.
9841 if (true == SitemapExtractor.IsGzipSitemapUrl(potentialSitemapUrl))
9842 pageSource = SitemapExtractor.FetchAndInflateGzipSitemap(potentialSitemapUrl, this);
9843 else
9844 pageSource = ReadSitemap(potentialSitemapUrl);
9845 }
9846 if (false == suggestedSitemaps.Contains(potentialSitemapUrl))
9847 suggestedSitemaps.Add(potentialSitemapUrl);
9848
9849 // a bot-check interstitial (e.g. PerimeterX "Robot or human?") is not a sitemap - recognize it,
9850 // warn, and stop walking this origin rather than parsing the challenge page as xml.
9851 if (true == SitemapExtractor.IsBotCheckPage(pageSource))
9852 {
9853 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Bot-check interstitial detected at [{currentUrl}], skipping sitemap walk.", this, GPALObjectType.Browser);
9854
9855 _lastWalkNote = new SitemapNote
9856 {
9857 Result = "bot-check-page",
9858 Message = $"A bot-check interstitial answered for [{currentUrl}] instead of a sitemap, so the walk stopped there and no urls were read. Run this one on a signed-in profile, or read the sitemap the site publishes by name."
9859 };
9860
9861 break;
9862 }
9863
9864 sitemapUrls = sme.ExtractSitemapUrls(pageSource, Int32.MaxValue, out urlIsSitemap); // NOTE: CAVEAT: hardcoded value - we want them all - we need a setting for this? with all that match?
9865 retGrid.AddRow(sitemapUrls);
9866 string currentUrl2 = string.Empty;
9867
9868 if (true == UseOttoMagic)
9869 currentUrl2 = MagicHelper.GetCurrentUrl();
9870 else if (true == BrowserSettings.UsePuppeteer)
9871 currentUrl2 = PuppeteerClient.GetCurrentUrl().Execute();
9872 else
9873 currentUrl2 = BrowserDriver.Url;
9874
9875 currentUrl2 = currentUrl2.Trim('\"');
9876
9877 // how to break out of the loop?
9878 // 1 - we have no sitemap urls
9879 // 2 - we have a list and have walked all of them and accumulated all of the sitemap information walking all sitemaps
9880 if (0 == potentialSitemapUrls.Count || potentialSitemapUrls.Count == idx)
9881 {
9882 if (false == UrlHelper.AreEquivalent(startedOn, currentUrl2))
9883 GoTo(startedOn);
9884 break;
9885 }
9886
9887 } while (true);
9888
9889 sitemapUrls = retGrid.ToList();
9890
9891 // what the urls were read out of, handed back so the caller can say what kind of sitemap this was
9892 // without going to the page for it
9893 SiteMapSource = pageSource ?? string.Empty;
9894
9895 // a site with no sitemap answers a walk the same way a walk that found nothing does: an empty list
9896 // and, a line later, an empty file. said here so the reason is on the log next to the url it was
9897 // asked about, and kept so the file can say it too. the bot-check above has already said its piece
9898 if (0 == sitemapUrls.Count && null == _lastWalkNote)
9899 {
9900 string tried = 0 == potentialSitemapUrls.Count
9901 ? "nothing: robots.txt named no sitemap and the page is not one"
9902 : string.Join(", ", potentialSitemapUrls);
9903
9904 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No sitemap URLs found for [{currentUrl}], tried [{tried}]", this, GPALObjectType.Browser);
9905
9906 _lastWalkNote = new SitemapNote
9907 {
9908 Result = "no-sitemap-found",
9909 Message = $"No sitemap was found for [{currentUrl}], so no urls were walked. Tried [{tried}]."
9910 };
9911 }
9912 else if (0 < sitemapUrls.Count)
9913 _lastWalkNote = null;
9914
9915 return sitemapUrls;
9916 }
9924 public IAllowBrowserActionOrAnySelector SaveSiteMapUrls(GPALFile sitemapUrlsFile)
9925 {
9926 List<string> sitemapUrls = CachedSitemapUrls();
9927
9928 // one URL per row, single "url" column, so the converter can emit any format from the file extension
9929 IGPALGrid<string> grid = new GPALGrid<string>();
9930
9931 if (0 == sitemapUrls.Count)
9932 {
9933 // nothing to write reads as a broken run when the file is empty, so the file says what happened
9934 // instead. a reader looking for url nodes finds none and finds result, in whichever format the
9935 // extension chose
9936 SitemapNote note = _lastWalkNote ?? new SitemapNote
9937 {
9938 Result = "no-sitemap-found",
9939 Message = $"No sitemap was found for [{UrlHelper.GetOrigin(CurrentUrl)}], so there are no urls to write."
9940 };
9941
9942 grid.AddRow(new List<string> { note.Result, note.Message });
9943
9944 GPAL.Converter
9945 .WithInput(grid)
9946 .WithColumnName("result")
9947 .WithColumnName("message")
9948 .SaveTo(sitemapUrlsFile);
9949
9950 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No sitemap URLs to save, so [{sitemapUrlsFile.Filename}] says [{note.Result}] instead", this, GPALObjectType.Browser);
9952 else
9953 {
9954 foreach (string url in sitemapUrls)
9955 grid.AddRow(new List<string> { url });
9956
9958 .WithInput(grid)
9959 .WithColumnName("url")
9960 .SaveTo(sitemapUrlsFile);
9961
9962 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Saved [{sitemapUrls.Count}] sitemap URLs to [{sitemapUrlsFile.Filename}]", this, GPALObjectType.Browser);
9963 }
9964
9965 return this;
9966 }
9967 #endregion Sitemap Operations
9968 #region Cast Operations
9972 private string SinkName { get; set; }
9973
9980 public IAllowBrowserActionOrAnySelector CastTab(string sinkName)
9981 {
9982 SinkName = sinkName;
9983 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Casting Tab to [{sinkName}]");
9984
9985 if (true == UsePuppeteer)
9986 PuppeteerClient.CastTab(sinkName).Execute();
9987 else if (true == UseSelenium)
9988 CastTo_Selenium(sinkName, CastMode.Tab);
9989 else if (true == UseOttoMagic)
9990 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Casting is not available via OttoMagic");
9992 return this;
9993 }
9994
10001 public IAllowBrowserActionOrAnySelector CastDesktop(string sinkName)
10002 {
10003 SinkName = sinkName;
10004 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Casting Desktop to [{sinkName}]");
10005
10006 if (true == UsePuppeteer)
10007 PuppeteerClient.CastDesktop(sinkName).Execute();
10008 else if (true == UseSelenium)
10009 CastTo_Selenium(sinkName, CastMode.Desktop);
10010 else if (true == UseOttoMagic)
10011 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Casting is not available via OttoMagic");
10012
10013 return this;
10014 }
10015
10020 public IAllowBrowserActionOrAnySelector StopCasting()
10021 {
10022 if (false == string.IsNullOrEmpty(SinkName))
10023 {
10024 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Stopping cast to [{SinkName}]");
10025
10026 if (true == UsePuppeteer)
10027 {
10028 if (null != PuppeteerCommunicator.SinkName)
10029 {
10030 PuppeteerClient.StopCasting().Execute();
10031 PuppeteerCommunicator.SinkName = null;
10032 }
10033 }
10034 else if (true == UseSelenium)
10035 {
10036 var driver = (OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver;
10037
10038 var param = new Dictionary<string, object> { ["sinkName"] = SinkName };
10039
10040 if (null != SinkName)
10041 try
10042 {
10043 // 1. Try official stop command
10044 driver.ExecuteCdpCommand("Cast.stopCasting", param);
10045 SinkName = null;
10046 }
10047 catch (Exception ex)
10048 {
10049 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Cast.stopCasting failed, which it does sometimes", this, GPALObjectType.Browser, ex);
10050 }
10051 }
10052 }
10053
10054 return this;
10055 }
10056
10063 private IAllowBrowserActionOrAnySelector CastTo_Selenium(string sinkName, CastMode mode)
10064 {
10065 int retries = 10;
10066
10067 if (string.IsNullOrEmpty(sinkName))
10068 return this;
10069
10070 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Cast.enable",
10071 new Dictionary<string, object>());
10072
10073 Thread.Sleep(3_000);
10074 var param = new Dictionary<string, object> { ["sinkName"] = sinkName };
10075
10076 string command = (mode == CastMode.Tab)
10077 ? "Cast.startTabMirroring"
10078 : "Cast.startDesktopMirroring";
10079
10080 while (0 < retries)
10081 {
10082 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Searching for [{sinkName}].");
10083 try
10084 {
10085 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand(command, param);
10086
10087 GPAL.PublishSimpleEvent(GPALEventType.INFO,
10088 $"[{sinkName}] found.", this, GPALObjectType.Browser);
10089 break;
10090 }
10091 catch
10092 {
10093 retries--;
10094 }
10095 Thread.Sleep(3_000);
10096 }
10097 return this;
10098 }
10099 #endregion Cast Operations
10100 }
10101}
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 string GetCurrentUrl(BrowserSettings browserSettings)
Returns the current page URL, queried via OttoMagic, Puppeteer, or the Selenium WebDriver depending o...
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 void SetHeadlessDownload(BrowserSettings browserSettings, string destination)
Instructs the underlying Chromium driver (via CDP Page.setDownloadBehavior) to send the next download...
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
IAllowAfterAnySelector WithSelector(string literalData)
Add literal data to a grid when using .GetGrid.
Definition Browser.cs:1165
IAllowStorageOptions WithStorageDomain(string domain)
Specifies the domain to scope the next storage Run* action to. Starts a new StorageAction if the prev...
Definition Browser.cs:1573
IAllowBrowserSettingsOrGoTo WithHiddenDesktop(bool hiddenDesktop)
Run the browser on a Win32 desktop object of its own: a real window, real rendering,...
Definition Browser.cs:362
IAllowBrowserActionOrAnySelector NewTab(GPALUrl url)
Used to specify the next GoTo command should open in a new tab. The new tab is then the active tab....
Definition Browser.cs:3604
IAllowWorkflowExecution WhileLoopMaxIterations(int maxIterations)
Sets the maximum number of iterations a While/Until loop is allowed to run before it is aborted.
Definition Browser.cs:9034
IBrowser ToGPALObject()
Return a GPAL.Browser object so that GPAL.Browser does not have to be cast.
Definition Browser.cs:185
IAllowAfterAnySelector InMainDom()
Specify that GPAL is no longer working in an iFram or shadow dom and reset back to the main document.
Definition Browser.cs:2046
PuppeteerCommunicator PuppeteerCommunicator
Communicator used to send/receive messages to the Puppeteer engine for this Browser.
Definition Browser.cs:7511
IAllowBrowserActionOrAnySelector ScrollElementVertical(int scrollAmountInPixels)
Scroll the element vertically by number of pixels. Negative numbers scroll up. JavaScript only.
Definition Browser.cs:6774
IAllowBrowserSettingsOrGoTo WithLoadImages(bool loadImages)
Determine whether to show images on a webpage. .
Definition Browser.cs:527
BrowserSettings BrowserSettings
The settings backing this Browser, including configuration, state, and engine handles.
Definition Browser.cs:7486
IAllowBrowserSettingsOrGoTo WithDownloadLocation(string directory)
Specifies the download location for this browser sessions, overrides the default.
Definition Browser.cs:317
IAllowBrowserActionOrAnySelector DragAndDrop(ModifierKeys modifierKeys)
Left click the element found for Selector(s) defined in the UOW One left click for the first element...
Definition Browser.cs:6845
AutomationEngine AutomationEngine
The automation engine (OttoMagic, Puppeteer, or Selenium) used by this Browser.
Definition Browser.cs:7401
IAllowBrowserActionOrAnySelector FillInFrom(IGPALGrid< string > inputGrid)
FillIn data from the grid overwriting text in the input Selector(s) defined in the UOW One token (on...
Definition Browser.cs:4860
IAllowBrowserActionOrAnySelector Focus(Selector selector)
Focus the current element found via the Selector. Only the first element per Selector found will be f...
Definition Browser.cs:4925
IAllowBrowserSettingsOrGoTo WithWaitOnDocumentReady(int timeoutInMs)
Specifies GPAL will wait for a true in the browser DocumentReady flag before trying to locate element...
Definition Browser.cs:840
IAllowBrowserActionOrAnySelector Get(GPALUrl URL)
Definition Browser.cs:2783
string BrowserVersion
The version of the browser being automated.
Definition Browser.cs:9111
IAllowCallTemplate WithCallFilter(string urlFragment)
Save the retrieved grid. The output format and delimiter are determined by the GPALFile (its extensio...
Definition Browser.cs:5692
bool AreRobotsAllowed
True if the current page's robots.txt/meta tags allow automation, as determined by WithRespectRobotMe...
Definition Browser.cs:235
IAllowAllBrowserAndAllSelector Minimize
Minimize the browser to the taskbar.
Definition Browser.cs:2723
IAllowBrowserSettingsOrGoTo WithOverwriteExistingFile(bool overwriteFile)
Overwrite the destination file locally when downloading. If set to false, filenames will have a date...
Definition Browser.cs:508
IAllowBrowserActionOrAnySelector OpenWindow(GPALUrl URL)
Opens a new browser tab/window navigated to URL and switches focus to it.
Definition Browser.cs:8536
IAllowAfterWaitForAndSizeControl WaitForWindowRegex(string waitForTitleRegex)
Wait for a Windows window with title that matches the regex pattern to appear on the desktop....
Definition Browser.cs:6261
IAllowBrowserActionOrAnySelector PrintToPDF(GPALFile gpalFile)
Prints each selector on the unit of work to a PDF of its own, and the whole page when the unit of wor...
Definition Browser.cs:6288
IAllowBrowserActionOrAnySelector InsertFrom(IGPALGrid< string > inputGrid)
Insert the data from the grid at the beginning of the text in the input Selector(s) defined in the UO...
Definition Browser.cs:4874
IAllowAfterAnySelector InElement(Selector selector)
InElement indicates the root element for following withselector element searches which would use a re...
Definition Browser.cs:1701
void Close(bool killWebDrivers=false)
Close/terminate the browser. This terminates the fluent browser workflow and nothing can be chained ...
Definition Browser.cs:8228
IAllowBrowserActionOrAnySelector PageEnd
Scroll the current tab window to the bottom of the page. Scrolling ends the unit of work,...
Definition Browser.cs:2556
IAllowBrowserActionOrAnySelector RightClickAndDownload(GPALFile filenames)
Right click the element(s) found for Selector(s) defined in the UOW, select 'Save as....
Definition Browser.cs:4502
IAllowBrowserActionOrAnySelector PressModifierKey(ModifierKeys modifierKeys)
Press the specified modifer keys (can be ORd) NOTE: If you SendString a mixed case string,...
Definition Browser.cs:6548
IAllowWithHeaderOrFileActions WithHeader(string header)
Specify a column header for output. Headers are output in order defined.
Definition Browser.cs:2351
IAllowBrowserActionOrAnySelector CaptureCalls(out List< GPALCall > calls)
Records what the page asks for and hands back everything recorded so far, so an endpoint can be read ...
Definition Browser.cs:5855
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
IAllowBrowserActionOrAnySelector GoToWindow(dynamic urlOrId)
Switches focus to the browser tab/window matching the given URL or window/tab id.
Definition Browser.cs:8711
IAllowBrowserActionOrAnySelector ScrollElementHorizontal(int scrollAmountInPixels)
Scroll the element horizontally by number of pixels. Negative numbers scroll left....
Definition Browser.cs:6756
delegate CallIfStatus CallAfterFillInDelegate(IBrowser browser, IGPALGrid< string > tokens, int tokenIdx)
Delegate callback for the CallAfterFillIn EventHandler which will be invoked after each row of tokens...
IAllowBrowserActionOrAnySelector Forward
Navigate forward one page.
Definition Browser.cs:2486
IAllowBrowserSettingsOrGoTo WithObeyRobotsTxt(bool trueFalse=false)
Set whether to deny navigating to webpage if denied by robots.txt.
Definition Browser.cs:2761
IAllowBrowserSettingsOrGoTo WithPromptForDownload(bool promptForDownload)
Set to true to always display the SAVE AS dialog box when downloading a file. Very useful with LeftC...
Definition Browser.cs:570
IAllowNetworkIdleSettings WithNetworkIdlePruneMs(int networkIdlePruneMs=3_000)
Specifies the maximum duration to keep network stats before pruning old data The time to wait for new...
Definition Browser.cs:966
IAllowBrowserSettingsOrGoTo WithBrowserType(BrowserType browserType)
Specify which browser to run the workflow in. NOTE: Chrome and Edge act mostly the same,...
Definition Browser.cs:488
IAllowBrowserActionOrAnySelector GetStorageData(out string data)
Retrieves the data read by the most recent .RunGet storage action.
Definition Browser.cs:1686
delegate CallIfStatus CallOnFailDelegate(IBrowser browser, GPALFailure failure, string detail)
Delegate for the CallOnFail handler, invoked when something fails that the workflow,...
IAllowBrowserSettingsOrGoTo WithBlockPopUps(bool blockPopUp)
Specifies whether to block popups in the browser.
Definition Browser.cs:336
IAllowAfterAnySelector WithSelector(Func< string > dataFunction)
Add literal data to a grid by invoking this function when using .GetGrid.
Definition Browser.cs:1197
IAllowBrowserSettingsOrGoTo WithUseDirectDownload(bool trueFalse)
For use with .RightClickAndDownload, use websockets to directly download what the webelement referenc...
Definition Browser.cs:297
IAllowBrowserSettingsOrGoTo WithNavigationGrace(int graceInMs)
How long a click waits to see whether it started a navigation, before deciding it stayed on the page....
Definition Browser.cs:864
IAllowAfterWaitForAndSizeControl WithWaitForWindowTimeout(int waitTimeInSeconds)
Define the time in seconds to WaitForWindow/Regex.
Definition Browser.cs:2200
IAllowBrowserActionOrAnySelector ContinueAsRESTClient(out IRESTClient client)
Carries on without the browser: hands back a RESTClient holding what this browser has,...
Definition Browser.cs:1413
IAllowBrowserActionOrAnySelector SendKey(byte VKCode)
Type special characters like Enter, Page Up, Page Down, Tab, etc. Use GPAL.VK constants for ease of ...
Definition Browser.cs:6690
IAllowBrowserActionOrAnySelector InsertFrom(GPALDatabase inputDatabase)
Insert the text from the database at the beginning of the input Selector(s) defined in the UOW One t...
Definition Browser.cs:4768
IAllowBrowserActionOrAnySelector ExecuteJavaScriptStr(string javascript)
Executes JavaScript in the browser and stores the result as a string in JavaScriptResultStr.
Definition Browser.cs:6890
IAllowBrowserActionOrAnySelector Back
Navigate back one page.
Definition Browser.cs:2465
IAllowStorageOptions WithStorageStoreName(string storeName)
Specifies the IndexedDB store name to scope the next storage Run* action to. Starts a new StorageActi...
Definition Browser.cs:1630
IAllowBrowserActionOrAnySelector MoveTo(Selector selector=null)
Moves the mouse to the current element found via the Selector. Only the first element per Selector fo...
Definition Browser.cs:5019
IAllowAfterAnySelector CallIfNotFound(Browser.CallIfDelegate callIfNotFound)
Add a Unit of Work level CallIfNotFound handler to be called if any defined selectors DO NOT find/mat...
Definition Browser.cs:2168
IHiddenDesktop HiddenDesktop
The hidden desktop this browser is running on, or null when it is on the desktop you are looking at....
Definition Browser.cs:7574
IAllowBrowserSettingsOrGoTo WithOpenPDFExternally(bool openPDFExternally)
Specifies whether the browser will render PDFs or launch an external program. .
Definition Browser.cs:580
IAllowNetworkIdleSettings WithNetworkIdleTimeoutMs(int networkIdleTimeoutMs=500)
Specifies max time in milliseconds to wait for the network idle before returning false.
Definition Browser.cs:916
IAllowStorageOptions WithStorageKey(string key)
Specifies the key to scope the next storage Run* action to. Starts a new StorageAction if the previou...
Definition Browser.cs:1611
IAllowWithPagesAndGridActions WithNextPageButton(Selector nextPageButtonselector)
Specifies the Next Page button used to retrieve multiple pages of data. Use with ....
Definition Browser.cs:2072
IRESTClient OttoMagicClient
The client for the OttoMagic REST API this browser is running. Only the OttoMagic engines start GPALR...
Definition Browser.cs:7557
IAllowBrowserActionOrAnySelector CloseWindow(dynamic urlOrId=null)
Closes the current browser tab/window, or the one matching the given URL or window/tab id,...
Definition Browser.cs:8808
IAllowBrowserSettingsOrGoTo WithProfileName(string profileName)
Specify the profile name to use under the data directory for this browser automation session NOTE: us...
Definition Browser.cs:1042
IAllowBrowserActionOrAnySelector SaveSiteMapUrls(GPALFile sitemapUrlsFile)
Discovers and walks the site's sitemap(s), then saves the extracted URLs to a file....
Definition Browser.cs:9895
IAllowBrowserActionOrAnySelector ClearResultCache()
Clears the cached sitemap / LLM digest / hydration results for this browser, forcing the next Get*‍/S...
Definition Browser.cs:9616
string RestApiBaseUrl
Where this browser's GPALRestAPI is listening, e.g. http://localhost:3117/, taken from the port it an...
Definition Browser.cs:7476
IAllowAfterAnySelector InShadowDom(Selector shadowDomSelector)
Specify that the following .WithSelectors are contained in an a shadow dom specified by the Selector....
Definition Browser.cs:1920
IAllowBrowserSettingsOrGoTo WithAutomationEngine(AutomationEngine automationEngine)
Selects which automation engine drives the browser: Selenium, OttoMagic, or one of the Puppeteer conn...
Definition Browser.cs:792
IAllowBrowserActionOrAnySelector RightClick(Selector selector=null)
Right click the element found for Selector(s) defined in the UOW One right click for the first eleme...
Definition Browser.cs:3901
IAllowBrowserActionOrAnySelector PageTop
Scroll the current tab window to the top of the page. Scrolling ends the unit of work,...
Definition Browser.cs:2578
IAllowBrowserActionOrAnySelector SetValueFrom(Selector selector)
Sets the value of the element(s) matched by the preceding WithSelector call from the value of the ele...
Definition Browser.cs:4989
IAllowBrowserActionOrAnySelector NextTab
Navigate to the next tab. If at the last tab nothing will happen. NOTE: New window is just another ta...
Definition Browser.cs:3532
IAllowWithPagesAndFetch WithTokensFrom(string tokens)
Definition Browser.cs:2121
IAllowAllBrowserAndAllSelector FullScreen
Make the browser window fullscreen, saving the current window size so it can be restored later via Re...
Definition Browser.cs:2654
string CurrentUrl
The URL of the page currently loaded in the browser.
Definition Browser.cs:7365
IAllowBrowserActionOrAnySelector GetHydratedData(out NextJsHydrationResult hydrationResult)
Extracts Next.js hydration data (e.g. NEXT_DATA) embedded in the current page's HTML.
Definition Browser.cs:9264
IAllowBrowserActionOrAnySelector WithDialogsAccepted(bool accept)
Answers the alert, confirm and prompt dialogs the page raises instead of letting them open and stop e...
Definition Browser.cs:6988
List< string > RobotsTxt
The lines of the current site's robots.txt, as parsed by WithRespectRobotMetaTags/WithObeyRobotsTxt.
Definition Browser.cs:9087
IAllowBrowserActionOrAnySelector ScrollWindowVertical(int scrollAmountInPixels)
Scroll the browser window horizontally the number of pixels. Negative numbers scroll up....
Definition Browser.cs:6732
IAllowAfterWaitFor WaitFor(WaitTime waitForTimeInMs)
Either wait the amount of time specified or wait UP TO the amount of time specified waiting for eleme...
Definition Browser.cs:6184
bool UseSelenium
True if this Browser is using the Selenium automation engine.
Definition Browser.cs:7373
string SiteMapSource
The sitemap text the last GetSiteMapUrls read, so a workflow can tell an index from a urlset from an ...
Definition Browser.cs:9587
IAllowBrowserActionOrAnySelector StopCasting()
Stops any active cast (tab or desktop mirroring) started via CastTab or CastDesktop.
Definition Browser.cs:9991
IAllowStorageOptions WithUserDefinedFilter(string userDefined)
Specifies a user-defined filter string passed through to the storage action implementation....
Definition Browser.cs:1668
IAllowBrowserActionOrAnySelector AppendFrom(string text)
Append the given text to the first input in the UOW. Should probably only be used with one selector a...
Definition Browser.cs:4886
IAllowBrowserActionOrAnySelector LeftClickAndDownload(GPALFile gPalFileToDownloadTo)
Left clicks the element(s) found for the current selector(s) and downloads the resulting file to gPal...
Definition Browser.cs:3960
IAllowBrowserSettingsOrGoTo WithHiddenDesktop(string desktopName)
Run the browser on the named Win32 desktop object. Browsers naming the same desktop share it,...
Definition Browser.cs:388
IReadOnlyList< string > ColumnNames
The column names the last GetGrid read off the page itself, from the header row of a table it was poi...
Definition Browser.cs:9595
delegate CallIfStatus CallAfterFetchDelegate(IBrowser browser, List< string > results, IGPALGrid< string > tokens, int tokenIdx)
Delegate callback for the CallAfterFetch handler declared on a GPALRequest, invoked after each row of...
IAllowBrowserActionOrAnySelector AppendFrom(GPALFile inputFile)
Append the data from the input file to the end of any input Selectors defined in the UOW One token (...
Definition Browser.cs:4798
IAllowBrowserActionOrAnySelector WithExistingCDPDebugPort(int port)
Specifies the port to use to connect to an existing session from another workflow started with ....
Definition Browser.cs:683
IAllowAfterAnySelector WithJavaScript
Specifies to execute javascript on the page to interact with the Selector just defined with ....
Definition Browser.cs:2439
bool IsEndOfPage()
Helper method to check whether the current page is at the bottom.
Definition Browser.cs:8411
IAllowBrowserActionOrAnySelector NextWindow
Switches focus to the next browser tab/window in the window list, wrapping around to the first.
Definition Browser.cs:8471
bool UseOttoMagic
True if this Browser is using the OttoMagic automation engine.
Definition Browser.cs:7387
IAllowAfterAnySelector InFrame(Selector selector)
Specify that the following .WithSelector(s) are contained in an iFrame specified by the Selector....
Definition Browser.cs:1807
IAllowBrowserActionOrAnySelector Run()
Runs the workflows added via WithWorkflow against this browser, in the order they were added....
Definition Browser.cs:8969
IAllowAfterWaitFor WaitFor(ElementState elementState)
Waitfor an element to eneter the specified state. This is an action.
Definition Browser.cs:6230
IAllowBrowserActionOrAnySelector FillInFrom(string text)
Overwrite all text in the first input in the UOW. Should probably only be used with one selector at a...
Definition Browser.cs:4899
IAllowBrowserActionOrAnySelector AppendFrom(IGPALGrid< string > inputGrid)
Append the data from the input grid to the end of any input Selectors defined in the UOW One token (...
Definition Browser.cs:4846
IAllowAfterWaitFor WaitFor(Selector waitForSelector)
Wait for the specified elements(s) defined by withForSelector to be present and defined on-page.
Definition Browser.cs:6203
IAllowBrowserSettingsOrGoTo WithProfileDataDirectory(string profileDataDirectory)
Specify the profile directory to use for this browser automation session NOTE: if both Profile User N...
Definition Browser.cs:989
IAllowBrowserActionOrAnySelector Hover(Selector selector=null)
Fluent alias for MoveTo(Selector). Moving the mouse to an element is a real input-layer event on ever...
Definition Browser.cs:5005
IAllowBrowserSettingsOrGoTo WithUseReferrer(string referrer="")
Set the refererrer to use with any Goto or Get for the rest of the browser sessions....
Definition Browser.cs:1067
IAllowBrowserSettingsOrGoTo WithUserAgentFromBrowser(bool userAgentFromBrowser=true)
Launch a browser purely to read its own user agent, instead of working one out without one....
Definition Browser.cs:1094
IAllowBrowserActionOrAnySelector CastDesktop(string sinkName)
Casts (mirrors) the entire desktop to the named cast sink (e.g. a Chromecast device)....
Definition Browser.cs:9972
IAllowBrowserActionOrAnySelector ClearCapturedCalls()
Forgets everything recorded so far, so what is captured next is what happened next....
Definition Browser.cs:5884
IAllowBrowserActionOrAnySelector GetLLMDigest(out LLMDigestResult digestResult, string ruleSetName=null)
Converts the current page's HTML into an LLM-optimized markdown digest.
Definition Browser.cs:9343
IAllowWorkflowExecution WhileLoopTimeout(int timeoutMs)
Sets the maximum amount of time a While/Until loop is allowed to run before it is aborted.
Definition Browser.cs:9016
IAllowBrowserActionOrAnySelector While(Func< Browser, bool > predicate)
Repeats the workflow added via WithWorkflow for as long as the given predicate returns true,...
Definition Browser.cs:9053
IAllowBrowserActionOrAnySelector Until(Selector selector)
Repeats the workflow added via WithWorkflow until the given selector matches an element on the page,...
Definition Browser.cs:9064
IAllowBrowserActionOrAnySelector AppendFrom(GPALDatabase inputDatabase)
Append data from the database to the end of any input Selectors defined in the UOW One token (one co...
Definition Browser.cs:4753
IAllowBrowserActionOrAnySelector MiddleClick()
Midle click the element found for Selector(s) defined in the UOW One middle click for each element f...
Definition Browser.cs:3844
IAllowBrowserSettingsOrGoTo WithGPALRestAPIUrl(string restApiUrl)
Specifies the TCP port number to start the browser, but also connect to this browser session from a c...
Definition Browser.cs:738
IAllowBrowserActionOrAnySelector ScrollWindowHorizontal(int scrollAmountInPixels)
Scroll the browser window horizontally the number of pixels. Negative numbers scroll left....
Definition Browser.cs:6709
IAllowBrowserActionOrAnySelector FillInFrom(GPALFile inputFile)
FillIn data from the file overwriting text in the input Selector(s) defined in the UOW One token (on...
Definition Browser.cs:4814
IAllowBrowserActionOrAnySelector GetSiteMapUrls(out List< string > sitemapUrls, string sitemapUrl=null)
Discovers and walks the site's sitemap(s) (via robots.txt, the current page, or heuristic detection),...
Definition Browser.cs:9637
bool? FileDownloaded
True if the most recent download action successfully downloaded a file. Null if no download has been ...
Definition Browser.cs:7409
IAllowBrowserActionOrAnySelector SetRange(int rangeValue)
Sets the value of a range/slider input element matched by the current selector(s) by scrolling it int...
Definition Browser.cs:7311
object JavaScriptResultObj
The result object returned by the most recent ExecuteJavaScriptObj call.
Definition Browser.cs:9095
IAllowBrowserActionOrAnySelector InsertFrom(GPALFile inputFile)
Insert the data from the file at the beginning of the text in the input Selector(s) defined in the UO...
Definition Browser.cs:4830
IAllowBrowserActionOrAnySelector LeftDoubleClick()
Left double click the element found for Selector(s) defined in the UOW One left click for each eleme...
Definition Browser.cs:3827
IAllowBrowserActionOrAnySelector StealthLeftClick(Selector selector)
Definition Browser.cs:3789
int ServerResponseCode
HTTP status code of the most recent page navigation/response.
Definition Browser.cs:7356
IAllowBrowserActionOrAnySelector Hide(Selector selector=null)
Hides the element(s) found for selector (or the current selector(s) if none is given) by setting the...
Definition Browser.cs:4940
IAllowBrowserActionOrAnySelector WithDialogText(string text)
What a prompt hands back when dialogs are accepted. Left unsaid, the prompt's own default stands.
Definition Browser.cs:7008
IAllowBrowserSettingsOrGoTo WithPersistentSelector(Selector selector)
Add a persistent selector to the persistent UOW. Persistent selectors are always looked for whenever ...
Definition Browser.cs:2312
IAllowAfterAnySelector WithHardware
Specifies to use hardware emulation when interacting with the Selector just defined with ....
Definition Browser.cs:2424
IAllowBrowserSettingsOrGoTo WithDriverLocation(string directory)
Specifies the location where the browser driver executable is loaded from. Default location is the d...
Definition Browser.cs:287
IAllowBrowserSettingsOrGoTo WithUseStealth(StealthType steathType)
Use stealth operations to block bot detection use Runtime.disable before clicking login button Add pe...
Definition Browser.cs:1056
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
IAllowBrowserActionOrAnySelector ExecuteJavaScriptObj(string javascript)
Executes JavaScript in the browser and stores the raw result object in JavaScriptResultObj.
Definition Browser.cs:6879
IAllowBrowserSettingsOrGoTo WithCredentials(ICredentials credentials)
Add a handler to be called when something fails on this browser that the workflow is in a position to...
Definition Browser.cs:2257
IAllowBrowserActionOrAnySelector GetPageSource(out string pageSource)
Gets the current page's HTML source, cleaned of escape sequences and control characters.
Definition Browser.cs:9393
IAllowBrowserSettingsOrGoTo PersistentCallIfNotFound(CallIfDelegate persistentCallIfNotFound)
Add a persistent CallIfNotFound handler to be called if any defined persistent selectors DO NOT find/...
Definition Browser.cs:2229
int CurrentTabIdx
Index of the currently active browser tab/window.
Definition Browser.cs:222
IAllowBrowserActionOrAnySelector AppendTo(GPALFile file)
Append the retrieved grid. The output format and delimiter are determined by the GPALFile (its extens...
Definition Browser.cs:6079
IPuppeteerClient PuppeteerClient
The Puppeteer client used to issue commands when this Browser is using the Puppeteer engine.
Definition Browser.cs:7539
IAllowBrowserActionOrAnySelector SaveTo(ref string data)
Hand back what .Fetch retrieved, as a JSON array holding one response body per page requested,...
Definition Browser.cs:6047
IAllowBrowserSettingsOrGoTo WithRespectRobotMetaTags(bool trueFalse=false)
Set whether to respect robots-related meta tags (e.g. noindex/nofollow) on the page.
Definition Browser.cs:2771
IAllowBrowserActionOrAnySelector ClearInjectedScripts()
Removes all scripts previously registered via InjectScript(string) or InjectScript(GPALFile).
Definition Browser.cs:7280
IAllowAllBrowserAndAllSelector Restore
Restore the browser to window state, unminimize.
Definition Browser.cs:2619
IAllowAfterWaitForAndSizeControl WaitForWindow(string waitForTitle)
Wait for a Windows window with title to appear on the desktop. Used in conjunction with ....
Definition Browser.cs:6250
IAllowBrowserActionOrAnySelector SelectClick(SelectClickType selectClickType)
Clicks the option element(s) found for the current selector(s) using the given select-click behavior ...
Definition Browser.cs:3767
IAllowBrowserSettingsOrGoTo WithProfileUserName(string profileUserName)
Specify the operating system login username to find the profile directory to use for this browser aut...
Definition Browser.cs:1017
IAllowBrowserActionOrAnySelector GoToTab(dynamic URLorTabTuple=null)
Goto the tab with the specified URL If using OttoMagic, you can use the tabid from browser....
Definition Browser.cs:6798
IAllowBrowserActionOrAnySelector CloseTab(dynamic URLorTabId=null)
Close the current in focus, active tab.
Definition Browser.cs:3266
IAllowWorkflowExecution WithWorkflow(Action< IBrowser > workflow)
Adds a workflow to run against this browser, used with Run, While and Until(Selector)....
Definition Browser.cs:9006
IAllowBrowserSettingsOrGoTo WithUseUserAgent(string userAgent="")
Set the user agent to use with any Goto or Get for the rest of the browser sessions....
Definition Browser.cs:1078
IAllowStorageOptions WithStorageData(string data)
Specifies the data value to write for the next storage .RunSet action. Starts a new StorageAction if ...
Definition Browser.cs:1649
IAllowWithPagesAndFetch WithTokensFrom(GPALDatabase inputDatabase)
Definition Browser.cs:2112
IAllowBrowserActionOrAnySelector PreviousWindow
Switches focus to the previous browser tab/window in the window list, wrapping around to the last.
Definition Browser.cs:8638
IAllowBrowserActionOrAnySelector LeftClick(ModifierKeys modifierKeys)
Left click the element found for Selector(s) defined in the UOW One left click for each element foun...
Definition Browser.cs:3691
IAllowNetworkIdleSettings WithNetworkIdleMaxConnections(int maxConnections=0)
Specifies the maximum duration to keep network stats before pruning old data.
Definition Browser.cs:940
IAllowBrowserActionOrAnySelector PreviousTab
Navigate to the previous tab. If at the first tab nothing will happen.
Definition Browser.cs:3625
IAllowBrowserActionOrAnySelector SetAttribute(string value)
Assigns value to the attribute named by the preceding WithAttribute call, completing the SetAttribut...
Definition Browser.cs:4973
IWebDriver BrowserDriver
The browser driver controlling the current Browser. Only available after the first ....
Definition Browser.cs:7460
IAllowBrowserSettingsOrGoTo WithCDPDebugPipe(bool trueFalse=false)
Use CDP protocol over pipes, should this impply CDP because selenium doesn't support pipe,...
Definition Browser.cs:815
IAllowBrowserActionOrAnySelector LeftClick()
Left click the element found for Selector(s) defined in the UOW One left click for the first element...
Definition Browser.cs:3681
IAllowBrowserActionOrAnySelector RunDelete(WebsiteStorageType storageType)
Deletes data from browser storage (cookies, local/session storage, IndexedDB) using the domain/path/k...
Definition Browser.cs:1558
IAllowStorageOptions WithStoragePath(string path)
Specifies the path to scope the next storage Run* action to. Starts a new StorageAction if the previo...
Definition Browser.cs:1592
IAllowBrowserActionOrAnySelector StartWorkflow()
Start a workflow to invoke CallIf handlers (if no other action is being used in the workflow) A Left...
Definition Browser.cs:5038
IAllowWithPagesAndFetch WithTokensFrom(GPALFile inputFile)
Definition Browser.cs:2103
IAllowBrowserActionOrAnySelector ReleaseModifierKey(ModifierKeys modifierKeys)
Release the specified modifer keys (can be ORd) NOTE: If you SendString a mixed case string,...
Definition Browser.cs:6618
IAllowSetAttributeValue WithAttribute(string attribute)
Names the DOM attribute to set on the current selector(s). Must be followed by SetAttribute(string) t...
Definition Browser.cs:4962
IAllowAfterAnySelector CallAfterFillIn(Browser.CallAfterFillInDelegate callAfterFillIn)
Add a handler to call after a row of tokens is consumed and after all inputs are filled in with data....
Definition Browser.cs:2136
IAllowBrowserActionOrAnySelector SendString(string textToSend)
Type in the literal string provided. NOTE: If you press SHIFT then SendString a mixed-case string,...
Definition Browser.cs:6522
IAllowBrowserActionOrAnySelector SaveHydratedData(GPALFile hydrationFile)
Definition Browser.cs:9326
IAllowBrowserActionOrAnySelector GetWindowRectangle(out Rectangle window)
The browser window position and size in screen coordinates, chrome included. Puppeteer reads it fr...
Definition Browser.cs:7227
IAllowBrowserActionOrAnySelector ClearDialogHandling()
Stops answering dialogs, so they open and block again the way they would for a person....
Definition Browser.cs:7033
IAllowBrowserActionOrAnySelector PageUp
Scroll the current tab window up one page. Scrolling ends the unit of work, because scrolling can re...
Definition Browser.cs:2600
IAllowBrowserActionOrAnySelector LeftClick(Selector selector)
Left click the element(s) found for Selector parameter, creates a UOW of 1 element One left click w/...
Definition Browser.cs:3754
IAllowBrowserActionOrAnySelector SaveLLMDigest(GPALFile llmDigestFile, string ruleSetName=null)
Converts the current page's HTML into an LLM-optimized markdown digest and saves it to a file.
Definition Browser.cs:9380
IAllowBrowserActionOrAnySelector CastTab(string sinkName)
Casts (mirrors) the current browser tab to the named cast sink (e.g. a Chromecast device)....
Definition Browser.cs:9951
IAllowAllBrowserAndAllSelector Maximize
Maximize the browser to full-screen.
Definition Browser.cs:2687
IAllowBrowserActionOrAnySelector LeftClickAndUpload(GPALFile gPalFileToUpload)
Left click the input[type=file] or custom control button Headful - enter filename in open file dialog...
Definition Browser.cs:4332
IAllowGetGridAndFillInFrom WithPages(int pageCount)
Specified the number of pages to retrieve when using GetGrid or any .SaveTo[CSV|Excel|File] Must als...
Definition Browser.cs:2084
IAllowBrowserActionOrAnySelector RunSet(WebsiteStorageType storageType)
Writes data to browser storage (cookies, local/session storage, IndexedDB) using the domain/path/key/...
Definition Browser.cs:1544
IAllowBrowserActionOrAnySelector FillInFrom(GPALDatabase inputDatabase)
FillIn data from the database overwriting text in the input Selector(s) defined in the UOW One token...
Definition Browser.cs:4783
IAllowWithHeaderOrFileActions WithGridToSave(IGPALGrid< string > inputGrid)
Specifies a GPALGrid<string> that will be subsequently saved to disk. Headers can be specified inlin...
Definition Browser.cs:620
IAllowBrowserActionOrAnySelector GetSiteMap(out string sitemapXML)
Gets the raw XML/source of the current page, intended for use when the current page is itself a sitem...
Definition Browser.cs:9564
BrowserType BrowserType
Return the current browser type.
Definition Browser.cs:7425
delegate CallIfStatus CallIfDelegate(IBrowser browser, List< IGPALElement > foundElements, List< IGPALElement > matchedElements, Selector selector, bool matchedAll)
Delegate callback for the CallIfFound/CallIfNotFound EventHandlers which will be invoked when a selec...
IAllowBrowserSettingsOrGoTo WithScrollIntoView(bool trueFalse=true)
Scroll each scraped element into view as it is read, so the page visibly follows the workflow....
Definition Browser.cs:549
string JavaScriptResultStr
The result string returned by the most recent ExecuteJavaScriptStr call.
Definition Browser.cs:9103
IAllowAllBrowserAndAllSelector WithWindowSize(Rectangle windowSize)
Set the default window size for when the browser opens. As well, exiting fullscreen will return to th...
Definition Browser.cs:2387
IAllowBrowserSettingsOrGoTo WithDownloadTimeoutInSec(int seconds)
Sets the maximum time to wait for a download to complete.
Definition Browser.cs:307
IAllowBrowserActionOrAnySelector WithPageOrientation(PageOrientation pageOrientation)
Sets the page orientation (Portrait/Landscape) used by PrintToPDF.
Definition Browser.cs:6271
IAllowWithHeaderOrFileActions GetGrid(ref IGPALGrid< string > returnGrid)
Retrieve the current UOW selectors data into a <string> of columns and rows to use with GPAL or howev...
Definition Browser.cs:5132
IAllowBrowserActionOrAnySelector PageDown
Scroll the current tab window down one page. Scrolling ends the unit of work, because scrolling can ...
Definition Browser.cs:2534
IAllowWithPagesAndGridActions WithInfiniteScroll
Indicates the current page does not have a Next Page button, but an infinite scroll....
Definition Browser.cs:2452
IAllowAfterAnySelector WithSelector(Selector selector)
Add a selector to the current UOW. Selectors make up a Unit of Work (UOW) to perform actions upon....
Definition Browser.cs:1124
IAllowStorageOutData RunGet(WebsiteStorageType storageType)
Reads data from browser storage (cookies, local/session storage, IndexedDB) using the domain/path/key...
Definition Browser.cs:1530
IAllowBrowserActionOrAnySelector InsertFrom(string textToUse)
Insert text at the beginning of the first input in the UOW. Should probably only be used with one sel...
Definition Browser.cs:4913
IAllowBrowserSettingsOrGoTo PersistentCallIfFound(CallIfDelegate persistentCallIfFound)
Add a persistent CallIfFound handler to be called if any defined persistent selectors find/match elem...
Definition Browser.cs:2214
IAllowBrowserActionOrAnySelector Refresh
Refresh the current page.
Definition Browser.cs:2507
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
IAllowWithPagesAndFetch WithTokensFrom(IGPALGrid< string > inputGrid)
Supply the values .Fetch drops into the numbered tokens of its request, one row per set of requests....
Definition Browser.cs:2096
IAllowAfterAnySelector CallIfFound(Browser.CallIfDelegate callIfFoundDelegate)
Add a Unit of Work level CallIfFound handler to be called if any defined selectors find/match element...
Definition Browser.cs:2152
IAllowBrowserActionOrAnySelector CaptureCallTemplate(out GPALRequest template)
Waits for the page to make a call the filter matches, then hands it back as a request ready to issue....
Definition Browser.cs:5717
IAllowBrowserActionOrAnySelector InjectScript(string script)
Registers a script (raw JS string) to run on every new document load. Callable before the first ....
Definition Browser.cs:7249
Process Process
The OS process hosting the browser, if started and tracked by GPAL.
Definition Browser.cs:7394
IAllowBrowserSettingsOrGoTo WithWaitOnIdleConnection(bool trueFalse=true)
Specifies GPAL will wait for network to be idle on current page before trying to locate elements....
Definition Browser.cs:890
Represents a URL with optional pre-navigation storage cleanup / inspection actions....
Definition GPALUrl.cs:53
IAllowGPALUrlStorageType ForUrl(string url)
Changes (or sets) the target URL for this builder instance.
Definition GPALUrl.cs:106
string Url
Gets the target URL string.
Definition GPALUrl.cs:69
List< StorageAction > Add(StorageAction storageAction)
Adds a fully-configured StorageAction to the list.
Definition GPALUrl.cs:243
void SetGoogleReferrer()
Overrides referrer stealthily browser.MagicHelper.SetGoogleReferrer();.
TabTuple GotoTab(dynamic URLorTabId)
Navigates to a tab by URL or tab ID TabTuple tab = browser.MagicHelper.GotoTab("https://example....
void CaptureCalls(bool capture=true, string urlFragment=null, bool clear=false)
Starts or stops recording what the page asks for. The extension watches with webRequest,...
string GetCapturedCalls()
Everything recorded so far, oldest first. string json = browser.MagicHelper.GetCapturedCalls();.
string GetContentAndCss(string elementId)
Gets content and CSS for a elementId string content = browser.MagicHelper.GetContentAndCss(elementId)...
bool IsEndOfPage()
Checks if the page is at the end bool end = browser.MagicHelper.IsEndOfPage();.
void PressModifierKey(ModifierKeys modifierKeys)
Press the specified modifier keys.
string GetPageSource()
Get the html source of the current page.
IAllowPuppeteerExecution GoTo(GPALUrl url)
Configure this client to navigate the current tab to the given URL, resolving relative URLs and check...
IAllowPuppeteerExecution CastDesktop(string deviceNameOrId)
Cast the desktop to the specified device name client.CastDesktop("living room").Execute();.
IAllowPuppeteerExecution CastTab(string deviceNameOrId)
Cast the current tab to the specified device name client.CastTab("living room").Execute();.
IAllowPuppeteerExecution SwitchToDefaultContent()
Configure this client to switch context back to the main document.
async Task SetGoogleReferrer(string sessionId=null)
Overrides the HTTP Referer header for subsequent requests to "https://www.google.com",...
Represents a single storage-related action to perform before navigating to a URL.
Definition GPALUrl.cs:274
WebAuthType WebAuthType
How the site expects to be told who you are, supplied via .WithWebAuth. Read by a browser this creden...
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 EnsureDirectoryEndsWithBackslash(string directoryPath)
Adds a trailing backslash to a directory path when it does not already have one, so the path can be c...
static string GetDefaultDownloadDirectory(IBrowser browser)
Returns the download directory this browser will actually use, read out of its own configuration rath...
One request the page made, as reported by the browser while .CaptureCalls was on. Where a GPALRequest...
string Url
The full url requested.
string PostData
The request body, when it had one. Null for a GET.
Dictionary< string, string > Headers
The headers the page sent with it. What its own code added is here, which is what an API expects and ...
string Method
The HTTP method, GET, POST and so on.
Class to define database usage. Currently only used for input from a table, sql or stored procedure....
Pseudo element used in Applications and Browser workflows for image matching and unified automation....
void SendKeys(string text)
Sends keystrokes to the element.
string TagName
HTML tag name of the element.
string GetAttribute(string attributeName)
Gets an attribute value with fallback to internal dictionary.
string AttributeName
Custom attribute name (used internally).
string Css
CSS selector used to locate this element.
dynamic WebElement
The wrapped Selenium IWebElement or internal dynamic element.
Thrown where GPAL deliberately ends the workflow, such as a CallIf handler returning CallIfStatus....
GPAL File object instantied with GPAL.File Used to load tokens into a GPALGrid [rows/columns].
Definition GPALFile.cs:36
List< string > ReturnFilenames
Get the list of filenames saved to (returned).
Definition GPALFile.cs:550
GPALFile Next
Advances an internal cursor and returns the next filename to use. If there is exactly one file and Wi...
Definition GPALFile.cs:401
int Count
The number of filenames in Filenames.
Definition GPALFile.cs:720
List< string > Filenames
Get the list of filenames.
Definition GPALFile.cs:540
string Filename
We have only one file, accessing it.
Definition GPALFile.cs:468
Everything starts here, all the GPAL controls and global settings using fluent syntax are here....
Definition GPAL.cs:49
static IAllowSelectorSettings Selector
Instantiates a new fluent GPAL Selector object used to locate an element. Selectors are for browsers...
Definition GPAL.cs:729
static IAllowConverterInput Converter
New GPAL Convertor.
Definition GPAL.cs:571
static byte VK_RETURN
Enter/Return key virtual keycode (VK) for use with keybd_event.
Definition GPAL.cs:98
static byte VK_DOWN
Down Arrow key virtual keycode (VK) for use with keybd_event.
Definition GPAL.cs:86
static IAllowRequestSettings Request
Start describing an API request for .Fetch to issue from inside the page, so it carries the session t...
Definition GPAL.cs:760
static IAllowRESTEndpoint RESTClient
Instantiate a new fluent RESTClient.
Definition GPAL.cs:950
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
static byte VK_HOME
Home key virtual keycode (VK) for use with keybd_event.
Definition GPAL.cs:82
Describes an API request for .Fetch to issue from inside the page, so it carries the session the brow...
Fluent REST client for making API calls with a chained interface. Supports defining workflows,...
Definition RESTClient.cs:56
IAllowRESTExecution WithHeader(string name, string value)
Adds an HTTP header to the request client.WithHeader("Authorization", "Bearer YOUR_ACCESS_TOKEN")....
GPAL Selector used to locate Application and Browser elements. Instantiated with GPAL....
Definition Selector.cs:56
string SelectorPath
Returns the first defined selector path string.
Definition Selector.cs:766
InteractionType InteractionType
Defined method to interact with this element. NOTE: This can be overridden in the workflow.
Definition Selector.cs:743
List< SelectorSettings.SelectorPathEntry > SelectorPaths
The selector paths defined to locate elements.
Definition Selector.cs:777
string Name
The name you gave this selector, or one assigned by GPAL [selector1, selector2...] Used in Informati...
Definition Selector.cs:870
Settings for the current selector. Use this only for debugging.
SelectorType SelectorType
Type of selector: A selector for an element, or literal data or a data function for dynamic data not ...
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 ....
List< string > HeaderList
Output header list, Defined using .WithHeader or, if not supplied, from the names given to selectors....
List< Selector > InSelectorList
List of iframe or shadow dom selectors. Set using .InFrame() or .InShadowDom() Use ....
Definition UnitOfWork.cs:53
List< Selector > WithSelectorList
List of With selectors Use .WithSelector to add selectors to this list.
Definition UnitOfWork.cs:45
int PageCount
Number of pages to retrieve. Default is 1 page. Use .WithPages() to set this value.
Definition UnitOfWork.cs:80
bool ActionCalled
Has an action been called on the current unit of work? If true, then the next .WithSelector() will c...
Allow Browser Settings, Goto, WaitOnDocumentReady.
What a workflow may say once .WithCallFilter has said which call it is after. Taking a template only ...
IAllowBrowserActionOrAnySelector SetAttribute(string value)
Directly sets a DOM attribute on the found element(s) via element.setAttribute(attribute,...