GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
SeleniumStorageHelper.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using System;
18using System.Collections.Generic;
19using System.Linq;
20using System.Text;
21using System.Threading.Tasks;
22using Newtonsoft.Json;
23using OpenQA.Selenium;
24using OpenQA.Selenium.Chromium;
25using OpenQA.Selenium.Support.Extensions;
26using static GenerallyPositive.Enums;
27
29{
30 public class SeleniumStorageHelper
31 {
32 internal IWebDriver webDriver;
33 internal ChromiumDriver browserDriver;
34 internal Browser browser;
35 private string EscapeJs(string s) => s?.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\"", "\\\"") ?? "";
36
37 internal SeleniumStorageHelper(Browser browser)
38 {
39 webDriver = browser.BrowserDriver;
40 if (Enums.BrowserType.FireFox != browser.BrowserType)
41 browserDriver = (ChromiumDriver) browser.BrowserDriver;
42 this.browser = browser;
43 }
44
45 // ──────────────────────────────────────────────────────────────
46 // Main public API – unchanged signature
47 // ──────────────────────────────────────────────────────────────
48
49 public string GetStorage(StorageAction action)
50 {
51 try
52 {
53 return action.StorageType switch
54 {
55 WebsiteStorageType.indexedDb => GetIndexedDb(action),
56 WebsiteStorageType.cookie => GetCookies(action),
57 WebsiteStorageType.localStorage => GetLocalOrSessionStorage(action),
58 WebsiteStorageType.sessionStorage => GetLocalOrSessionStorage(action),
59 WebsiteStorageType.cache => GetCache(action),
60 WebsiteStorageType.notSet => ReturnEmptyString(),
61 _ => FailAndReturnNull($"Get not implemented for {action.StorageType}", action)
62 };
63 }
64 catch (Exception ex)
65 {
66 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to get [{action.StorageType}]", action, GPALObjectType.Other, ex);
67 return null;
68 }
69 }
70
71 public int DeleteStorage(StorageAction action)
72 {
73 int deleted = 0;
74 try
75 {
76 deleted = action.StorageType switch
77 {
78 WebsiteStorageType.indexedDb => DeleteIndexedDb(action),
79 WebsiteStorageType.cookie => DeleteCookies(action),
80 WebsiteStorageType.localStorage => DeleteLocalOrSessionStorage(action),
81 WebsiteStorageType.sessionStorage => DeleteLocalOrSessionStorage(action),
82 WebsiteStorageType.cache => DeleteCache(action),
83 WebsiteStorageType.notSet => ReturnZero(),
84 _ => FailAndReturnZero($"Delete not implemented for {action.StorageType}", action)
85 };
86 }
87 catch (Exception ex)
88 {
89 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to delete [{action.StorageType}]", action, GPALObjectType.Other, ex);
90 }
91 return deleted;
92 }
93
94 public bool SetStorage(StorageAction action)
95 {
96 try
97 {
98 return action.StorageType switch
99 {
100 WebsiteStorageType.indexedDb => SetIndexedDb(action),
101 WebsiteStorageType.cookie => SetCookie(action),
102 WebsiteStorageType.localStorage => SetLocalOrSessionStorage(action),
103 WebsiteStorageType.sessionStorage => SetLocalOrSessionStorage(action),
104 WebsiteStorageType.cache => SetCache(action),
105 WebsiteStorageType.notSet => ReturnFalse(),
106 _ => FailAndReturnFalse($"Set not implemented for {action.StorageType}", action)
107 };
108 }
109 catch (Exception ex)
110 {
111 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to set [{action.StorageType}]", action, GPALObjectType.Other, ex);
112 return false;
113 }
114 }
115
116 private bool ReturnFalse()
117 {
118 return false;
119 }
120
121 private int ReturnZero()
122 {
123 return 0;
124 }
125
126 private string ReturnEmptyString()
127 {
128 return string.Empty;
129 }
130 // ──────────────────────────────────────────────────────────────
131 // IndexedDB – complete wildcard handling
132 // ──────────────────────────────────────────────────────────────
133
134 private string GetIndexedDb(StorageAction action) => GetIndexedDbSelenium(action);
135
136 internal string GetIndexedDbSelenium(StorageAction action)
137 {
138 string dbName = string.IsNullOrWhiteSpace(action.Path) ? null : EscapeJs(action.Path);
139 string storeName = string.IsNullOrWhiteSpace(action.StoreName) ? null : EscapeJs(action.StoreName);
140 string key = string.IsNullOrWhiteSpace(action.Key) ? null : EscapeJs(action.Key);
141
142 // Helper: C# null > JS null, otherwise safe string literal
143 string JsLit(string value) => value == null ? "null" : $"'{value}'";
144
145 string expression = $@"
146 (async () => {{
147 // Helper to open DB
148 const openDb = (name) => new Promise((resolve, reject) => {{
149 if (!name) {{
150 reject(new Error('Database name is required'));
151 return;
152 }}
153 const req = indexedDB.open(name);
154 req.onsuccess = () => resolve(req.result);
155 req.onerror = () => reject(req.error);
156 }});
157
158 try {{
159 // CASE 1: No dbName > list all database names
160 if ({JsLit(dbName)} === null) {{
161 if (typeof indexedDB.databases === 'function') {{
162 const dbs = await indexedDB.databases();
163 return JSON.stringify(dbs.map(db => db.name));
164 }}
165 return JSON.stringify([]); // fallback
166 }}
167
168 // CASE 2: dbName given, but no storeName > list object stores
169 if ({JsLit(storeName)} === null) {{
170 const db = await openDb({JsLit(dbName)});
171 const storeNames = Array.from(db.objectStoreNames);
172 db.close();
173 return JSON.stringify(storeNames);
174 }}
175
176 // CASE 3: Both dbName + storeName given
177 const db = await openDb({JsLit(dbName)});
178
179 if (!db.objectStoreNames.contains({JsLit(storeName)})) {{
180 db.close();
181 return {(key != null ? "null" : "JSON.stringify([])")};
182 }}
183
184 const tx = db.transaction({JsLit(storeName)}, 'readonly');
185 const store = tx.objectStore({JsLit(storeName)});
186
187 if ({(key != null ? "true" : "false")}) {{
188 // Get single value by key
189 const value = await new Promise((resolve, reject) => {{
190 const req = store.get({JsLit(key)});
191 req.onsuccess = () => resolve(req.result ?? null);
192 req.onerror = () => reject(req.error);
193 }});
194 db.close();
195 return JSON.stringify(value);
196 }}
197 else {{
198 // Get ALL values from the store
199 const values = await new Promise((resolve, reject) => {{
200 const req = store.getAll();
201 req.onsuccess = () => resolve(req.result);
202 req.onerror = () => reject(req.error);
203 }});
204 db.close();
205 return JSON.stringify(values);
206 }}
207 }} catch (e) {{
208 console.error('IndexedDB error in CDP:', e);
209 return JSON.stringify({{
210 error: true,
211 message: e.message || 'Unknown error',
212 detail: e.name || e.toString()
213 }});
214 }}
215 }})();
216 ";
217
218 if (Enums.BrowserType.FireFox == browser.BrowserType)
219 {
220 string retVal = ExecuteFirefoxAsyncScript(expression);
221 if (retVal?.Contains("\"error\":true") == true)
222 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"IndexedDB operation failed [{retVal}]", browser, GPALObjectType.Browser);
223 action.Data = retVal;
224 return retVal;
225 }
226
227 var parameters = new Dictionary<string, object>
228 {
229 { "expression", expression },
230 { "awaitPromise", true }, // ← This is critical for async/await
231 { "returnByValue", true } // ← Returns plain value instead of RemoteObject
232 };
233
234 try
235 {
236 dynamic cdpResult = ((ChromiumDriver)browserDriver).ExecuteCdpCommand("Runtime.evaluate", parameters);
237
238 string retVal = ExtractResultValue(cdpResult);
239
240 // Optional: nice debug output
241 if (retVal?.Contains("\"error\":true") == true)
242 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"IndexedDB operation failed [{retVal}]", cdpResult, GPALObjectType.Other);
243
244 action.Data = retVal;
245 return retVal;
246 }
247 catch (Exception ex)
248 {
249 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"IndexedDB operation failed", action, GPALObjectType.Other, ex);
250 string errorJson = $@"{{""error"":true,""message"":""{EscapeJs(ex.Message)}"",""detail"":""CDP execution error""}}";
251 action.Data = errorJson;
252 return errorJson;
253 }
254 }
255
256 private int DeleteIndexedDb(StorageAction action)
257 {
258 bool hasDb = !string.IsNullOrWhiteSpace(action.Path);
259 bool hasStore = !string.IsNullOrWhiteSpace(action.StoreName);
260 bool hasKey = !string.IsNullOrWhiteSpace(action.Key);
261
262 string js = hasKey
263 ? BuildDeleteKeysJs(action.Path, action.StoreName, action.Key)
264 : hasStore
265 ? BuildClearStoreJs(action.Path, action.StoreName)
266 : string.Empty;
267
268 if (Enums.BrowserType.FireFox == browser.BrowserType)
269 {
270 if (true == string.IsNullOrEmpty(js))
271 return DeleteAllIndexedDbFirefox(action) ? 1 : 0;
272 string ffRaw = ExecuteFirefoxAsyncScript(js) ?? "0";
273 if (IsErrorJson(ffRaw))
274 {
275 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"IndexedDB delete failed: [{ffRaw}]", action, GPALObjectType.Other);
276 return 0;
277 }
278 return int.TryParse(ffRaw, out int ffN) ? ffN : 0;
279 }
280
281 // the delete-everything answer was thrown away and 1 returned either way, so a CDP failure that
282 // already logged an ERROR was reported to the caller as one database deleted. Firefox above honours it
283 if (true == string.IsNullOrEmpty(js))
284 return DeleteAllIndexedDb(action) ? 1 : 0;
285
286 string raw = ExecuteAsyncJs<string>(js) ?? "0";
287
288 if (IsErrorJson(raw))
289 {
290 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"IndexedDB delete failed: [{raw}]", action, GPALObjectType.Other);
291 return 0;
292 }
293
294 return int.TryParse(raw, out int n) ? n : 0;
295 }
296
297 private bool DeleteAllIndexedDb(StorageAction action)
298 {
299 string origin = false == string.IsNullOrEmpty(action.Domain) ? UrlHelper.GetOrigin(action.Domain) : UrlHelper.GetOrigin(BrowserHelper.GetCurrentUrl(browser.BrowserSettings));
300
301 if (true == string.IsNullOrEmpty(origin))
302 origin = UrlHelper.GetOrigin(BrowserHelper.GetCurrentUrl(browser.BrowserSettings));
303
304 if (string.IsNullOrEmpty(origin))
305 {
306 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Unable to determine current origin for [{action.StorageType}] d[{action.Domain}] sn[{action.StoreName}] p[{action.Path}] k[{action.Key}]");
307 return false;
308 }
309
310 var cdpParams = new Dictionary<string, object>()
311 {
312 { "origin", origin },
313 { "storageTypes", "indexeddb" }
314 };
315
316 dynamic cdpResult = ((ChromiumDriver)browserDriver).ExecuteCdpCommand("Storage.clearDataForOrigin", cdpParams);
317
318 string retVal = ExtractResultValue(cdpResult);
319
320 if (retVal?.Contains("\"error\":true") == true)
321 {
322 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"IndexedDB operation failed [{retVal}]", cdpResult, GPALObjectType.Other);
323 return false;
324 }
325
326 return true;
327 }
328
329 private bool DeleteAllIndexedDbFirefox(StorageAction action)
330 {
331 string js = @"(async () => {
332 try {
333 if (typeof indexedDB.databases !== 'function') return 'ok';
334 const dbs = await indexedDB.databases();
335 for (const db of dbs) {
336 await new Promise((res, rej) => {
337 const req = indexedDB.deleteDatabase(db.name);
338 req.onsuccess = res;
339 req.onblocked = res;
340 req.onerror = rej;
341 });
342 }
343 return 'ok';
344 } catch(e) {
345 return JSON.stringify({error: true, message: e.message});
346 }
347 })()";
348 string result = ExecuteFirefoxAsyncScript(js);
349 if (result?.Contains("\"error\":true") == true)
350 {
351 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"IndexedDB delete all failed [{result}]", action, GPALObjectType.Other);
352 return false;
353 }
354 return true;
355 }
356
357 private bool SetCache(StorageAction action)
358 {
359 if (string.IsNullOrWhiteSpace(action.StoreName) || string.IsNullOrWhiteSpace(action.Key))
360 return FailAndReturnFalse("SetCache requires StoreName (cache) and Key (request URL)", action);
361
362
363 string cacheName = EscapeJs(action.StoreName);
364 string requestUrl = EscapeJs(action.Key);
365 string data = EscapeJs(action.Data);
366
367 string js = $@"
368 (async () => {{
369
370 function fail(msg, err) {{
371 console.error(msg, err?.message || err || '');
372 return {{ error: true, message: msg }};
373 }}
374
375 function parseBody(v) {{
376 try {{
377 return JSON.parse(v);
378 }} catch {{
379 return v;
380 }}
381 }}
382
383 function buildResponse(body) {{
384 // Normalize body > string
385 let payload;
386 let contentType = 'text/plain;charset=utf-8';
387
388 if (typeof body === 'object' && body !== null) {{
389 payload = JSON.stringify(body);
390 contentType = 'application/json;charset=utf-8';
391 }} else {{
392 payload = String(body);
393 }}
394
395 return new Response(payload, {{
396 status: 200,
397 statusText: 'OK',
398 headers: {{
399 'Content-Type': contentType,
400 'Cache-Control': 'max-age=31536000'
401 }}
402 }});
403 }}
404
405 function normalizeUrl(url) {{
406 try {{
407 return new URL(url, location.origin).href;
408 }} catch {{
409 throw new Error('Invalid URL: ' + url);
410 }}
411 }}
412
413 try {{
414 const cache = await caches.open('{cacheName}');
415
416 let normalizedUrl;
417 try {{
418 normalizedUrl = normalizeUrl('{requestUrl}');
419 }} catch (e) {{
420 return fail('URL normalization failed', e);
421 }}
422
423 const request = new Request(normalizedUrl, {{
424 method: 'GET'
425 }});
426
427 const body = parseBody('{data}');
428 const response = buildResponse(body);
429
430 try {{
431 await cache.put(request, response);
432 }} catch (e) {{
433 return fail('cache.put failed', e);
434 }}
435
436 return {{
437 success: true,
438 cacheName: '{cacheName}',
439 key: normalizedUrl
440 }};
441
442 }} catch (err) {{
443 return fail('Cache set failed', err);
444 }}
445
446 }})();
447 ";
448
449 if (Enums.BrowserType.FireFox == browser.BrowserType)
450 {
451 string ffResult = ExecuteFirefoxAsyncScript(js);
452 if (IsErrorJson(ffResult ?? ""))
453 {
454 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Cache set failed: [{ffResult}]", action, GPALObjectType.Other);
455 return false;
456 }
457 return ffResult?.Contains("success") == true;
458 }
459
460 string result = ExecuteAsyncJs<string>(js) ?? "false";
461
462 if (IsErrorJson(result))
463 {
464 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Cache set failed: [{result}]", action, GPALObjectType.Other);
465 return false;
466 }
467
468 return result.Contains("success");
469 }
470
471 private bool SetIndexedDb(StorageAction action)
472 {
473 string db = EscapeJs(action.Path);
474 string store = EscapeJs(action.StoreName);
475 string key = EscapeJs(action.Key);
476 string val = EscapeJs(action.Data);
477
478 string js = $@"
479 (async () => {{
480 function fail(msg, err, db) {{
481 console.error(msg, err?.name || err || '');
482 try {{ db?.close(); }} catch {{ }}
483 return {{ error: true, message: msg }};
484 }}
485
486 function parseValue(v) {{
487 try {{ return JSON.parse(v); }} catch {{ return v; }}
488 }}
489
490 function isValidKey(k) {{
491 if (k === undefined) return false;
492 if (typeof k === 'string' || typeof k === 'number') return true;
493 if (k instanceof Date) return true;
494 if (Array.isArray(k)) return true;
495 return false;
496 }}
497
498 try {{
499 // Open DB
500 let db = await new Promise((res, rej) => {{
501 let r = indexedDB.open('{db}');
502 r.onsuccess = e => res(e.target.result);
503 r.onupgradeneeded = e => {{
504 if (!e.target.result.objectStoreNames.contains('{store}'))
505 e.target.result.createObjectStore('{store}', {{ keyPath: null }});
506 }};
507 r.onerror = e => rej(e.target.error);
508 }});
509
510 let tx = db.transaction('{store}', 'readwrite');
511 let objStore = tx.objectStore('{store}');
512 let keyPath = objStore.keyPath;
513
514 let value = parseValue('{val}');
515 let inputKey = '{key}';
516 if (inputKey === '') inputKey = undefined;
517 if (inputKey !== undefined && !isValidKey(inputKey))
518 return fail('Invalid key type', inputKey, db);
519
520 // Wrap value for inline keyPath if needed
521 if (keyPath !== null && (typeof value !== 'object' || value === null)) {{
522 const wrapped = {{}};
523 if (typeof keyPath === 'string') wrapped[keyPath] = inputKey;
524 else if (Array.isArray(keyPath)) {{
525 if (!Array.isArray(inputKey)) throw new Error('Compound key requires array key');
526 for (let i = 0; i < keyPath.length; i++) wrapped[keyPath[i]] = inputKey[i];
527 }}
528 wrapped.value = value;
529 value = wrapped;
530 inputKey = undefined;
531 }}
532
533 let req;
534 try {{
535 if (keyPath === null) {{
536 if (inputKey === undefined && !objStore.autoIncrement)
537 return fail('Missing key for out-of-line store', null, db);
538 req = objStore.put(value, inputKey);
539 }} else {{
540 req = objStore.put(value);
541 }}
542 }} catch (e) {{
543 return fail('Put setup error', e, db);
544 }}
545
546 // Wait for transaction completion
547 await new Promise((res, rej) => {{
548 tx.oncomplete = () => res(true);
549 tx.onerror = () => rej(tx.error);
550 tx.onabort = () => rej(tx.error || 'Transaction aborted');
551 }});
552
553 db.close();
554 return true;
555
556 }} catch (e) {{
557 return {{ error: true, message: e.message }};
558 }}
559 }})();
560 ";
561
562 if (Enums.BrowserType.FireFox == browser.BrowserType)
563 {
564 string ffResult = ExecuteFirefoxAsyncScript(js);
565 if (ffResult == null || IsErrorJson(ffResult) || ffResult == "false")
566 {
567 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"IndexedDB set failed: [{ffResult}]", action, GPALObjectType.Other);
568 return false;
569 }
570 return true;
571 }
572
573 var parms = new Dictionary<string, object>
574 {
575 { "expression", js },
576 { "awaitPromise", true }, // ← This is critical for async/await
577 { "returnByValue", true } // ← Returns plain value instead of RemoteObject
578 };
579
580 dynamic result = ((ChromiumDriver)browserDriver).ExecuteCdpCommand("Runtime.evaluate", parms);
581
582 if (false == result?["result"]?["value"])
583 {
584 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"IndexedDB set failed: [{result}]", action, GPALObjectType.Other);
585 return false;
586 }
587
588 // the value is what says whether it worked and it is tested above, so reaching here is the success.
589 // this used to compare the whole CDP result dictionary against the string "true", which is never
590 // equal, so every set that worked reported failure
591 return true;
592 }
593
594 private string BuildDeleteKeysJs(string dbPat, string storePat, string keyPat)
595 {
596 string keyJs = $"'{EscapeJs(keyPat)}'";
597
598 // the count is what this returns. it used to take a provenance flag and build a per-key record
599 // instead, but nothing ever set the flag, so that whole path was unreachable
600 string collect = "deleted++";
601
602 string dbLoop = string.IsNullOrWhiteSpace(dbPat)
603 ? @"let dbs = await indexedDB.databases();
604 for (let info of dbs) { let dbName = info.name;"
605 : $"let dbName = '{EscapeJs(dbPat)}';";
606
607 string dbClose = string.IsNullOrWhiteSpace(dbPat) ? "db.close(); }" : "db.close();";
608
609 string storeLoop = string.IsNullOrWhiteSpace(storePat)
610 ? @"for (let storeName of db.objectStoreNames) {"
611 : $"let storeName = '{EscapeJs(storePat)}';";
612
613 string storeClose = string.IsNullOrWhiteSpace(storePat) ? "}" : "";
614
615 return $@"
616 (async () => {{
617 let deleted = 0;
618 try {{
619 {dbLoop}
620 let db = await new Promise((res, rej) => {{
621 let r = indexedDB.open(dbName);
622 r.onsuccess = e => res(e.target.result);
623 r.onerror = e => rej(e.target.error);
624 }});
625 {storeLoop}
626 try {{
627 let tx = db.transaction(storeName, 'readwrite');
628 let store = tx.objectStore(storeName);
629 let exists = await new Promise((res, rej) => {{
630 let r = store.get({keyJs});
631 r.onsuccess = () => res(r.result !== undefined);
632 r.onerror = rej;
633 }});
634 if (exists) {{
635 await new Promise((res, rej) => {{
636 let r = store.delete({keyJs});
637 r.onsuccess = res;
638 r.onerror = rej;
639 }});
640 {collect}
641 }}
642 }} catch (e) {{ /* skip bad store */ }}
643 {storeClose}
644 {dbClose}
645 return deleted;
646 }} catch (e) {{
647 return JSON.stringify({{error:true, message:e.message}});
648 }}
649 }})();
650 ";
651 }
652
653 private string BuildClearStoreJs(string dbNameRaw, string storeNameRaw)
654 {
655 string db = EscapeJs(dbNameRaw);
656 string store = EscapeJs(storeNameRaw);
657
658 return $@"
659 (async () => {{
660 try {{
661 let db = await new Promise((res, rej) => {{
662 let r = indexedDB.open('{db}');
663 r.onsuccess = e => res(e.target.result);
664 r.onerror = e => rej(e.target.error);
665 }});
666 let count = await new Promise((res, rej) => {{
667 let req = db.transaction('{store}', 'readonly').objectStore('{store}').count();
668 req.onsuccess = () => res(req.result);
669 req.onerror = rej;
670 }});
671 await new Promise((res, rej) => {{
672 let tx = db.transaction('{store}', 'readwrite');
673 let req = tx.objectStore('{store}').clear();
674 req.onsuccess = res;
675 req.onerror = rej;
676 }});
677 db.close();
678 return count;
679 }} catch (e) {{
680 return JSON.stringify({{error:true, message:e.message}});
681 }}
682 }})();
683 ";
684 }
685
686 // ──────────────────────────────────────────────────────────────
687 // Cookies / Local-Session – your existing impl is already solid
688 // (can keep as-is or apply similar error patterns if desired)
689 // ──────────────────────────────────────────────────────────────
690
691 // ──────────────────────────────────────────────────────────────
692 // COOKIES
693 private string GetCookies(StorageAction action)
694 {
695 bool isAllKey = string.IsNullOrEmpty(action.Key);
696 var cookies = webDriver.Manage().Cookies.AllCookies
697 .Where(c =>
698 (isAllKey || c.Name == action.Key) &&
699 UrlHelper.CookieDomainMatches(c.Domain, action.Domain) &&
700 (string.IsNullOrEmpty(action.Path) || c.Path == action.Path))
701 .ToList();
702
703 if (!cookies.Any()) return "[]";
704
705 // the whole cookie record, which is what every caller gets. the names-only alternative was behind
706 // action.IncludeProvenance, a flag no fluent call ever sets, so the ternary always took this branch
707 string data = System.Text.Json.JsonSerializer.Serialize(cookies.Select(c => new
708 {
709 name = c.Name,
710 value = c.Value,
711 domain = c.Domain,
712 path = c.Path,
713 expiry = c.Expiry,
714 secure = c.Secure,
715 httpOnly = c.IsHttpOnly
716 }));
717
719 //if (false == action.IncludeProvenance)
720 // data = System.Text.Json.JsonSerializer.Serialize(cookies.Select(c => c.Name));
721
722 action.Data = data;
723 return data;
724 }
725
726 private int DeleteCookies(StorageAction action)
727 {
728 bool isAllKey = string.IsNullOrEmpty(action.Key);
729
730 if (Enums.BrowserType.FireFox == browser.BrowserType)
731 {
732 int deleted = 0;
733 if (isAllKey && string.IsNullOrEmpty(action.Domain) && string.IsNullOrEmpty(action.Path))
734 {
735 int count = webDriver.Manage().Cookies.AllCookies.Count;
736 webDriver.Manage().Cookies.DeleteAllCookies();
737 return count;
738 }
739 foreach (var c in webDriver.Manage().Cookies.AllCookies.ToList())
740 {
741 if (!isAllKey && c.Name != action.Key) continue;
742 if (!UrlHelper.CookieDomainMatches(c.Domain, action.Domain)) continue;
743 if (!string.IsNullOrEmpty(action.Path) && c.Path != action.Path) continue;
744 webDriver.Manage().Cookies.DeleteCookieNamed(c.Name);
745 deleted++;
746 }
747 if (!isAllKey && deleted == 0)
748 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Cookie deletion failed: key [{action.Key}] not found", action, GPALObjectType.Other);
749 return deleted;
750 }
751
752 // Selenium's native Manage().Cookies and JS ExecuteScript often fail
753 // to clear Partitioned (CHIPS) cookies. CDP is the surgical fix.
754 int deletedCdp = 0;
755
756 // 1. Get all cookies via CDP to access PartitionKey metadata
757 // We pass an empty dictionary to get all cookies for the current session
758 dynamic response = ((ChromiumDriver)browserDriver).ExecuteCdpCommand("Network.getCookies", new Dictionary<string, object>());
759 var cookies = response["cookies"] as Array;
760
761 if (cookies == null) return 0;
762
763 foreach (dynamic c in cookies)
764 {
765 string name = (string)c["name"];
766 string domain = (string)c["domain"];
767 string path = (string)c["path"];
768
769 // 2. Apply your existing filters
770 if (!isAllKey && name != action.Key) continue;
771 if (!UrlHelper.CookieDomainMatches(domain, action.Domain)) continue;
772 if (!string.IsNullOrEmpty(action.Path) && path != action.Path) continue;
773
774 // 3. Build the deletion parameters for CDP
775 var delParams = new Dictionary<string, object>
776 {
777 { "name", name },
778 { "domain", domain },
779 { "path", path }
780 };
781
782 // 4. Handle PartitionKey if it exists
783 if (c.ContainsKey("partitionKey") && c["partitionKey"] != null)
784 {
785 var pk = new Dictionary<string, object>
786 {
787 { "topLevelSite", (string)c["partitionKey"]["topLevelSite"] },
788 { "hasCrossSiteAncestor", (bool)c["partitionKey"]["hasCrossSiteAncestor"] }
789 };
790 delParams.Add("partitionKey", pk);
791 }
792
793 // 5. Execute the surgical delete
794 ((ChromiumDriver)browserDriver).ExecuteCdpCommand("Network.deleteCookies", delParams);
795 deletedCdp++;
796 }
797
798 if (!isAllKey && deletedCdp == 0)
799 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
800 $"Cookie deletion failed: key [{action.Key}] not found",
801 action, GPALObjectType.Other);
802
803 return deletedCdp;
804 }
805
806
807 private bool SetCookie(StorageAction action)
808 {
809 if (Enums.BrowserType.FireFox == browser.BrowserType)
810 {
811 string name = action.Key;
812 string value = action.Data;
813 if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value))
814 {
815 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[key] and [data] cannot be empty. Unable to set [{action.StorageType}]", action, GPALObjectType.Other);
816 return false;
817 }
818 string path = string.IsNullOrWhiteSpace(action.Path) ? "/" : action.Path;
819 string domain = string.IsNullOrEmpty(action.Domain) ? new Uri(webDriver.Url).Host : action.Domain;
820 webDriver.Manage().Cookies.AddCookie(new Cookie(name, value, domain, path, null));
821 return true;
822 }
823
824 try
825 {
826 string name = action.Key;
827 string value = action.Data;
828 string path = string.IsNullOrWhiteSpace(action.Path) ? "/" : action.Path;
829 string domainInput = action.Domain;
830
831 // Collect empty fields
832 var emptyFields = new List<string>();
833
834 if (string.IsNullOrWhiteSpace(name))
835 emptyFields.Add("key");
836
837 if (string.IsNullOrWhiteSpace(value))
838 emptyFields.Add("data");
839
840 if (string.IsNullOrWhiteSpace(path))
841 emptyFields.Add("path");
842
843 if (string.IsNullOrWhiteSpace(domainInput))
844 emptyFields.Add("domain");
845
846 // Build CSV string
847 string emptyCsv = string.Join(",", emptyFields);
848
849 // Optional: check if any are empty
850 if (0 < emptyFields.Count)
851 {
852 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{emptyCsv}] cannot be empty. Unable to set [{action.StorageType}]", action, GPALObjectType.Other);
853 return false;
854 }
855
856 var driver = (OpenQA.Selenium.Chromium.ChromiumDriver)browserDriver;
857
858 // 1️⃣ Get cookies scoped to current page using Storage.getCookies
859 var getParams = new Dictionary<string, object>
860 {
861 ["urls"] = new[] { driver.Url }
862 };
863
864 dynamic result = driver.ExecuteCdpCommand("Storage.getCookies", getParams);
865 var cookies = result["cookies"] as object[];
866
867 dynamic existing = null;
868
869 if (cookies != null)
870 {
871 foreach (dynamic c in cookies)
872 {
873 string cName = c["name"]?.ToString() ?? "";
874 string cDomain = c["domain"]?.ToString() ?? "";
875 string cPath = c["path"]?.ToString() ?? "/";
876
877 bool domainMatches = string.IsNullOrEmpty(domainInput)
878 || cDomain == domainInput
879 || cDomain.EndsWith("." + domainInput);
880
881 if (cName == name && domainMatches && cPath == path)
882 {
883 existing = c;
884 break;
885 }
886 }
887 }
888
889 // 2️⃣ Build merged cookie
890 var cookie = new Dictionary<string, object>();
891
892 cookie["name"] = name;
893 cookie["value"] = value;
894 cookie["path"] = path;
895
896 // Domain resolution
897 string domain =
898 existing?["domain"]?.ToString()
899 ?? (!string.IsNullOrEmpty(domainInput)
900 ? domainInput
901 : new Uri(driver.Url).Host);
902
903 cookie["domain"] = domain;
904
905 // Expiry (~6 months default)
906 cookie["expires"] =
907 existing?["expires"] != null
908 ? (double)existing["expires"]
909 : DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (180L * 24 * 60 * 60);
910
911 cookie["secure"] = existing?["secure"] ?? true;
912 cookie["httpOnly"] = existing?["httpOnly"] ?? true;
913 cookie["sameSite"] = existing?["sameSite"]?.ToString() ?? "None";
914
915 // Optional extras
916 cookie["priority"] = existing?["priority"]?.ToString() ?? "Medium";
917
918 // Only include if present
919 if (existing?["partitionKey"] != null)
920 cookie["partitionKey"] = existing["partitionKey"];
921
922 // 3️⃣ Set cookie via Network.setCookie
923 driver.ExecuteCdpCommand("Network.setCookie", cookie);
924
925 return true;
926 }
927 catch (Exception ex)
928 {
929 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
930 $"Failed to set cookie [{action.Key}]",
931 action, GPALObjectType.Other, ex);
932
933 return false;
934 }
935 }
936
937 // ──────────────────────────────────────────────────────────────
938 // LOCAL / SESSION STORAGE
939 private string GetLocalOrSessionStorage(StorageAction action)
940 {
941 bool isAllKey = string.IsNullOrEmpty(action.Key);
942 string storageObj = action.StorageType.ToString();
943
944 string script = isAllKey
945 ? $@"
946 let obj = {{}};
947 for (let i = 0; i < {storageObj}.length; i++) {{
948 let k = {storageObj}.key(i);
949 obj[k] = {storageObj}.getItem(k);
950 }}
951 return JSON.stringify(obj);"
952 : $"return {storageObj}.getItem('{EscapeJs(action.Key)}') || null;";
953
954 dynamic result = webDriver.ExecuteJavaScript<dynamic>(script);
955
956 action.Data = result;
957 return result;
958 }
959
960 private int DeleteLocalOrSessionStorage(StorageAction action)
961 {
962 bool isAllKey = string.IsNullOrEmpty(action.Key);
963 string storageObj = action.StorageType.ToString();
964
965 string script = isAllKey
966 ? $@"
967 let count = {storageObj}.length;
968 {storageObj}.clear();
969 return count;"
970 : $@"
971 if ({storageObj}.getItem('{EscapeJs(action.Key)}') !== null) {{
972 {storageObj}.removeItem('{EscapeJs(action.Key)}');
973 return 1;
974 }} else return 0;";
975
976 return webDriver.ExecuteJavaScript<int>(script);
977 }
978
979 private bool SetLocalOrSessionStorage(StorageAction action)
980 {
981 string storageObj = action.StorageType.ToString();
982 string script = $"{storageObj}.setItem('{EscapeJs(action.Key)}', '{EscapeJs(action.Data)}'); return true;";
983 return webDriver.ExecuteJavaScript<bool>(script);
984 }
985
986 // ──────────────────────────────────────────────────────────────
987 // CACHE STORAGE
988 private string GetCache(StorageAction action)
989 {
990 string expression;
991
992 if (string.IsNullOrEmpty(action.StoreName) && string.IsNullOrEmpty(action.Key))
993 {
994 // Case 1: No path, no key > Return array of cache names only (matches original JS extension)
995 expression = @"(async () => {
996 const cacheNames = await caches.keys();
997 return JSON.stringify(cacheNames);
998 })();";
999 }
1000 else if (string.IsNullOrEmpty(action.Key))
1001 {
1002 // Case 2: Path only > List URLs inside the cache (no bodies)
1003 expression = $@"(async () => {{
1004 const cache = await caches.open('{EscapeJs(action.StoreName)}');
1005 const requests = await cache.keys();
1006 const urls = requests.map(req => req.url);
1007 return JSON.stringify(urls);
1008 }})();";
1009 }
1010 else
1011 {
1012 // Case 3: Path + Key > Return { url, body } or null (matches original JS)
1013 expression = $@"(async () => {{
1014 const cache = await caches.open('{EscapeJs(action.StoreName)}');
1015 const response = await cache.match('{EscapeJs(action.Key)}');
1016
1017 if (!response) return null;
1018
1019 let body = null;
1020 try {{
1021 body = await response.clone().text();
1022 }} catch (e) {{
1023 body = '[unreadable]';
1024 }}
1025
1026 return JSON.stringify({{
1027 url: response.url,
1028 body: body
1029 }});
1030 }})();";
1031 }
1032
1033 if (Enums.BrowserType.FireFox == browser.BrowserType)
1034 {
1035 string retVal = ExecuteFirefoxAsyncScript(expression);
1036 action.Data = retVal;
1037 return retVal;
1038 }
1039
1040 var parameters = new Dictionary<string, object>
1041 {
1042 { "expression", expression },
1043 { "awaitPromise", true }, // Critical for async IIFE
1044 { "returnByValue", true } // Return plain JSON string instead of RemoteObject
1045 };
1046
1047 try
1048 {
1049 dynamic cdpResult = ((ChromiumDriver)browserDriver).ExecuteCdpCommand("Runtime.evaluate", parameters);
1050
1051 string retVal = ExtractResultValue(cdpResult);
1052
1053 action.Data = retVal;
1054 return retVal;
1055 }
1056 catch (Exception ex)
1057 {
1058 // Log or handle as needed
1059 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Cache access failed", action, GPALObjectType.Other, ex);
1060 action.Data = null;
1061 return null;
1062 }
1063 }
1064
1068 private string ExtractResultValue(IDictionary<string, object> cdpResult)
1069 {
1070 if (cdpResult == null) return null;
1071
1072 if (cdpResult.TryGetValue("result", out var resultObj))
1073 {
1074 if (resultObj is IDictionary<string, object> resultDict)
1075 {
1076 if (resultDict.TryGetValue("value", out var value))
1077 {
1078 // a script that returns an object comes back as a dictionary, and ToString on one of those
1079 // is its type name. the cache set answers {success:true,...} and read as a type name, so a
1080 // put that worked reported failure, and an {error:true} answer could never be recognised
1081 return value is string said ? said : (null == value ? null : JsonConvert.SerializeObject(value));
1082 }
1083 }
1084 }
1085
1086 // Fallback for some edge cases
1087 if (cdpResult.TryGetValue("value", out var directValue))
1088 {
1089 return directValue?.ToString();
1090 }
1091
1092 return null;
1093 }
1094
1095 private int DeleteCache(StorageAction action)
1096 {
1097 // the cache is named by StoreName, which is what .WithStorageStoreName sets and what SetCache and
1098 // GetCache both read. Reading Path here meant a set, get and delete round trip on one cache took the
1099 // early out and deleted nothing
1100 if (string.IsNullOrEmpty(action.StoreName))
1101 {
1102 return 0; // nothing to do
1103 }
1104
1105 bool deleteWholeCache = string.IsNullOrEmpty(action.Key);
1106
1107 // caches.delete and cache.delete both answer whether anything was there, so the count is what they
1108 // say. this used to fire the delete into the background and return 1 before the promise settled,
1109 // so it claimed one deleted whether the cache existed or the delete threw
1110 string deleteCode = deleteWholeCache
1111 ? $"const gone = await caches.delete('{EscapeJs(action.StoreName)}');"
1112 : $"const cache = await caches.open('{EscapeJs(action.StoreName)}'); const gone = await cache.delete('{EscapeJs(action.Key)}');";
1113
1114 string script = $@"(async () => {{
1115 if (!('caches' in window)) return 0;
1116 try {{
1117 {deleteCode}
1118 return gone ? 1 : 0;
1119 }} catch(e) {{
1120 return JSON.stringify({{error: true, message: e.message}});
1121 }}
1122 }})();";
1123
1124 string raw = ExecuteAsyncJs<string>(script) ?? "0";
1125
1126 if (IsErrorJson(raw))
1127 {
1128 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Cache delete failed: [{raw}]", action, GPALObjectType.Other);
1129 return 0;
1130 }
1131
1132 return int.TryParse(raw, out int deleted) ? deleted : 0;
1133 }
1134
1135 // ──────────────────────────────────────────────────────────────
1136 // Helpers
1137 // ──────────────────────────────────────────────────────────────
1138
1139 private string ExecuteFirefoxAsyncScript(string asyncIife)
1140 {
1141 webDriver.Manage().Timeouts().AsynchronousJavaScript = TimeSpan.FromSeconds(30);
1142 string iife = asyncIife.TrimEnd(';', ' ', '\n', '\r', '\t');
1143 string wrapped = $@"var __cb = arguments[arguments.length - 1];
1144(async () => {{
1145 try {{
1146 const __r = await {iife};
1147 __cb(__r != null ? (typeof __r === 'string' ? __r : JSON.stringify(__r)) : null);
1148 }} catch(e) {{
1149 __cb(JSON.stringify({{error: true, message: e.message}}));
1150 }}
1151}})();";
1152 try
1153 {
1154 return ((IJavaScriptExecutor)webDriver).ExecuteAsyncScript(wrapped)?.ToString();
1155 }
1156 catch (Exception ex)
1157 {
1158 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Firefox async script failed", browser, GPALObjectType.Browser, ex);
1159 return null;
1160 }
1161 }
1162
1173 private string ExecuteAsyncJs<T>(string js, T fallback = default)
1174 {
1175 string retVal = fallback?.ToString() ?? "";
1176
1177 try
1178 {
1179 if (true == browser.UseSelenium && BrowserType.FireFox == browser.BrowserSettings.BrowserType)
1180 retVal = ExecuteFirefoxAsyncScript(js) ?? retVal;
1181 else
1182 {
1183 var parms = new Dictionary<string, object>
1184 {
1185 { "expression", js },
1186 { "awaitPromise", true },
1187 { "returnByValue", true }
1188 };
1189
1190 dynamic cdpResult = ((ChromiumDriver)browserDriver).ExecuteCdpCommand("Runtime.evaluate", parms);
1191
1192 retVal = ExtractResultValue(cdpResult) ?? retVal;
1193 }
1194 }
1195 catch (Exception ex)
1196 {
1197 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Async script failed", browser, GPALObjectType.Browser, ex);
1198 }
1199
1200 return retVal;
1201 }
1202
1203 private bool IsErrorJson(dynamic s) => s.Contains("\"error\":true");
1204
1205 private string FailAndReturnNull(string msg, StorageAction a)
1206 {
1207 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, a, GPALObjectType.Other);
1208 return null;
1209 }
1210
1211 private int FailAndReturnZero(string msg, StorageAction a)
1212 {
1213 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, a, GPALObjectType.Other);
1214 return 0;
1215 }
1216
1217 private bool FailAndReturnFalse(string msg, StorageAction a)
1218 {
1219 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, a, GPALObjectType.Other);
1220 return false;
1221 }
1222 }
1223}
1224
static string GetCurrentUrl(BrowserSettings browserSettings)
Returns the current page URL, queried via OttoMagic, Puppeteer, or the Selenium WebDriver depending o...
Browser object that contains the fluent methods to create your Browser workflow. Instatiated using G...
Definition Browser.cs:68
IWebDriver BrowserDriver
The browser driver controlling the current Browser. Only available after the first ....
Definition Browser.cs:7460
Represents a single storage-related action to perform before navigating to a URL.
Definition GPALUrl.cs:274
WebsiteStorageType StorageType
The target storage type (cookies, localStorage, indexedDb, etc.).
Definition GPALUrl.cs:298
string Key
Key name for localStorage, sessionStorage, IndexedDB, or cookie name.
Definition GPALUrl.cs:303
string StoreName
IndexedDB object store name.
Definition GPALUrl.cs:318
string Domain
Domain restriction (primarily for cookies).
Definition GPALUrl.cs:313
string Path
Path restriction (primarily for cookies).
Definition GPALUrl.cs:308
string Data
Value to get/set (used when Action = "get" or "set"). NOTE: context sensitive (json) data NOTE: Data ...
Definition GPALUrl.cs:328
Everything starts here, all the GPAL controls and global settings using fluent syntax are here....
Definition GPAL.cs:49
static void PublishSimpleEvent(GPALEventType gPALEventType, string msg, dynamic gPALObject=null, Enums.GPALObjectType gPALObjectType=GPALObjectType.None, Exception ex=null)
Publish a message to either the information channel or exception channel (if exception passed in) Pub...
Definition GPAL.cs:2568