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 OpenQA.Selenium;
23using OpenQA.Selenium.Chromium;
24using OpenQA.Selenium.Support.Extensions;
25using static GenerallyPositive.Enums;
26
28{
29 public class SeleniumStorageHelper
30 {
31 internal IWebDriver webDriver;
32 internal ChromiumDriver browserDriver;
33 internal Browser browser;
34 private string EscapeJs(string s) => s?.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\"", "\\\"") ?? "";
35
36 internal SeleniumStorageHelper(Browser browser)
37 {
38 webDriver = browser.BrowserDriver;
39 if (Enums.BrowserType.FireFox != browser.BrowserType)
40 browserDriver = (ChromiumDriver) browser.BrowserDriver;
41 this.browser = browser;
42 }
43
44 // ──────────────────────────────────────────────────────────────
45 // Main public API – unchanged signature
46 // ──────────────────────────────────────────────────────────────
47
48 public string GetStorage(StorageAction action)
49 {
50 try
51 {
52 return action.StorageType switch
53 {
54 WebsiteStorageType.indexedDb => GetIndexedDb(action),
55 WebsiteStorageType.cookie => GetCookies(action),
56 WebsiteStorageType.localStorage => GetLocalOrSessionStorage(action),
57 WebsiteStorageType.sessionStorage => GetLocalOrSessionStorage(action),
58 WebsiteStorageType.cache => GetCache(action),
59 WebsiteStorageType.notSet => ReturnEmptyString(),
60 _ => FailAndReturnNull($"Get not implemented for {action.StorageType}", action)
61 };
62 }
63 catch (Exception ex)
64 {
65 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to get [{action.StorageType}]", action, GPALObjectType.Other, ex);
66 return null;
67 }
68 }
69
70 public int DeleteStorage(StorageAction action)
71 {
72 int deleted = 0;
73 try
74 {
75 deleted = action.StorageType switch
76 {
77 WebsiteStorageType.indexedDb => DeleteIndexedDb(action),
78 WebsiteStorageType.cookie => DeleteCookies(action),
79 WebsiteStorageType.localStorage => DeleteLocalOrSessionStorage(action),
80 WebsiteStorageType.sessionStorage => DeleteLocalOrSessionStorage(action),
81 WebsiteStorageType.cache => DeleteCache(action),
82 WebsiteStorageType.notSet => ReturnZero(),
83 _ => FailAndReturnZero($"Delete not implemented for {action.StorageType}", action)
84 };
85 }
86 catch (Exception ex)
87 {
88 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to delete [{action.StorageType}]", action, GPALObjectType.Other, ex);
89 }
90 return deleted;
91 }
92
93 public bool SetStorage(StorageAction action)
94 {
95 try
96 {
97 return action.StorageType switch
98 {
99 WebsiteStorageType.indexedDb => SetIndexedDb(action),
100 WebsiteStorageType.cookie => SetCookie(action),
101 WebsiteStorageType.localStorage => SetLocalOrSessionStorage(action),
102 WebsiteStorageType.sessionStorage => SetLocalOrSessionStorage(action),
103 WebsiteStorageType.cache => SetCache(action),
104 WebsiteStorageType.notSet => ReturnFalse(),
105 _ => FailAndReturnFalse($"Set not implemented for {action.StorageType}", action)
106 };
107 }
108 catch (Exception ex)
109 {
110 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to set [{action.StorageType}]", action, GPALObjectType.Other, ex);
111 return false;
112 }
113 }
114
115 private bool ReturnFalse()
116 {
117 return false;
118 }
119
120 private int ReturnZero()
121 {
122 return 0;
123 }
124
125 private string ReturnEmptyString()
126 {
127 return string.Empty;
128 }
129 // ──────────────────────────────────────────────────────────────
130 // IndexedDB – complete wildcard handling
131 // ──────────────────────────────────────────────────────────────
132
133 private string GetIndexedDb(StorageAction action) => GetIndexedDbSelenium(action);
134
135 internal string GetIndexedDbSelenium(StorageAction action)
136 {
137 string dbName = string.IsNullOrWhiteSpace(action.Path) ? null : EscapeJs(action.Path);
138 string storeName = string.IsNullOrWhiteSpace(action.StoreName) ? null : EscapeJs(action.StoreName);
139 string key = string.IsNullOrWhiteSpace(action.Key) ? null : EscapeJs(action.Key);
140
141 // Helper: C# null > JS null, otherwise safe string literal
142 string JsLit(string value) => value == null ? "null" : $"'{value}'";
143
144 string expression = $@"
145 (async () => {{
146 // Helper to open DB
147 const openDb = (name) => new Promise((resolve, reject) => {{
148 if (!name) {{
149 reject(new Error('Database name is required'));
150 return;
151 }}
152 const req = indexedDB.open(name);
153 req.onsuccess = () => resolve(req.result);
154 req.onerror = () => reject(req.error);
155 }});
156
157 try {{
158 // CASE 1: No dbName > list all database names
159 if ({JsLit(dbName)} === null) {{
160 if (typeof indexedDB.databases === 'function') {{
161 const dbs = await indexedDB.databases();
162 return JSON.stringify(dbs.map(db => db.name));
163 }}
164 return JSON.stringify([]); // fallback
165 }}
166
167 // CASE 2: dbName given, but no storeName > list object stores
168 if ({JsLit(storeName)} === null) {{
169 const db = await openDb({JsLit(dbName)});
170 const storeNames = Array.from(db.objectStoreNames);
171 db.close();
172 return JSON.stringify(storeNames);
173 }}
174
175 // CASE 3: Both dbName + storeName given
176 const db = await openDb({JsLit(dbName)});
177
178 if (!db.objectStoreNames.contains({JsLit(storeName)})) {{
179 db.close();
180 return {(key != null ? "null" : "JSON.stringify([])")};
181 }}
182
183 const tx = db.transaction({JsLit(storeName)}, 'readonly');
184 const store = tx.objectStore({JsLit(storeName)});
185
186 if ({(key != null ? "true" : "false")}) {{
187 // Get single value by key
188 const value = await new Promise((resolve, reject) => {{
189 const req = store.get({JsLit(key)});
190 req.onsuccess = () => resolve(req.result ?? null);
191 req.onerror = () => reject(req.error);
192 }});
193 db.close();
194 return JSON.stringify(value);
195 }}
196 else {{
197 // Get ALL values from the store
198 const values = await new Promise((resolve, reject) => {{
199 const req = store.getAll();
200 req.onsuccess = () => resolve(req.result);
201 req.onerror = () => reject(req.error);
202 }});
203 db.close();
204 return JSON.stringify(values);
205 }}
206 }} catch (e) {{
207 console.error('IndexedDB error in CDP:', e);
208 return JSON.stringify({{
209 error: true,
210 message: e.message || 'Unknown error',
211 detail: e.name || e.toString()
212 }});
213 }}
214 }})();
215 ";
216
217 if (Enums.BrowserType.FireFox == browser.BrowserType)
218 {
219 string retVal = ExecuteFirefoxAsyncScript(expression);
220 if (retVal?.Contains("\"error\":true") == true)
221 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"IndexedDB operation failed [{retVal}]", browser, GPALObjectType.Browser);
222 action.Data = retVal;
223 return retVal;
224 }
225
226 var parameters = new Dictionary<string, object>
227 {
228 { "expression", expression },
229 { "awaitPromise", true }, // ← This is critical for async/await
230 { "returnByValue", true } // ← Returns plain value instead of RemoteObject
231 };
232
233 try
234 {
235 dynamic cdpResult = ((ChromiumDriver)browserDriver).ExecuteCdpCommand("Runtime.evaluate", parameters);
236
237 string retVal = ExtractResultValue(cdpResult);
238
239 // Optional: nice debug output
240 if (retVal?.Contains("\"error\":true") == true)
241 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"IndexedDB operation failed [{retVal}]", cdpResult, GPALObjectType.Other);
242
243 action.Data = retVal;
244 return retVal;
245 }
246 catch (Exception ex)
247 {
248 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"IndexedDB operation failed", action, GPALObjectType.Other, ex);
249 string errorJson = $@"{{""error"":true,""message"":""{EscapeJs(ex.Message)}"",""detail"":""CDP execution error""}}";
250 action.Data = errorJson;
251 return errorJson;
252 }
253 }
254
255 private int DeleteIndexedDb(StorageAction action)
256 {
257 bool hasDb = !string.IsNullOrWhiteSpace(action.Path);
258 bool hasStore = !string.IsNullOrWhiteSpace(action.StoreName);
259 bool hasKey = !string.IsNullOrWhiteSpace(action.Key);
260
261 string js = hasKey
262 ? BuildDeleteKeysJs(action.Path, action.StoreName, action.Key, action.IncludeProvenance)
263 : hasStore
264 ? BuildClearStoreJs(action.Path, action.StoreName)
265 : string.Empty;
266
267 if (Enums.BrowserType.FireFox == browser.BrowserType)
268 {
269 if (true == string.IsNullOrEmpty(js))
270 return DeleteAllIndexedDbFirefox(action) ? 1 : 0;
271 string ffRaw = ExecuteFirefoxAsyncScript(js) ?? "0";
272 if (IsErrorJson(ffRaw))
273 {
274 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"IndexedDB delete failed: [{ffRaw}]", action, GPALObjectType.Other);
275 return 0;
276 }
277 return int.TryParse(ffRaw, out int ffN) ? ffN : 0;
278 }
279
280 if (true == string.IsNullOrEmpty(js))
281 DeleteAllIndexedDb(action);
282 else
283 {
284 string raw = ExecuteAsyncJs<string>(js) ?? "0";
285
286 if (IsErrorJson(raw))
287 {
288 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"IndexedDB delete failed: [{raw}]", action, GPALObjectType.Other);
289 return 0;
290 }
291
292 return int.TryParse(raw, out int n) ? n : 0;
293 }
294 return 1;
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 return result == "true";
589 }
590
591 private string BuildDeleteKeysJs(string dbPat, string storePat, string keyPat, bool provenance)
592 {
593 string keyJs = $"'{EscapeJs(keyPat)}'";
594 string collect = provenance
595 ? $"results.push({{db: dbName, store: storeName, key: {keyJs}, deleted: true}})"
596 : "deleted++";
597
598 string dbLoop = string.IsNullOrWhiteSpace(dbPat)
599 ? @"let dbs = await indexedDB.databases();
600 for (let info of dbs) { let dbName = info.name;"
601 : $"let dbName = '{EscapeJs(dbPat)}';";
602
603 string dbClose = string.IsNullOrWhiteSpace(dbPat) ? "db.close(); }" : "db.close();";
604
605 string storeLoop = string.IsNullOrWhiteSpace(storePat)
606 ? @"for (let storeName of db.objectStoreNames) {"
607 : $"let storeName = '{EscapeJs(storePat)}';";
608
609 string storeClose = string.IsNullOrWhiteSpace(storePat) ? "}" : "";
610
611 return $@"
612 (async () => {{
613 let deleted = 0;
614 let results = [];
615 try {{
616 {dbLoop}
617 let db = await new Promise((res, rej) => {{
618 let r = indexedDB.open(dbName);
619 r.onsuccess = e => res(e.target.result);
620 r.onerror = e => rej(e.target.error);
621 }});
622 {storeLoop}
623 try {{
624 let tx = db.transaction(storeName, 'readwrite');
625 let store = tx.objectStore(storeName);
626 let exists = await new Promise((res, rej) => {{
627 let r = store.get({keyJs});
628 r.onsuccess = () => res(r.result !== undefined);
629 r.onerror = rej;
630 }});
631 if (exists) {{
632 await new Promise((res, rej) => {{
633 let r = store.delete({keyJs});
634 r.onsuccess = res;
635 r.onerror = rej;
636 }});
637 {collect}
638 }}
639 }} catch (e) {{ /* skip bad store */ }}
640 {storeClose}
641 {dbClose}
642 return {(provenance ? "JSON.stringify(results)" : "deleted")};
643 }} catch (e) {{
644 return JSON.stringify({{error:true, message:e.message}});
645 }}
646 }})();
647 ";
648 }
649
650 private string BuildClearStoreJs(string dbNameRaw, string storeNameRaw)
651 {
652 string db = EscapeJs(dbNameRaw);
653 string store = EscapeJs(storeNameRaw);
654
655 return $@"
656 (async () => {{
657 try {{
658 let db = await new Promise((res, rej) => {{
659 let r = indexedDB.open('{db}');
660 r.onsuccess = e => res(e.target.result);
661 r.onerror = e => rej(e.target.error);
662 }});
663 let count = await new Promise((res, rej) => {{
664 let req = db.transaction('{store}', 'readonly').objectStore('{store}').count();
665 req.onsuccess = () => res(req.result);
666 req.onerror = rej;
667 }});
668 await new Promise((res, rej) => {{
669 let tx = db.transaction('{store}', 'readwrite');
670 let req = tx.objectStore('{store}').clear();
671 req.onsuccess = res;
672 req.onerror = rej;
673 }});
674 db.close();
675 return count;
676 }} catch (e) {{
677 return JSON.stringify({{error:true, message:e.message}});
678 }}
679 }})();
680 ";
681 }
682
683 // ──────────────────────────────────────────────────────────────
684 // Cookies / Local-Session – your existing impl is already solid
685 // (can keep as-is or apply similar error patterns if desired)
686 // ──────────────────────────────────────────────────────────────
687
688 // ──────────────────────────────────────────────────────────────
689 // COOKIES
690 private string GetCookies(StorageAction action)
691 {
692 bool isAllKey = string.IsNullOrEmpty(action.Key);
693 var cookies = webDriver.Manage().Cookies.AllCookies
694 .Where(c =>
695 (isAllKey || c.Name == action.Key) &&
696 UrlHelper.CookieDomainMatches(c.Domain, action.Domain) &&
697 (string.IsNullOrEmpty(action.Path) || c.Path == action.Path))
698 .ToList();
699
700 if (!cookies.Any()) return "[]";
701
702 string data = true //action.IncludeProvenance// TODO: use provenance correctly
703 ? System.Text.Json.JsonSerializer.Serialize(cookies.Select(c => new
704 {
705 name = c.Name,
706 value = c.Value,
707 domain = c.Domain,
708 path = c.Path,
709 expiry = c.Expiry,
710 secure = c.Secure,
711 httpOnly = c.IsHttpOnly
712 }))
713 : System.Text.Json.JsonSerializer.Serialize(cookies.Select(c => c.Name));
714
715 action.Data = data;
716 return data;
717 }
718
719 private int DeleteCookies(StorageAction action)
720 {
721 bool isAllKey = string.IsNullOrEmpty(action.Key);
722
723 if (Enums.BrowserType.FireFox == browser.BrowserType)
724 {
725 int deleted = 0;
726 if (isAllKey && string.IsNullOrEmpty(action.Domain) && string.IsNullOrEmpty(action.Path))
727 {
728 int count = webDriver.Manage().Cookies.AllCookies.Count;
729 webDriver.Manage().Cookies.DeleteAllCookies();
730 return count;
731 }
732 foreach (var c in webDriver.Manage().Cookies.AllCookies.ToList())
733 {
734 if (!isAllKey && c.Name != action.Key) continue;
735 if (!UrlHelper.CookieDomainMatches(c.Domain, action.Domain)) continue;
736 if (!string.IsNullOrEmpty(action.Path) && c.Path != action.Path) continue;
737 webDriver.Manage().Cookies.DeleteCookieNamed(c.Name);
738 deleted++;
739 }
740 if (!isAllKey && deleted == 0)
741 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Cookie deletion failed: key [{action.Key}] not found", action, GPALObjectType.Other);
742 return deleted;
743 }
744
745 // Selenium's native Manage().Cookies and JS ExecuteScript often fail
746 // to clear Partitioned (CHIPS) cookies. CDP is the surgical fix.
747 int deletedCdp = 0;
748
749 // 1. Get all cookies via CDP to access PartitionKey metadata
750 // We pass an empty dictionary to get all cookies for the current session
751 dynamic response = ((ChromiumDriver)browserDriver).ExecuteCdpCommand("Network.getCookies", new Dictionary<string, object>());
752 var cookies = response["cookies"] as Array;
753
754 if (cookies == null) return 0;
755
756 foreach (dynamic c in cookies)
757 {
758 string name = (string)c["name"];
759 string domain = (string)c["domain"];
760 string path = (string)c["path"];
761
762 // 2. Apply your existing filters
763 if (!isAllKey && name != action.Key) continue;
764 if (!UrlHelper.CookieDomainMatches(domain, action.Domain)) continue;
765 if (!string.IsNullOrEmpty(action.Path) && path != action.Path) continue;
766
767 // 3. Build the deletion parameters for CDP
768 var delParams = new Dictionary<string, object>
769 {
770 { "name", name },
771 { "domain", domain },
772 { "path", path }
773 };
774
775 // 4. Handle PartitionKey if it exists
776 if (c.ContainsKey("partitionKey") && c["partitionKey"] != null)
777 {
778 var pk = new Dictionary<string, object>
779 {
780 { "topLevelSite", (string)c["partitionKey"]["topLevelSite"] },
781 { "hasCrossSiteAncestor", (bool)c["partitionKey"]["hasCrossSiteAncestor"] }
782 };
783 delParams.Add("partitionKey", pk);
784 }
785
786 // 5. Execute the surgical delete
787 ((ChromiumDriver)browserDriver).ExecuteCdpCommand("Network.deleteCookies", delParams);
788 deletedCdp++;
789 }
790
791 if (!isAllKey && deletedCdp == 0)
792 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
793 $"Cookie deletion failed: key [{action.Key}] not found",
794 action, GPALObjectType.Other);
795
796 return deletedCdp;
797 }
798
799
800 private bool SetCookie(StorageAction action)
801 {
802 if (Enums.BrowserType.FireFox == browser.BrowserType)
803 {
804 string name = action.Key;
805 string value = action.Data;
806 if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value))
807 {
808 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[key] and [data] cannot be empty. Unable to set [{action.StorageType}]", action, GPALObjectType.Other);
809 return false;
810 }
811 string path = string.IsNullOrWhiteSpace(action.Path) ? "/" : action.Path;
812 string domain = string.IsNullOrEmpty(action.Domain) ? new Uri(webDriver.Url).Host : action.Domain;
813 webDriver.Manage().Cookies.AddCookie(new Cookie(name, value, domain, path, null));
814 return true;
815 }
816
817 try
818 {
819 string name = action.Key;
820 string value = action.Data;
821 string path = string.IsNullOrWhiteSpace(action.Path) ? "/" : action.Path;
822 string domainInput = action.Domain;
823
824 // Collect empty fields
825 var emptyFields = new List<string>();
826
827 if (string.IsNullOrWhiteSpace(name))
828 emptyFields.Add("key");
829
830 if (string.IsNullOrWhiteSpace(value))
831 emptyFields.Add("data");
832
833 if (string.IsNullOrWhiteSpace(path))
834 emptyFields.Add("path");
835
836 if (string.IsNullOrWhiteSpace(domainInput))
837 emptyFields.Add("domain");
838
839 // Build CSV string
840 string emptyCsv = string.Join(",", emptyFields);
841
842 // Optional: check if any are empty
843 if (0 < emptyFields.Count)
844 {
845 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{emptyCsv}] cannot be empty. Unable to set [{action.StorageType}]", action, GPALObjectType.Other);
846 return false;
847 }
848
849 var driver = (OpenQA.Selenium.Chromium.ChromiumDriver)browserDriver;
850
851 // 1️⃣ Get cookies scoped to current page using Storage.getCookies
852 var getParams = new Dictionary<string, object>
853 {
854 ["urls"] = new[] { driver.Url }
855 };
856
857 dynamic result = driver.ExecuteCdpCommand("Storage.getCookies", getParams);
858 var cookies = result["cookies"] as object[];
859
860 dynamic existing = null;
861
862 if (cookies != null)
863 {
864 foreach (dynamic c in cookies)
865 {
866 string cName = c["name"]?.ToString() ?? "";
867 string cDomain = c["domain"]?.ToString() ?? "";
868 string cPath = c["path"]?.ToString() ?? "/";
869
870 bool domainMatches = string.IsNullOrEmpty(domainInput)
871 || cDomain == domainInput
872 || cDomain.EndsWith("." + domainInput);
873
874 if (cName == name && domainMatches && cPath == path)
875 {
876 existing = c;
877 break;
878 }
879 }
880 }
881
882 // 2️⃣ Build merged cookie
883 var cookie = new Dictionary<string, object>();
884
885 cookie["name"] = name;
886 cookie["value"] = value;
887 cookie["path"] = path;
888
889 // Domain resolution
890 string domain =
891 existing?["domain"]?.ToString()
892 ?? (!string.IsNullOrEmpty(domainInput)
893 ? domainInput
894 : new Uri(driver.Url).Host);
895
896 cookie["domain"] = domain;
897
898 // Expiry (~6 months default)
899 cookie["expires"] =
900 existing?["expires"] != null
901 ? (double)existing["expires"]
902 : DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (180L * 24 * 60 * 60);
903
904 cookie["secure"] = existing?["secure"] ?? true;
905 cookie["httpOnly"] = existing?["httpOnly"] ?? true;
906 cookie["sameSite"] = existing?["sameSite"]?.ToString() ?? "None";
907
908 // Optional extras
909 cookie["priority"] = existing?["priority"]?.ToString() ?? "Medium";
910
911 // Only include if present
912 if (existing?["partitionKey"] != null)
913 cookie["partitionKey"] = existing["partitionKey"];
914
915 // 3️⃣ Set cookie via Network.setCookie
916 driver.ExecuteCdpCommand("Network.setCookie", cookie);
917
918 return true;
919 }
920 catch (Exception ex)
921 {
922 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
923 $"Failed to set cookie [{action.Key}]",
924 action, GPALObjectType.Other, ex);
925
926 return false;
927 }
928 }
929
930 // ──────────────────────────────────────────────────────────────
931 // LOCAL / SESSION STORAGE
932 private string GetLocalOrSessionStorage(StorageAction action)
933 {
934 bool isAllKey = string.IsNullOrEmpty(action.Key);
935 string storageObj = action.StorageType.ToString();
936
937 string script = isAllKey
938 ? $@"
939 let obj = {{}};
940 for (let i = 0; i < {storageObj}.length; i++) {{
941 let k = {storageObj}.key(i);
942 obj[k] = {storageObj}.getItem(k);
943 }}
944 return JSON.stringify(obj);"
945 : $"return {storageObj}.getItem('{EscapeJs(action.Key)}') || null;";
946
947 dynamic result = webDriver.ExecuteJavaScript<dynamic>(script);
948
949 action.Data = result;
950 return result;
951 }
952
953 private int DeleteLocalOrSessionStorage(StorageAction action)
954 {
955 bool isAllKey = string.IsNullOrEmpty(action.Key);
956 string storageObj = action.StorageType.ToString();
957
958 string script = isAllKey
959 ? $@"
960 let count = {storageObj}.length;
961 {storageObj}.clear();
962 return count;"
963 : $@"
964 if ({storageObj}.getItem('{EscapeJs(action.Key)}') !== null) {{
965 {storageObj}.removeItem('{EscapeJs(action.Key)}');
966 return 1;
967 }} else return 0;";
968
969 return webDriver.ExecuteJavaScript<int>(script);
970 }
971
972 private bool SetLocalOrSessionStorage(StorageAction action)
973 {
974 string storageObj = action.StorageType.ToString();
975 string script = $"{storageObj}.setItem('{EscapeJs(action.Key)}', '{EscapeJs(action.Data)}'); return true;";
976 return webDriver.ExecuteJavaScript<bool>(script);
977 }
978
979 // ──────────────────────────────────────────────────────────────
980 // CACHE STORAGE
981 private string GetCache(StorageAction action)
982 {
983 string expression;
984
985 if (string.IsNullOrEmpty(action.StoreName) && string.IsNullOrEmpty(action.Key))
986 {
987 // Case 1: No path, no key > Return array of cache names only (matches original JS extension)
988 expression = @"(async () => {
989 const cacheNames = await caches.keys();
990 return JSON.stringify(cacheNames);
991 })();";
992 }
993 else if (string.IsNullOrEmpty(action.Key))
994 {
995 // Case 2: Path only > List URLs inside the cache (no bodies)
996 expression = $@"(async () => {{
997 const cache = await caches.open('{EscapeJs(action.StoreName)}');
998 const requests = await cache.keys();
999 const urls = requests.map(req => req.url);
1000 return JSON.stringify(urls);
1001 }})();";
1002 }
1003 else
1004 {
1005 // Case 3: Path + Key > Return { url, body } or null (matches original JS)
1006 expression = $@"(async () => {{
1007 const cache = await caches.open('{EscapeJs(action.StoreName)}');
1008 const response = await cache.match('{EscapeJs(action.Key)}');
1009
1010 if (!response) return null;
1011
1012 let body = null;
1013 try {{
1014 body = await response.clone().text();
1015 }} catch (e) {{
1016 body = '[unreadable]';
1017 }}
1018
1019 return JSON.stringify({{
1020 url: response.url,
1021 body: body
1022 }});
1023 }})();";
1024 }
1025
1026 if (Enums.BrowserType.FireFox == browser.BrowserType)
1027 {
1028 string retVal = ExecuteFirefoxAsyncScript(expression);
1029 action.Data = retVal;
1030 return retVal;
1031 }
1032
1033 var parameters = new Dictionary<string, object>
1034 {
1035 { "expression", expression },
1036 { "awaitPromise", true }, // Critical for async IIFE
1037 { "returnByValue", true } // Return plain JSON string instead of RemoteObject
1038 };
1039
1040 try
1041 {
1042 dynamic cdpResult = ((ChromiumDriver)browserDriver).ExecuteCdpCommand("Runtime.evaluate", parameters);
1043
1044 string retVal = ExtractResultValue(cdpResult);
1045
1046 action.Data = retVal;
1047 return retVal;
1048 }
1049 catch (Exception ex)
1050 {
1051 // Log or handle as needed
1052 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Cache access failed", action, GPALObjectType.Other, ex);
1053 action.Data = null;
1054 return null;
1055 }
1056 }
1057
1061 private string ExtractResultValue(IDictionary<string, object> cdpResult)
1062 {
1063 if (cdpResult == null) return null;
1064
1065 if (cdpResult.TryGetValue("result", out var resultObj))
1066 {
1067 if (resultObj is IDictionary<string, object> resultDict)
1068 {
1069 if (resultDict.TryGetValue("value", out var value))
1070 {
1071 return value?.ToString(); // This is the JSON string we want
1072 }
1073 }
1074 }
1075
1076 // Fallback for some edge cases
1077 if (cdpResult.TryGetValue("value", out var directValue))
1078 {
1079 return directValue?.ToString();
1080 }
1081
1082 return null;
1083 }
1084
1085 private int DeleteCache(StorageAction action)
1086 {
1087 if (string.IsNullOrEmpty(action.Path))
1088 {
1089 return 0; // nothing to do
1090 }
1091
1092 bool deleteWholeCache = string.IsNullOrEmpty(action.Key);
1093
1094 string deleteCode = deleteWholeCache
1095 ? $"await caches.delete('{EscapeJs(action.Path)}');"
1096 : $"const cache = await caches.open('{EscapeJs(action.Path)}'); await cache.delete('{EscapeJs(action.Key)}');";
1097
1098 // - We check "caches" synchronously return 0 immediately if not supported
1099 // - We fire the async delete in the background (fire-and-forget)
1100 // - The *last expression* of the script is a plain number (1 or 0)
1101 string script = $@"if (!('caches' in window)) return 0;
1102 (async () => {{ {deleteCode} }})();
1103 return 1;";
1104
1105 return webDriver.ExecuteJavaScript<int>(script);
1106 }
1107
1108 // ──────────────────────────────────────────────────────────────
1109 // Helpers
1110 // ──────────────────────────────────────────────────────────────
1111
1112 private string ExecuteFirefoxAsyncScript(string asyncIife)
1113 {
1114 webDriver.Manage().Timeouts().AsynchronousJavaScript = TimeSpan.FromSeconds(30);
1115 string iife = asyncIife.TrimEnd(';', ' ', '\n', '\r', '\t');
1116 string wrapped = $@"var __cb = arguments[arguments.length - 1];
1117(async () => {{
1118 try {{
1119 const __r = await {iife};
1120 __cb(__r != null ? (typeof __r === 'string' ? __r : JSON.stringify(__r)) : null);
1121 }} catch(e) {{
1122 __cb(JSON.stringify({{error: true, message: e.message}}));
1123 }}
1124}})();";
1125 try
1126 {
1127 return ((IJavaScriptExecutor)webDriver).ExecuteAsyncScript(wrapped)?.ToString();
1128 }
1129 catch (Exception ex)
1130 {
1131 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Firefox async script failed", browser, GPALObjectType.Browser, ex);
1132 return null;
1133 }
1134 }
1135
1136 private string ExecuteAsyncJs<T>(string js, T fallback = default)
1137 {
1138 try
1139 {
1140 return browserDriver.ExecuteJavaScript<string>($"({js})") ?? (fallback?.ToString() ?? "");
1141 }
1142 catch
1143 {
1144 return fallback?.ToString() ?? "";
1145 }
1146 }
1147
1148 private bool IsErrorJson(dynamic s) => s.Contains("\"error\":true");
1149
1150 private string FailAndReturnNull(string msg, StorageAction a)
1151 {
1152 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, a, GPALObjectType.Other);
1153 return null;
1154 }
1155
1156 private int FailAndReturnZero(string msg, StorageAction a)
1157 {
1158 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, a, GPALObjectType.Other);
1159 return 0;
1160 }
1161
1162 private bool FailAndReturnFalse(string msg, StorageAction a)
1163 {
1164 GPAL.PublishSimpleEvent(GPALEventType.ERROR, msg, a, GPALObjectType.Other);
1165 return false;
1166 }
1167 }
1168}
1169
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:7022
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
bool IncludeProvenance
For wildcard storage gets returning multiple elements. If set to true, return domain,...
Definition GPALUrl.cs:339
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:2406