From e637107898569fd0325090ef5f28a0775cf21a62 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Fri, 12 Sep 2025 19:01:51 +0700 Subject: [PATCH 01/15] feat(testplane): save browser state --- src/browser/commands/index.ts | 1 + src/browser/commands/saveState.ts | 3 + .../commands/saveState/clearAllIndexedDB.ts | 24 ++++ .../commands/saveState/dumpIndexedDB.ts | 103 ++++++++++++++++++ src/browser/commands/saveState/index.ts | 61 +++++++++++ .../commands/saveState/restoreIndexedDB.ts | 67 ++++++++++++ 6 files changed, 259 insertions(+) create mode 100644 src/browser/commands/saveState.ts create mode 100644 src/browser/commands/saveState/clearAllIndexedDB.ts create mode 100644 src/browser/commands/saveState/dumpIndexedDB.ts create mode 100644 src/browser/commands/saveState/index.ts create mode 100644 src/browser/commands/saveState/restoreIndexedDB.ts diff --git a/src/browser/commands/index.ts b/src/browser/commands/index.ts index 3586f6c71..bfa25a77e 100644 --- a/src/browser/commands/index.ts +++ b/src/browser/commands/index.ts @@ -9,4 +9,5 @@ export const customCommandFileNames = [ "switchToRepl", "moveCursorTo", "captureDomSnapshot", + "saveState", ]; diff --git a/src/browser/commands/saveState.ts b/src/browser/commands/saveState.ts new file mode 100644 index 000000000..1d2bef02d --- /dev/null +++ b/src/browser/commands/saveState.ts @@ -0,0 +1,3 @@ +import saveState from "./saveState/index"; + +export default saveState; diff --git a/src/browser/commands/saveState/clearAllIndexedDB.ts b/src/browser/commands/saveState/clearAllIndexedDB.ts new file mode 100644 index 000000000..165905f79 --- /dev/null +++ b/src/browser/commands/saveState/clearAllIndexedDB.ts @@ -0,0 +1,24 @@ +export async function clearAllIndexedDB(): Promise { + if (!("databases" in indexedDB)) { + throw new Error("Your browser don't indexedDB.databases()"); + } + + const dbList = await indexedDB.databases(); + + await Promise.all( + dbList.map((dbInfo: { name?: string }): Promise => { + if (!dbInfo.name) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const deleteReq = indexedDB.deleteDatabase(dbInfo.name as string); + deleteReq.onsuccess = (): void => resolve(); + deleteReq.onerror = (): void => reject(deleteReq.error); + deleteReq.onblocked = (): void => { + resolve(); + }; + }); + }), + ); +} diff --git a/src/browser/commands/saveState/dumpIndexedDB.ts b/src/browser/commands/saveState/dumpIndexedDB.ts new file mode 100644 index 000000000..bf229230b --- /dev/null +++ b/src/browser/commands/saveState/dumpIndexedDB.ts @@ -0,0 +1,103 @@ +export interface DumpIndexDB { + version?: number; + stores: Record; +} + +export interface DumpStoreIndexDB { + keyPath: string | string[]; + autoIncrement: boolean; + indexes: { + name: string, + keyPath: string | string[]; + unique: boolean, + multiEntry: boolean, + }[] + records: { + key: unknown; + value: unknown; + }[] +} + +export async function dumpIndexedDB(): Promise> { + if (!("databases" in indexedDB)) { + throw new Error("Your browser don't indexedDB.databases()"); + } + + const dbList = await indexedDB.databases(); // список баз + const result: Record = {}; + + for (const dbInfo of dbList) { + const name = dbInfo.name; + const version = dbInfo.version; + if (!name) continue; + + const dbDump: DumpIndexDB = { version, stores: {} }; + + await new Promise((resolve, reject) => { + const openReq = indexedDB.open(name); + openReq.onsuccess = (): void => { + const db = openReq.result; + + let pending = db.objectStoreNames.length; + if (pending === 0) { + result[name] = dbDump; + db.close(); + resolve(); + } + + for (const storeName of db.objectStoreNames) { + const tx = db.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); + + const storeDump: DumpStoreIndexDB = { + keyPath: store.keyPath, + autoIncrement: store.autoIncrement, + indexes: [], + records: [], + }; + + // Save indexes + for (const idxName of store.indexNames) { + const index = store.index(idxName); + storeDump.indexes.push({ + name: index.name, + keyPath: index.keyPath, + unique: index.unique, + multiEntry: index.multiEntry, + }); + } + + + // Get keys and values + const getAllReq = store.getAll(); + const getAllKeysReq = store.getAllKeys(); + + getAllReq.onsuccess = (): void => { + getAllKeysReq.onsuccess = (): void => { + const values = getAllReq.result; + const keys = getAllKeysReq.result; + + storeDump.records = keys.map((k, i) => ({ + key: k, + value: values[i], + })); + + dbDump.stores[storeName] = storeDump; + + if (--pending === 0) { + result[name] = dbDump; + db.close(); + resolve(); + } + }; + getAllKeysReq.onerror = (): void => reject(getAllKeysReq.error); + }; + getAllReq.onerror = (): void => reject(getAllReq.error); + } + }; + openReq.onerror = (): void => reject(openReq.error); + }); + } + + return result; +} diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts new file mode 100644 index 000000000..7acf05577 --- /dev/null +++ b/src/browser/commands/saveState/index.ts @@ -0,0 +1,61 @@ +import type { Browser } from "../../types"; +import { dumpIndexedDB } from "./dumpIndexedDB"; +import fs from "fs-extra"; + +interface SaveStateOptions { + path?: string; + + cookies?: boolean; + localStorage?: boolean; + sessionStorage?: boolean; + indexDb?: boolean; +} + +export default (browser: Browser): void => { + const { publicAPI: session } = browser; + + session.addCommand( + "saveState", + async (options: SaveStateOptions) => { + const cookies = await session.getAllCookies(); + + // browser.getA + + const localStorage: unknown = await session.execute(() => ( + JSON.parse(JSON.stringify(localStorage)) + )); + const sessionStorage: unknown = await session.execute(() => ( + JSON.parse(JSON.stringify(sessionStorage)) + )); + const indexDB: unknown = await session.execute(dumpIndexedDB); + + const puppeteer = await session.getPuppeteer(); + const pages = await puppeteer.pages(); + + + // @ts-ignore + const ppp = await puppeteer.pages(); + + // browser.publicAPI.ori + + // const urls: Record; + // localStorage: Record; + // }> = {}; + + const data = { + cookies, + localStorage, + sessionStorage, + indexDB, + // pages: pages.map((page) => page.frames()), + frames: pages[0].frames().map((f) => f.url()), + ppp, + }; + + if (options && options.path) { + await fs.writeJson(options.path, data); + } + } + ); +}; diff --git a/src/browser/commands/saveState/restoreIndexedDB.ts b/src/browser/commands/saveState/restoreIndexedDB.ts new file mode 100644 index 000000000..7d0753795 --- /dev/null +++ b/src/browser/commands/saveState/restoreIndexedDB.ts @@ -0,0 +1,67 @@ +import { DumpIndexDB, DumpStoreIndexDB } from "./dumpIndexedDB"; + +import { clearAllIndexedDB } from "./clearAllIndexedDB"; + + +export async function restoreIndexedDB(dump: Record): Promise { + await clearAllIndexedDB(); + + for (const [dbName, dbData] of Object.entries(dump)) { + const version = dbData.version || 1; + const stores = dbData.stores || {}; + + await new Promise((resolve, reject) => { + const openReq = indexedDB.open(dbName, version); + + openReq.onupgradeneeded = (): void => { + const db = openReq.result; + + // Restore stores + for (const [storeName, storeInfo] of Object.entries(stores)) { + const { keyPath, autoIncrement, indexes } = storeInfo as DumpStoreIndexDB; + const objectStore = db.createObjectStore(storeName, { + keyPath: keyPath ?? undefined, + autoIncrement: !!autoIncrement, + }); + + for (const idx of indexes) { + objectStore.createIndex(idx.name, idx.keyPath, { + unique: idx.unique, + multiEntry: idx.multiEntry, + }); + } + } + }; + + openReq.onsuccess = (): void => { + const db = openReq.result; + const tx = db.transaction(Object.keys(stores), "readwrite"); + + for (const [storeName, storeInfo] of Object.entries(stores)) { + const { records } = storeInfo as DumpStoreIndexDB; + const store = tx.objectStore(storeName); + + for (const { key, value } of records) { + try { + if (store.keyPath) { + store.put(value); + } else { + store.put(value, key as unknown as string); + } + } catch (e) { + console.warn(`Insert error ${storeName}:`, e); + } + } + } + + tx.oncomplete = (): void => { + db.close(); + resolve(); + }; + tx.onerror = (): void => reject(tx.error); + }; + + openReq.onerror = (): void => reject(openReq.error); + }); + } +} From 0dd07f34d31d5c2903f461eb00d32e7ed902c80e Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Wed, 17 Sep 2025 18:56:11 +0700 Subject: [PATCH 02/15] feat(testplane): get cookies from all page requests --- package-lock.json | 11 +++++++ package.json | 1 + src/browser/commands/saveState/index.ts | 38 ++++++++++++++++++------- src/browser/existing-browser.ts | 30 +++++++++++++++++++ 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2e15c780..9b93a7126 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "png-validator": "1.1.0", "recast": "0.23.6", "resolve.exports": "2.0.2", + "set-cookie-parser": "2.7.1", "sizzle": "2.3.6", "socket.io": "4.7.5", "socket.io-client": "4.7.5", @@ -11778,6 +11779,11 @@ "randombytes": "^2.1.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -21806,6 +21812,11 @@ "randombytes": "^2.1.0" } }, + "set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, "setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", diff --git a/package.json b/package.json index e1344ec1e..d73a2cebd 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "png-validator": "1.1.0", "recast": "0.23.6", "resolve.exports": "2.0.2", + "set-cookie-parser": "2.7.1", "sizzle": "2.3.6", "socket.io": "4.7.5", "socket.io-client": "4.7.5", diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts index 7acf05577..a724744b7 100644 --- a/src/browser/commands/saveState/index.ts +++ b/src/browser/commands/saveState/index.ts @@ -18,8 +18,8 @@ export default (browser: Browser): void => { "saveState", async (options: SaveStateOptions) => { const cookies = await session.getAllCookies(); - - // browser.getA + // @ts-ignore + const requestsCookies = await session.getAllRequestsCookies(); const localStorage: unknown = await session.execute(() => ( JSON.parse(JSON.stringify(localStorage)) @@ -29,12 +29,27 @@ export default (browser: Browser): void => { )); const indexDB: unknown = await session.execute(dumpIndexedDB); - const puppeteer = await session.getPuppeteer(); - const pages = await puppeteer.pages(); + // const cookies = await session.send({ + // method: "storage.getCookies", + // // method: "Network.getAllCookies", + // params: { + // + // } + // }); + // const puppeteer = await session.getPuppeteer(); + // const client = await puppeteer.target().createCDPSession(); + // const cookies = await client.send("Network.getAllCookies"); + // const pages = await puppeteer.pages() - // @ts-ignore - const ppp = await puppeteer.pages(); + + // const pages = await puppeteer.pages(); + // const page = await puppeteer.target().page(); + // const cookies = page._client.send('Network.getAllCookies'); + // page?.mainFrame().origin(); + // const client = await puppeteer.target().page(); + + // const ppp = await puppeteer.pages(); // browser.publicAPI.ori @@ -44,17 +59,18 @@ export default (browser: Browser): void => { // }> = {}; const data = { - cookies, + cookies: [ + ...cookies, + '------------------------------------', + ...requestsCookies, + ], localStorage, sessionStorage, indexDB, - // pages: pages.map((page) => page.frames()), - frames: pages[0].frames().map((f) => f.url()), - ppp, }; if (options && options.path) { - await fs.writeJson(options.path, data); + await fs.writeJson(options.path, data, {spaces: 2}); } } ); diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index 6d6e932df..6ee757e00 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -1,5 +1,7 @@ import url from "url"; import _ from "lodash"; +// @ts-ignore +import { parse as parseCookiesString } from "set-cookie-parser"; import { attach, type AttachOptions, type ElementArray } from "@testplane/webdriverio"; import { sessionEnvironmentDetector } from "@testplane/wdio-utils"; import { Browser, BrowserOpts } from "./browser"; @@ -60,6 +62,7 @@ export class ExistingBrowser extends Browser { protected _meta: Record; protected _calibration?: CalibrationResult; protected _clientBridge?: ClientBridge; + private allCookies: Array> = []; constructor(config: Config, opts: BrowserOpts) { super(config, opts); @@ -94,6 +97,8 @@ export class ExistingBrowser extends Browser { await isolationPromise; + await this.startCollectCookies(); + this._callstackHistory?.clear(); try { @@ -111,6 +116,31 @@ export class ExistingBrowser extends Browser { return this; } + async startCollectCookies(): Promise { + if (!this._session) { + return; + } + + this.publicAPI.addCommand("getAllRequestsCookies", () => this.allCookies); + + const puppeteer = await this._session.getPuppeteer(); + const pages = await puppeteer.pages(); + + pages[0].on('response', async (res) => { + try { + const headers = res.headers(); + + if (headers['set-cookie']) { + const cookies = parseCookiesString(headers['set-cookie'], { map: false }); + + this.allCookies.push(...cookies); + } + } catch (err) { + console.error(err); + } + }); + } + markAsBroken(): void { if (this.state.isBroken) { return; From 47242599199a72181eb852c369a101b3d0f2d4a4 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Thu, 18 Sep 2025 15:38:46 +0700 Subject: [PATCH 03/15] fix(testplane): add local storage --- src/browser/commands/saveState/index.ts | 83 ++++++++++++++----------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts index a724744b7..959ce41fb 100644 --- a/src/browser/commands/saveState/index.ts +++ b/src/browser/commands/saveState/index.ts @@ -11,6 +11,34 @@ interface SaveStateOptions { indexDb?: boolean; } +const getLocalStorage = (): Record => { + const storage: Storage = window.localStorage; + const data: Record = {}; + + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + + if (key) { + data[key] = storage.getItem(key) as string; + } + } + return data; +} + +const getSessionStorage = (): Record => { + const storage: Storage = window.sessionStorage; + const data: Record = {}; + + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + + if (key) { + data[key] = storage.getItem(key) as string; + } + } + return data; +} + export default (browser: Browser): void => { const { publicAPI: session } = browser; @@ -21,42 +49,27 @@ export default (browser: Browser): void => { // @ts-ignore const requestsCookies = await session.getAllRequestsCookies(); - const localStorage: unknown = await session.execute(() => ( - JSON.parse(JSON.stringify(localStorage)) - )); - const sessionStorage: unknown = await session.execute(() => ( - JSON.parse(JSON.stringify(sessionStorage)) - )); - const indexDB: unknown = await session.execute(dumpIndexedDB); - - // const cookies = await session.send({ - // method: "storage.getCookies", - // // method: "Network.getAllCookies", - // params: { - // - // } - // }); - - // const puppeteer = await session.getPuppeteer(); - // const client = await puppeteer.target().createCDPSession(); - // const cookies = await client.send("Network.getAllCookies"); - // const pages = await puppeteer.pages() + const puppeteer = await session.getPuppeteer(); + const pages = await puppeteer.pages(); + const frames = pages[0].frames(); + const framesData: Record, + sessionStorage: Record, + indexDB: Record, + }> = {}; - // const pages = await puppeteer.pages(); - // const page = await puppeteer.target().page(); - // const cookies = page._client.send('Network.getAllCookies'); - // page?.mainFrame().origin(); - // const client = await puppeteer.target().page(); + for (const frame of frames) { + const localStorage: Record = await session.execute(getLocalStorage); + const sessionStorage: Record = await session.execute(getSessionStorage); + const indexDB: Record = await session.execute(dumpIndexedDB); - // const ppp = await puppeteer.pages(); - - // browser.publicAPI.ori - - // const urls: Record; - // localStorage: Record; - // }> = {}; + framesData[frame.url()] = { + localStorage, + sessionStorage, + indexDB, + }; + } const data = { cookies: [ @@ -64,9 +77,7 @@ export default (browser: Browser): void => { '------------------------------------', ...requestsCookies, ], - localStorage, - sessionStorage, - indexDB, + framesData, }; if (options && options.path) { From 7a3b5e88766e70283b43d14f676c4aadfa326688 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Thu, 18 Sep 2025 18:48:12 +0700 Subject: [PATCH 04/15] fix(testplane): done with save data and start restore data --- src/browser/commands/index.ts | 1 + src/browser/commands/restoreState.ts | 3 + .../clearAllIndexedDB.ts | 0 src/browser/commands/restoreState/index.ts | 20 +++ .../restoreIndexedDB.ts | 3 +- .../commands/saveState/dumpIndexedDB.ts | 156 +++++++++--------- src/browser/commands/saveState/dumpStorage.ts | 32 ++++ src/browser/commands/saveState/index.ts | 78 ++++----- src/browser/existing-browser.ts | 22 ++- 9 files changed, 183 insertions(+), 132 deletions(-) create mode 100644 src/browser/commands/restoreState.ts rename src/browser/commands/{saveState => restoreState}/clearAllIndexedDB.ts (100%) create mode 100644 src/browser/commands/restoreState/index.ts rename src/browser/commands/{saveState => restoreState}/restoreIndexedDB.ts (96%) create mode 100644 src/browser/commands/saveState/dumpStorage.ts diff --git a/src/browser/commands/index.ts b/src/browser/commands/index.ts index bfa25a77e..0b360ddd4 100644 --- a/src/browser/commands/index.ts +++ b/src/browser/commands/index.ts @@ -10,4 +10,5 @@ export const customCommandFileNames = [ "moveCursorTo", "captureDomSnapshot", "saveState", + "restoreState", ]; diff --git a/src/browser/commands/restoreState.ts b/src/browser/commands/restoreState.ts new file mode 100644 index 000000000..d8d9dda9d --- /dev/null +++ b/src/browser/commands/restoreState.ts @@ -0,0 +1,3 @@ +import restoreState from "./restoreState/index"; + +export default restoreState; diff --git a/src/browser/commands/saveState/clearAllIndexedDB.ts b/src/browser/commands/restoreState/clearAllIndexedDB.ts similarity index 100% rename from src/browser/commands/saveState/clearAllIndexedDB.ts rename to src/browser/commands/restoreState/clearAllIndexedDB.ts diff --git a/src/browser/commands/restoreState/index.ts b/src/browser/commands/restoreState/index.ts new file mode 100644 index 000000000..4feb15df4 --- /dev/null +++ b/src/browser/commands/restoreState/index.ts @@ -0,0 +1,20 @@ +// import fs from "fs-extra"; + +// import { restoreIndexedDB } from "./restoreIndexedDB"; + +import type { Browser } from "../../types"; + +// type RestoreStateOptions = { +// path: string, +// } + +export default (browser: Browser): void => { + const { publicAPI: session } = browser; + + session.addCommand( + "restoreState", + async (/* options: RestoreStateOptions */) => { + + } + ); +}; diff --git a/src/browser/commands/saveState/restoreIndexedDB.ts b/src/browser/commands/restoreState/restoreIndexedDB.ts similarity index 96% rename from src/browser/commands/saveState/restoreIndexedDB.ts rename to src/browser/commands/restoreState/restoreIndexedDB.ts index 7d0753795..fa6187fa0 100644 --- a/src/browser/commands/saveState/restoreIndexedDB.ts +++ b/src/browser/commands/restoreState/restoreIndexedDB.ts @@ -1,8 +1,7 @@ -import { DumpIndexDB, DumpStoreIndexDB } from "./dumpIndexedDB"; +import { DumpIndexDB, DumpStoreIndexDB } from "../saveState/dumpIndexedDB"; import { clearAllIndexedDB } from "./clearAllIndexedDB"; - export async function restoreIndexedDB(dump: Record): Promise { await clearAllIndexedDB(); diff --git a/src/browser/commands/saveState/dumpIndexedDB.ts b/src/browser/commands/saveState/dumpIndexedDB.ts index bf229230b..8a26abda5 100644 --- a/src/browser/commands/saveState/dumpIndexedDB.ts +++ b/src/browser/commands/saveState/dumpIndexedDB.ts @@ -18,86 +18,90 @@ export interface DumpStoreIndexDB { }[] } -export async function dumpIndexedDB(): Promise> { - if (!("databases" in indexedDB)) { - throw new Error("Your browser don't indexedDB.databases()"); - } - - const dbList = await indexedDB.databases(); // список баз - const result: Record = {}; - - for (const dbInfo of dbList) { - const name = dbInfo.name; - const version = dbInfo.version; - if (!name) continue; - - const dbDump: DumpIndexDB = { version, stores: {} }; - - await new Promise((resolve, reject) => { - const openReq = indexedDB.open(name); - openReq.onsuccess = (): void => { - const db = openReq.result; - - let pending = db.objectStoreNames.length; - if (pending === 0) { - result[name] = dbDump; - db.close(); - resolve(); - } - - for (const storeName of db.objectStoreNames) { - const tx = db.transaction(storeName, "readonly"); - const store = tx.objectStore(storeName); - - const storeDump: DumpStoreIndexDB = { - keyPath: store.keyPath, - autoIncrement: store.autoIncrement, - indexes: [], - records: [], - }; - - // Save indexes - for (const idxName of store.indexNames) { - const index = store.index(idxName); - storeDump.indexes.push({ - name: index.name, - keyPath: index.keyPath, - unique: index.unique, - multiEntry: index.multiEntry, - }); +export async function dumpIndexedDB(): Promise | undefined> { + try { + if (!("databases" in indexedDB)) { + throw new Error("Your browser don't indexedDB.databases()"); + } + + const dbList = await indexedDB.databases(); // список баз + const result: Record = {}; + + for (const dbInfo of dbList) { + const name = dbInfo.name; + const version = dbInfo.version; + if (!name) continue; + + const dbDump: DumpIndexDB = { version, stores: {} }; + + await new Promise((resolve, reject) => { + const openReq = indexedDB.open(name); + openReq.onsuccess = (): void => { + const db = openReq.result; + + let pending = db.objectStoreNames.length; + if (pending === 0) { + result[name] = dbDump; + db.close(); + resolve(); } + for (const storeName of db.objectStoreNames) { + const tx = db.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); - // Get keys and values - const getAllReq = store.getAll(); - const getAllKeysReq = store.getAllKeys(); - - getAllReq.onsuccess = (): void => { - getAllKeysReq.onsuccess = (): void => { - const values = getAllReq.result; - const keys = getAllKeysReq.result; - - storeDump.records = keys.map((k, i) => ({ - key: k, - value: values[i], - })); - - dbDump.stores[storeName] = storeDump; + const storeDump: DumpStoreIndexDB = { + keyPath: store.keyPath, + autoIncrement: store.autoIncrement, + indexes: [], + records: [], + }; - if (--pending === 0) { - result[name] = dbDump; - db.close(); - resolve(); - } + // Save indexes + for (const idxName of store.indexNames) { + const index = store.index(idxName); + storeDump.indexes.push({ + name: index.name, + keyPath: index.keyPath, + unique: index.unique, + multiEntry: index.multiEntry, + }); + } + + + // Get keys and values + const getAllReq = store.getAll(); + const getAllKeysReq = store.getAllKeys(); + + getAllReq.onsuccess = (): void => { + getAllKeysReq.onsuccess = (): void => { + const values = getAllReq.result; + const keys = getAllKeysReq.result; + + storeDump.records = keys.map((k, i) => ({ + key: k, + value: values[i], + })); + + dbDump.stores[storeName] = storeDump; + + if (--pending === 0) { + result[name] = dbDump; + db.close(); + resolve(); + } + }; + getAllKeysReq.onerror = (): void => reject(getAllKeysReq.error); }; - getAllKeysReq.onerror = (): void => reject(getAllKeysReq.error); - }; - getAllReq.onerror = (): void => reject(getAllReq.error); - } - }; - openReq.onerror = (): void => reject(openReq.error); - }); + getAllReq.onerror = (): void => reject(getAllReq.error); + } + }; + openReq.onerror = (): void => reject(openReq.error); + }); + } + + return Object.keys(result).length === 0 ? undefined : result; + } catch (error) { + return; } - - return result; } diff --git a/src/browser/commands/saveState/dumpStorage.ts b/src/browser/commands/saveState/dumpStorage.ts new file mode 100644 index 000000000..4b290cd67 --- /dev/null +++ b/src/browser/commands/saveState/dumpStorage.ts @@ -0,0 +1,32 @@ +export type StorageData = { + localStorage?: Record, + sessionStorage?: Record, +}; + +export const dumpStorage = (): StorageData => { + const getData = (storage: Storage): Record | undefined => { + const data: Record = {}; + + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + + if (key) { + data[key] = storage.getItem(key) as string; + } + } + + return Object.keys(data).length === 0 ? undefined : data; + }; + + try { + return { + localStorage: getData(window?.localStorage), + sessionStorage: getData(window?.sessionStorage), + }; + } catch (error) { + return { + localStorage: undefined, + sessionStorage: undefined, + }; + } +}; diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts index 959ce41fb..f3054ac81 100644 --- a/src/browser/commands/saveState/index.ts +++ b/src/browser/commands/saveState/index.ts @@ -1,43 +1,21 @@ -import type { Browser } from "../../types"; -import { dumpIndexedDB } from "./dumpIndexedDB"; import fs from "fs-extra"; -interface SaveStateOptions { - path?: string; - - cookies?: boolean; - localStorage?: boolean; - sessionStorage?: boolean; - indexDb?: boolean; -} - -const getLocalStorage = (): Record => { - const storage: Storage = window.localStorage; - const data: Record = {}; +import type { Browser } from "../../types"; +import { dumpIndexedDB } from "./dumpIndexedDB"; +import { dumpStorage, StorageData } from "./dumpStorage"; - for (let i = 0; i < storage.length; i++) { - const key = storage.key(i); +type SaveStateOptions = { + path?: string, - if (key) { - data[key] = storage.getItem(key) as string; - } - } - return data; + cookies?: boolean, + localStorage?: boolean, + sessionStorage?: boolean, + indexDb?: boolean, } -const getSessionStorage = (): Record => { - const storage: Storage = window.sessionStorage; - const data: Record = {}; - - for (let i = 0; i < storage.length; i++) { - const key = storage.key(i); - - if (key) { - data[key] = storage.getItem(key) as string; - } - } - return data; -} +type FrameData = StorageData & { + indexDB?: Record, +}; export default (browser: Browser): void => { const { publicAPI: session } = browser; @@ -53,28 +31,30 @@ export default (browser: Browser): void => { const pages = await puppeteer.pages(); const frames = pages[0].frames(); - const framesData: Record, - sessionStorage: Record, - indexDB: Record, - }> = {}; + const framesData: Record = {}; for (const frame of frames) { - const localStorage: Record = await session.execute(getLocalStorage); - const sessionStorage: Record = await session.execute(getSessionStorage); - const indexDB: Record = await session.execute(dumpIndexedDB); - - framesData[frame.url()] = { - localStorage, - sessionStorage, - indexDB, - }; + const origin = new URL(frame.url()).origin; + + if (origin === "null" || framesData[origin]) { + continue; + } + + const { localStorage, sessionStorage }: StorageData = await frame.evaluate(dumpStorage); + const indexDB: Record | undefined = await frame.evaluate(dumpIndexedDB); + + if (localStorage || sessionStorage || indexDB) { + framesData[origin] = { + localStorage, + sessionStorage, + indexDB, + }; + } } const data = { cookies: [ ...cookies, - '------------------------------------', ...requestsCookies, ], framesData, diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index 6ee757e00..7579360ab 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -62,7 +62,7 @@ export class ExistingBrowser extends Browser { protected _meta: Record; protected _calibration?: CalibrationResult; protected _clientBridge?: ClientBridge; - private allCookies: Array> = []; + private allCookies: Map> = new Map(); constructor(config: Config, opts: BrowserOpts) { super(config, opts); @@ -121,7 +121,7 @@ export class ExistingBrowser extends Browser { return; } - this.publicAPI.addCommand("getAllRequestsCookies", () => this.allCookies); + this.publicAPI.addCommand("getAllRequestsCookies", () => this.allCookies.values()); const puppeteer = await this._session.getPuppeteer(); const pages = await puppeteer.pages(); @@ -131,9 +131,21 @@ export class ExistingBrowser extends Browser { const headers = res.headers(); if (headers['set-cookie']) { - const cookies = parseCookiesString(headers['set-cookie'], { map: false }); - - this.allCookies.push(...cookies); + parseCookiesString(headers['set-cookie'], { map: false }).forEach((cookie: Record) => { + const index = [ + cookie.name, + cookie.domain, + cookie.path, + ].join('-'); + + this.allCookies.set( + index, + { + ...cookie, + domain: cookie.domain ?? new URL(res.url()).hostname, + } + ) + }) } } catch (err) { console.error(err); From 518cd1cb846f8f78391cdbd37f8871ef4ea6ecde Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Fri, 19 Sep 2025 20:57:45 +0700 Subject: [PATCH 05/15] fix(testplane): start restore data --- .../restoreState/clearAllIndexedDB.ts | 42 +++++---- src/browser/commands/restoreState/index.ts | 61 ++++++++++-- .../commands/restoreState/restoreIndexedDB.ts | 94 +++++++++---------- .../commands/restoreState/restoreStorage.ts | 11 +++ 4 files changed, 136 insertions(+), 72 deletions(-) create mode 100644 src/browser/commands/restoreState/restoreStorage.ts diff --git a/src/browser/commands/restoreState/clearAllIndexedDB.ts b/src/browser/commands/restoreState/clearAllIndexedDB.ts index 165905f79..32f1511a0 100644 --- a/src/browser/commands/restoreState/clearAllIndexedDB.ts +++ b/src/browser/commands/restoreState/clearAllIndexedDB.ts @@ -1,24 +1,28 @@ export async function clearAllIndexedDB(): Promise { - if (!("databases" in indexedDB)) { - throw new Error("Your browser don't indexedDB.databases()"); - } + try { + if (!("databases" in indexedDB)) { + throw new Error("Your browser don't indexedDB.databases()"); + } - const dbList = await indexedDB.databases(); + const dbList = await indexedDB.databases(); - await Promise.all( - dbList.map((dbInfo: { name?: string }): Promise => { - if (!dbInfo.name) { - return Promise.resolve(); - } + await Promise.all( + dbList.map((dbInfo: { name?: string }): Promise => { + if (!dbInfo.name) { + return Promise.resolve(); + } - return new Promise((resolve, reject) => { - const deleteReq = indexedDB.deleteDatabase(dbInfo.name as string); - deleteReq.onsuccess = (): void => resolve(); - deleteReq.onerror = (): void => reject(deleteReq.error); - deleteReq.onblocked = (): void => { - resolve(); - }; - }); - }), - ); + return new Promise((resolve, reject) => { + const deleteReq = indexedDB.deleteDatabase(dbInfo.name as string); + deleteReq.onsuccess = (): void => resolve(); + deleteReq.onerror = (): void => reject(deleteReq.error); + deleteReq.onblocked = (): void => { + resolve(); + }; + }); + }), + ); + } catch (error) { + console.error(error); + } } diff --git a/src/browser/commands/restoreState/index.ts b/src/browser/commands/restoreState/index.ts index 4feb15df4..ef1c204bc 100644 --- a/src/browser/commands/restoreState/index.ts +++ b/src/browser/commands/restoreState/index.ts @@ -1,20 +1,69 @@ -// import fs from "fs-extra"; +import fs from "fs-extra"; -// import { restoreIndexedDB } from "./restoreIndexedDB"; +// import { clearAllIndexedDB } from "./clearAllIndexedDB"; +import { restoreIndexedDB } from "./restoreIndexedDB"; +import { restoreStorage } from "./restoreStorage"; +import * as logger from "../../../utils/logger"; import type { Browser } from "../../types"; -// type RestoreStateOptions = { -// path: string, -// } +type RestoreStateOptions = { + path: string, +} export default (browser: Browser): void => { const { publicAPI: session } = browser; session.addCommand( "restoreState", - async (/* options: RestoreStateOptions */) => { + async (options: RestoreStateOptions) => { + const restoreState = await fs.readJson(options.path); + const puppeteer = await session.getPuppeteer(); + const pages = await puppeteer.pages(); + const frames = pages[0].frames(); + + for (const frame of frames) { + const origin = new URL(frame.url()).origin; + + if (origin === "null" || !restoreState.framesData[origin]) { + continue; + } + + logger.log("origin", origin); + + const frameData = restoreState.framesData[origin]; + + if (frameData.localStorage) { + logger.log("restoreState localStorage"); + + await frame.evaluate( + restoreStorage, + frameData.localStorage as Record, + "localStorage" as const + ); + } + + if (frameData.sessionStorage) { + logger.log("restoreState sessionStorage"); + + await frame.evaluate( + restoreStorage, + frameData.sessionStorage as Record, + "sessionStorage" as const + ); + } + + if (frameData.indexDB) { + logger.log("clear indexDB"); + + await frame.evaluate(clearAllIndexedDB); + logger.log("restoreState indexDB"); + await frame.evaluate(restoreIndexedDB, frameData.indexDB); + } + + logger.log("done with ", origin); + } } ); }; diff --git a/src/browser/commands/restoreState/restoreIndexedDB.ts b/src/browser/commands/restoreState/restoreIndexedDB.ts index fa6187fa0..7d725b130 100644 --- a/src/browser/commands/restoreState/restoreIndexedDB.ts +++ b/src/browser/commands/restoreState/restoreIndexedDB.ts @@ -1,66 +1,66 @@ import { DumpIndexDB, DumpStoreIndexDB } from "../saveState/dumpIndexedDB"; -import { clearAllIndexedDB } from "./clearAllIndexedDB"; - export async function restoreIndexedDB(dump: Record): Promise { - await clearAllIndexedDB(); - - for (const [dbName, dbData] of Object.entries(dump)) { - const version = dbData.version || 1; - const stores = dbData.stores || {}; - - await new Promise((resolve, reject) => { - const openReq = indexedDB.open(dbName, version); + try { + for (const [dbName, dbData] of Object.entries(dump)) { + const version = dbData.version || 1; + const stores = dbData.stores || {}; - openReq.onupgradeneeded = (): void => { - const db = openReq.result; + await new Promise((resolve, reject) => { + const openReq = indexedDB.open(dbName, version); - // Restore stores - for (const [storeName, storeInfo] of Object.entries(stores)) { - const { keyPath, autoIncrement, indexes } = storeInfo as DumpStoreIndexDB; - const objectStore = db.createObjectStore(storeName, { - keyPath: keyPath ?? undefined, - autoIncrement: !!autoIncrement, - }); + openReq.onupgradeneeded = (): void => { + const db = openReq.result; - for (const idx of indexes) { - objectStore.createIndex(idx.name, idx.keyPath, { - unique: idx.unique, - multiEntry: idx.multiEntry, + // Restore stores + for (const [storeName, storeInfo] of Object.entries(stores)) { + const { keyPath, autoIncrement, indexes } = storeInfo as DumpStoreIndexDB; + const objectStore = db.createObjectStore(storeName, { + keyPath: keyPath ?? undefined, + autoIncrement: !!autoIncrement, }); + + for (const idx of indexes) { + objectStore.createIndex(idx.name, idx.keyPath, { + unique: idx.unique, + multiEntry: idx.multiEntry, + }); + } } - } - }; + }; - openReq.onsuccess = (): void => { - const db = openReq.result; - const tx = db.transaction(Object.keys(stores), "readwrite"); + openReq.onsuccess = (): void => { + const db = openReq.result; + const tx = db.transaction(Object.keys(stores), "readwrite"); - for (const [storeName, storeInfo] of Object.entries(stores)) { - const { records } = storeInfo as DumpStoreIndexDB; - const store = tx.objectStore(storeName); + for (const [storeName, storeInfo] of Object.entries(stores)) { + const { records } = storeInfo as DumpStoreIndexDB; + const store = tx.objectStore(storeName); - for (const { key, value } of records) { - try { - if (store.keyPath) { - store.put(value); - } else { - store.put(value, key as unknown as string); + for (const { key, value } of records) { + try { + if (store.keyPath) { + store.put(value); + } else { + store.put(value, key as unknown as string); + } + } catch (e) { + console.warn(`Insert error ${storeName}:`, e); } - } catch (e) { - console.warn(`Insert error ${storeName}:`, e); } } - } - tx.oncomplete = (): void => { - db.close(); - resolve(); + tx.oncomplete = (): void => { + db.close(); + resolve(); + }; + tx.onerror = (): void => reject(tx.error); }; - tx.onerror = (): void => reject(tx.error); - }; - openReq.onerror = (): void => reject(openReq.error); - }); + openReq.onerror = (): void => reject(openReq.error); + }); + } + } catch (error) { + console.error(error); } } diff --git a/src/browser/commands/restoreState/restoreStorage.ts b/src/browser/commands/restoreState/restoreStorage.ts new file mode 100644 index 000000000..e38e2b7f2 --- /dev/null +++ b/src/browser/commands/restoreState/restoreStorage.ts @@ -0,0 +1,11 @@ +export const restoreStorage = ( + data: Record, + type: "localStorage" | "sessionStorage" +): void => { + const storage = window[type]; + storage.clear(); + + Object.keys(data).forEach((key) => { + storage.setItem(key, data[key]); + }) +}; From 79d35e6aeeea4b8fba96e576a615d9780c6711b5 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Fri, 19 Sep 2025 21:05:57 +0700 Subject: [PATCH 06/15] fix(testplane): start restore data --- src/browser/commands/restoreState/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/commands/restoreState/index.ts b/src/browser/commands/restoreState/index.ts index ef1c204bc..5fa43af6a 100644 --- a/src/browser/commands/restoreState/index.ts +++ b/src/browser/commands/restoreState/index.ts @@ -1,6 +1,6 @@ import fs from "fs-extra"; -// import { clearAllIndexedDB } from "./clearAllIndexedDB"; +import { clearAllIndexedDB } from "./clearAllIndexedDB"; import { restoreIndexedDB } from "./restoreIndexedDB"; import { restoreStorage } from "./restoreStorage"; import * as logger from "../../../utils/logger"; From 797b9d78592ba1da9b8b5bc3166ddae82c3a3e01 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Mon, 22 Sep 2025 15:04:19 +0700 Subject: [PATCH 07/15] fix(testplane): restore all data except indexDB --- .../restoreState/clearAllIndexedDB.ts | 9 +- src/browser/commands/restoreState/index.ts | 126 ++++++++++++------ .../commands/restoreState/restoreIndexedDB.ts | 17 +-- .../commands/restoreState/restoreStorage.ts | 9 +- src/browser/commands/saveState.ts | 1 + .../commands/saveState/dumpIndexedDB.ts | 11 +- src/browser/commands/saveState/dumpStorage.ts | 4 +- src/browser/commands/saveState/index.ts | 104 +++++++++------ src/browser/existing-browser.ts | 45 ++++--- src/browser/types.ts | 6 + 10 files changed, 201 insertions(+), 131 deletions(-) diff --git a/src/browser/commands/restoreState/clearAllIndexedDB.ts b/src/browser/commands/restoreState/clearAllIndexedDB.ts index 32f1511a0..4cb48fffb 100644 --- a/src/browser/commands/restoreState/clearAllIndexedDB.ts +++ b/src/browser/commands/restoreState/clearAllIndexedDB.ts @@ -14,11 +14,10 @@ export async function clearAllIndexedDB(): Promise { return new Promise((resolve, reject) => { const deleteReq = indexedDB.deleteDatabase(dbInfo.name as string); - deleteReq.onsuccess = (): void => resolve(); - deleteReq.onerror = (): void => reject(deleteReq.error); - deleteReq.onblocked = (): void => { - resolve(); - }; + + deleteReq.addEventListener("success", () => resolve()); + deleteReq.addEventListener("error", () => reject(deleteReq.error)); + deleteReq.addEventListener("blocked", () => resolve()); }); }), ); diff --git a/src/browser/commands/restoreState/index.ts b/src/browser/commands/restoreState/index.ts index 5fa43af6a..2cb145f6b 100644 --- a/src/browser/commands/restoreState/index.ts +++ b/src/browser/commands/restoreState/index.ts @@ -3,67 +3,105 @@ import fs from "fs-extra"; import { clearAllIndexedDB } from "./clearAllIndexedDB"; import { restoreIndexedDB } from "./restoreIndexedDB"; import { restoreStorage } from "./restoreStorage"; -import * as logger from "../../../utils/logger"; import type { Browser } from "../../types"; +import { defaultOptions, SaveStateOptions } from "../saveState"; +import { Protocol } from "devtools-protocol"; +import { DumpIndexDB } from "../saveState/dumpIndexedDB"; + +type RestoreStateOptions = SaveStateOptions; + +const normalizeCookies = (cookies: Array): Array => + cookies + .filter(c => c.name && c.value && c.domain) + .map(c => { + const cookie: Partial = { + name: c.name, + value: c.value, + domain: c.domain, + path: c.path || "/", + }; + + if (c.expires) { + cookie.expires = + typeof c.expires === "string" + ? Math.floor(new Date(c.expires).getTime() / 1000) + : Math.floor(c.expires as number); + } -type RestoreStateOptions = { - path: string, -} + if (c.secure !== undefined) cookie.secure = c.secure; + if (c.httpOnly !== undefined) cookie.httpOnly = c.httpOnly; + if (c.sameSite) cookie.sameSite = c.sameSite; -export default (browser: Browser): void => { - const { publicAPI: session } = browser; + if (!c.domain?.startsWith(".")) { + cookie.url = `https://${c.domain}`; + } else { + cookie.url = `https://${c.domain.replace(/^\./, "")}`; + } + + return cookie as Protocol.Network.CookieParam; + }); + +type FrameData = { + localStorage: Record; + sessionStorage: Record; + indexDB: Record; +}; - session.addCommand( - "restoreState", - async (options: RestoreStateOptions) => { - const restoreState = await fs.readJson(options.path); +type RestoreState = { + cookies: Array; + framesData: Record; +}; - const puppeteer = await session.getPuppeteer(); - const pages = await puppeteer.pages(); - const frames = pages[0].frames(); +export default (browser: Browser): void => { + const { publicAPI: session } = browser; - for (const frame of frames) { - const origin = new URL(frame.url()).origin; + session.addCommand("restoreState", async (_options: RestoreStateOptions) => { + const options = { ...defaultOptions, ..._options }; - if (origin === "null" || !restoreState.framesData[origin]) { - continue; - } + const restoreState: RestoreState = await fs.readJson(options.path); - logger.log("origin", origin); + const puppeteer = await session.getPuppeteer(); + const pages = await puppeteer.pages(); + const page = pages[0]; + const frames = page.frames(); - const frameData = restoreState.framesData[origin]; + if (restoreState.cookies && options.cookies) { + const normalized = normalizeCookies(restoreState.cookies); - if (frameData.localStorage) { - logger.log("restoreState localStorage"); + await page.setCookie(...normalized); + } - await frame.evaluate( - restoreStorage, - frameData.localStorage as Record, - "localStorage" as const - ); - } + for (const frame of frames) { + const origin = new URL(frame.url()).origin; - if (frameData.sessionStorage) { - logger.log("restoreState sessionStorage"); + if (origin === "null" || !restoreState.framesData[origin]) { + continue; + } - await frame.evaluate( - restoreStorage, - frameData.sessionStorage as Record, - "sessionStorage" as const - ); - } + const frameData = restoreState.framesData[origin]; - if (frameData.indexDB) { - logger.log("clear indexDB"); + if (frameData.localStorage && options.localStorage) { + await frame.evaluate( + restoreStorage, + frameData.localStorage as Record, + "localStorage" as const, + ); + } - await frame.evaluate(clearAllIndexedDB); - logger.log("restoreState indexDB"); - await frame.evaluate(restoreIndexedDB, frameData.indexDB); - } + if (frameData.sessionStorage && options.sessionStorage) { + await frame.evaluate( + restoreStorage, + frameData.sessionStorage as Record, + "sessionStorage" as const, + ); + } - logger.log("done with ", origin); + if (frameData.indexDB) { + // @todo: Doesn't work now + await frame.evaluate(clearAllIndexedDB); + await frame.evaluate(restoreIndexedDB, frameData.indexDB); } } - ); + }); }; diff --git a/src/browser/commands/restoreState/restoreIndexedDB.ts b/src/browser/commands/restoreState/restoreIndexedDB.ts index 7d725b130..4170163eb 100644 --- a/src/browser/commands/restoreState/restoreIndexedDB.ts +++ b/src/browser/commands/restoreState/restoreIndexedDB.ts @@ -9,7 +9,7 @@ export async function restoreIndexedDB(dump: Record): Promi await new Promise((resolve, reject) => { const openReq = indexedDB.open(dbName, version); - openReq.onupgradeneeded = (): void => { + openReq.addEventListener("upgradeneeded", (): void => { const db = openReq.result; // Restore stores @@ -27,9 +27,9 @@ export async function restoreIndexedDB(dump: Record): Promi }); } } - }; + }); - openReq.onsuccess = (): void => { + openReq.addEventListener("success", (): void => { const db = openReq.result; const tx = db.transaction(Object.keys(stores), "readwrite"); @@ -50,14 +50,15 @@ export async function restoreIndexedDB(dump: Record): Promi } } - tx.oncomplete = (): void => { + tx.addEventListener("success", (): void => { db.close(); resolve(); - }; - tx.onerror = (): void => reject(tx.error); - }; + }); - openReq.onerror = (): void => reject(openReq.error); + tx.addEventListener("error", (): void => reject(tx.error)); + }); + + openReq.addEventListener("error", (): void => reject(openReq.error)); }); } } catch (error) { diff --git a/src/browser/commands/restoreState/restoreStorage.ts b/src/browser/commands/restoreState/restoreStorage.ts index e38e2b7f2..23e3f736e 100644 --- a/src/browser/commands/restoreState/restoreStorage.ts +++ b/src/browser/commands/restoreState/restoreStorage.ts @@ -1,11 +1,8 @@ -export const restoreStorage = ( - data: Record, - type: "localStorage" | "sessionStorage" -): void => { +export const restoreStorage = (data: Record, type: "localStorage" | "sessionStorage"): void => { const storage = window[type]; storage.clear(); - Object.keys(data).forEach((key) => { + Object.keys(data).forEach(key => { storage.setItem(key, data[key]); - }) + }); }; diff --git a/src/browser/commands/saveState.ts b/src/browser/commands/saveState.ts index 1d2bef02d..1b8d2c3e7 100644 --- a/src/browser/commands/saveState.ts +++ b/src/browser/commands/saveState.ts @@ -1,3 +1,4 @@ import saveState from "./saveState/index"; export default saveState; +export * from "./saveState/index"; diff --git a/src/browser/commands/saveState/dumpIndexedDB.ts b/src/browser/commands/saveState/dumpIndexedDB.ts index 8a26abda5..419e26ffd 100644 --- a/src/browser/commands/saveState/dumpIndexedDB.ts +++ b/src/browser/commands/saveState/dumpIndexedDB.ts @@ -7,15 +7,15 @@ export interface DumpStoreIndexDB { keyPath: string | string[]; autoIncrement: boolean; indexes: { - name: string, + name: string; keyPath: string | string[]; - unique: boolean, - multiEntry: boolean, - }[] + unique: boolean; + multiEntry: boolean; + }[]; records: { key: unknown; value: unknown; - }[] + }[]; } export async function dumpIndexedDB(): Promise | undefined> { @@ -68,7 +68,6 @@ export async function dumpIndexedDB(): Promise | undefin }); } - // Get keys and values const getAllReq = store.getAll(); const getAllKeysReq = store.getAllKeys(); diff --git a/src/browser/commands/saveState/dumpStorage.ts b/src/browser/commands/saveState/dumpStorage.ts index 4b290cd67..04e7e38a7 100644 --- a/src/browser/commands/saveState/dumpStorage.ts +++ b/src/browser/commands/saveState/dumpStorage.ts @@ -1,6 +1,6 @@ export type StorageData = { - localStorage?: Record, - sessionStorage?: Record, + localStorage?: Record; + sessionStorage?: Record; }; export const dumpStorage = (): StorageData => { diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts index f3054ac81..b28073613 100644 --- a/src/browser/commands/saveState/index.ts +++ b/src/browser/commands/saveState/index.ts @@ -3,66 +3,86 @@ import fs from "fs-extra"; import type { Browser } from "../../types"; import { dumpIndexedDB } from "./dumpIndexedDB"; import { dumpStorage, StorageData } from "./dumpStorage"; +import { Protocol } from "devtools-protocol"; -type SaveStateOptions = { - path?: string, +export type SaveStateOptions = { + path: string; - cookies?: boolean, - localStorage?: boolean, - sessionStorage?: boolean, - indexDb?: boolean, -} + cookies?: boolean; + localStorage?: boolean; + sessionStorage?: boolean; + indexDB?: boolean; +}; type FrameData = StorageData & { - indexDB?: Record, + indexDB?: Record; +}; + +export type SaveStateData = { + cookies?: Array; + framesData?: Record; +}; + +export const defaultOptions = { + cookies: true, + localStorage: true, + sessionStorage: true, + indexDB: false, }; export default (browser: Browser): void => { const { publicAPI: session } = browser; - session.addCommand( - "saveState", - async (options: SaveStateOptions) => { - const cookies = await session.getAllCookies(); - // @ts-ignore - const requestsCookies = await session.getAllRequestsCookies(); + session.addCommand("saveState", async (_options: SaveStateOptions) => { + const options = { ...defaultOptions, ..._options }; + + const requestsCookies = await session.getAllRequestsCookies(); + + const puppeteer = await session.getPuppeteer(); + const pages = await puppeteer.pages(); + const frames = pages[0].frames(); + + const framesData: Record = {}; - const puppeteer = await session.getPuppeteer(); - const pages = await puppeteer.pages(); - const frames = pages[0].frames(); + for (const frame of frames) { + const origin = new URL(frame.url()).origin; - const framesData: Record = {}; + if (origin === "null" || framesData[origin]) { + continue; + } - for (const frame of frames) { - const origin = new URL(frame.url()).origin; + const { localStorage, sessionStorage }: StorageData = await frame.evaluate(dumpStorage); + const indexDB: Record | undefined = await frame.evaluate(dumpIndexedDB); - if (origin === "null" || framesData[origin]) { - continue; - } + const frameData: FrameData = {}; - const { localStorage, sessionStorage }: StorageData = await frame.evaluate(dumpStorage); - const indexDB: Record | undefined = await frame.evaluate(dumpIndexedDB); + if (localStorage && options.localStorage) { + frameData.localStorage = localStorage; + } - if (localStorage || sessionStorage || indexDB) { - framesData[origin] = { - localStorage, - sessionStorage, - indexDB, - }; - } + if (sessionStorage && options.sessionStorage) { + frameData.sessionStorage = sessionStorage; } - const data = { - cookies: [ - ...cookies, - ...requestsCookies, - ], - framesData, - }; + if (indexDB && options.indexDB) { + frameData.indexDB = indexDB; + } - if (options && options.path) { - await fs.writeJson(options.path, data, {spaces: 2}); + if (frameData.localStorage || frameData.sessionStorage || frameData.indexDB) { + framesData[origin] = frameData; } } - ); + + const data: SaveStateData = { + framesData, + }; + + if (options.cookies) { + data.cookies = requestsCookies; + } + + if (options && options.path) { + await fs.writeJson(options.path, data, { spaces: 2 }); + } + }); }; diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index 7579360ab..1518a245c 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -1,6 +1,6 @@ import url from "url"; import _ from "lodash"; -// @ts-ignore +// @ts-expect-error no typings for "set-cookie-parser" import { parse as parseCookiesString } from "set-cookie-parser"; import { attach, type AttachOptions, type ElementArray } from "@testplane/webdriverio"; import { sessionEnvironmentDetector } from "@testplane/wdio-utils"; @@ -20,6 +20,7 @@ import type { CalibrationResult, Calibrator } from "./calibrator"; import { NEW_ISSUE_LINK } from "../constants/help"; import { runWithoutHistory } from "./history"; import type { SessionOptions } from "./types"; +import { Protocol } from "devtools-protocol"; const OPTIONAL_SESSION_OPTS = ["transformRequest", "transformResponse"]; @@ -62,7 +63,7 @@ export class ExistingBrowser extends Browser { protected _meta: Record; protected _calibration?: CalibrationResult; protected _clientBridge?: ClientBridge; - private allCookies: Map> = new Map(); + private allCookies: Map = new Map(); constructor(config: Config, opts: BrowserOpts) { super(config, opts); @@ -121,31 +122,39 @@ export class ExistingBrowser extends Browser { return; } - this.publicAPI.addCommand("getAllRequestsCookies", () => this.allCookies.values()); + this.allCookies = new Map(); + + this.publicAPI.addCommand("getAllRequestsCookies", async () => { + if (this._session) { + const cookies = await this._session.getAllCookies(); + cookies.forEach(cookie => { + const index = [cookie.name, cookie.domain, cookie.path].join("-"); + + this.allCookies.set(index, cookie as Protocol.Network.CookieParam); + }); + } + + return [...this.allCookies.values()]; + }); const puppeteer = await this._session.getPuppeteer(); const pages = await puppeteer.pages(); - pages[0].on('response', async (res) => { + pages[0].on("response", async res => { try { const headers = res.headers(); - if (headers['set-cookie']) { - parseCookiesString(headers['set-cookie'], { map: false }).forEach((cookie: Record) => { - const index = [ - cookie.name, - cookie.domain, - cookie.path, - ].join('-'); - - this.allCookies.set( - index, - { + if (headers["set-cookie"]) { + parseCookiesString(headers["set-cookie"], { map: false }).forEach( + (cookie: Record) => { + const index = [cookie.name, cookie.domain, cookie.path].join("-"); + + this.allCookies.set(index, { ...cookie, domain: cookie.domain ?? new URL(res.url()).hostname, - } - ) - }) + } as Protocol.Network.CookieParam); + }, + ); } } catch (err) { console.error(err); diff --git a/src/browser/types.ts b/src/browser/types.ts index a3283ac15..f72dc5a9d 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -9,6 +9,8 @@ import type { Callstack } from "./history/callstack"; import type { Test, Hook } from "../test-reader/test-object"; import type { CaptureSnapshotOptions, CaptureSnapshotResult } from "./commands/captureDomSnapshot"; import type { Options } from "@testplane/wdio-types"; +import { SaveStateOptions } from "./commands/saveState"; +import { Protocol } from "devtools-protocol"; export const BrowserName = { CHROME: "chrome" as PuppeteerBrowser.CHROME, @@ -75,6 +77,10 @@ declare global { getConfig(this: WebdriverIO.Browser): Promise; + getAllRequestsCookies(): Promise>; + saveState(options: SaveStateOptions): Promise; + restoreState(options: SaveStateOptions): Promise; + overwriteCommand( name: CommandName, func: OverwriteCommandFn, From c28f1e4fa13e4dbfb5073e56624575358c5bf32b Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Tue, 23 Sep 2025 00:52:08 +0700 Subject: [PATCH 08/15] fix(testplane): webdriver save state done --- src/browser/commands/saveState/index.ts | 129 ++++++++++++++++++------ 1 file changed, 99 insertions(+), 30 deletions(-) diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts index b28073613..ea21b410c 100644 --- a/src/browser/commands/saveState/index.ts +++ b/src/browser/commands/saveState/index.ts @@ -4,6 +4,7 @@ import type { Browser } from "../../types"; import { dumpIndexedDB } from "./dumpIndexedDB"; import { dumpStorage, StorageData } from "./dumpStorage"; import { Protocol } from "devtools-protocol"; +import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../../../constants/config"; export type SaveStateOptions = { path: string; @@ -36,49 +37,117 @@ export default (browser: Browser): void => { session.addCommand("saveState", async (_options: SaveStateOptions) => { const options = { ...defaultOptions, ..._options }; - const requestsCookies = await session.getAllRequestsCookies(); + const data: SaveStateData = {}; - const puppeteer = await session.getPuppeteer(); - const pages = await puppeteer.pages(); - const frames = pages[0].frames(); + switch (browser.config.automationProtocol) { + case WEBDRIVER_PROTOCOL: { + if (options.cookies) { + const storageCookies = await session.storageGetCookies({}); - const framesData: Record = {}; + data.cookies = storageCookies.cookies.map(cookie => ({ + ...cookie, + value: cookie.value.value, + sameSite: cookie.sameSite.toLowerCase() as Protocol.Network.CookieSameSite, + })); + } - for (const frame of frames) { - const origin = new URL(frame.url()).origin; + await session.switchToParentFrame(); - if (origin === "null" || framesData[origin]) { - continue; - } + const frames = await session.execute(() => + Array.from(document.getElementsByTagName("iframe")) + .map(el => el.getAttribute("src") as string) + .filter(src => src !== null && src !== "about:blank"), + ); - const { localStorage, sessionStorage }: StorageData = await frame.evaluate(dumpStorage); - const indexDB: Record | undefined = await frame.evaluate(dumpIndexedDB); + const framesData: Record = {}; - const frameData: FrameData = {}; + for (let i = -1; i < frames.length; i++) { + await session.switchToParentFrame(); - if (localStorage && options.localStorage) { - frameData.localStorage = localStorage; - } + if (i > -1) { + await session.switchFrame(frames[i]); + } - if (sessionStorage && options.sessionStorage) { - frameData.sessionStorage = sessionStorage; - } + const origin = await session.execute(() => window.location.origin); - if (indexDB && options.indexDB) { - frameData.indexDB = indexDB; - } + if (!origin || origin === "null" || framesData[origin]) { + continue; + } + + const { localStorage, sessionStorage } = await session.execute(dumpStorage); + + const frameData: FrameData = {}; + + if (localStorage && options.localStorage) { + frameData.localStorage = localStorage; + } + + if (sessionStorage && options.sessionStorage) { + frameData.sessionStorage = sessionStorage; + } + + if (options.indexDB) { + const indexDB: Record | undefined = await session.execute(dumpIndexedDB); + + if (indexDB) { + frameData.indexDB = indexDB; + } + } + + if (frameData.localStorage || frameData.sessionStorage || frameData.indexDB) { + framesData[origin] = frameData; + } + } + + await session.switchToParentFrame(); - if (frameData.localStorage || frameData.sessionStorage || frameData.indexDB) { - framesData[origin] = frameData; + data.framesData = framesData; + break; } - } + case DEVTOOLS_PROTOCOL: { + data.cookies = await session.getAllRequestsCookies(); + + const puppeteer = await session.getPuppeteer(); + const pages = await puppeteer.pages(); + const frames = pages[0].frames(); + + const framesData: Record = {}; + + for (const frame of frames) { + const origin = new URL(frame.url()).origin; + + if (origin === "null" || framesData[origin]) { + continue; + } + + const { localStorage, sessionStorage }: StorageData = await frame.evaluate(dumpStorage); - const data: SaveStateData = { - framesData, - }; + const frameData: FrameData = {}; - if (options.cookies) { - data.cookies = requestsCookies; + if (localStorage && options.localStorage) { + frameData.localStorage = localStorage; + } + + if (sessionStorage && options.sessionStorage) { + frameData.sessionStorage = sessionStorage; + } + + if (options.indexDB) { + const indexDB: Record | undefined = await frame.evaluate(dumpIndexedDB); + + if (indexDB) { + frameData.indexDB = indexDB; + } + } + + if (frameData.localStorage || frameData.sessionStorage || frameData.indexDB) { + framesData[origin] = frameData; + } + } + + data.framesData = framesData; + break; + } } if (options && options.path) { From f0f885aae3c91bd74604b61be194d8eb5307d0e1 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Tue, 23 Sep 2025 04:06:17 +0700 Subject: [PATCH 09/15] fix(testplane): webdriver restore state --- src/browser/commands/restoreState/index.ts | 177 ++++++++++-------- .../commands/saveState/dumpIndexedDB.ts | 4 +- src/browser/commands/saveState/dumpStorage.ts | 6 +- src/browser/commands/saveState/index.ts | 76 +++++--- src/browser/existing-browser.ts | 16 +- src/browser/types.ts | 4 +- 6 files changed, 167 insertions(+), 116 deletions(-) diff --git a/src/browser/commands/restoreState/index.ts b/src/browser/commands/restoreState/index.ts index 2cb145f6b..b7ecdc7e5 100644 --- a/src/browser/commands/restoreState/index.ts +++ b/src/browser/commands/restoreState/index.ts @@ -4,103 +4,126 @@ import { clearAllIndexedDB } from "./clearAllIndexedDB"; import { restoreIndexedDB } from "./restoreIndexedDB"; import { restoreStorage } from "./restoreStorage"; +import _ from "lodash"; import type { Browser } from "../../types"; -import { defaultOptions, SaveStateOptions } from "../saveState"; +import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../../../constants/config"; +import { defaultOptions, getWebdriverFrames, SaveStateData, SaveStateOptions } from "../saveState"; import { Protocol } from "devtools-protocol"; -import { DumpIndexDB } from "../saveState/dumpIndexedDB"; type RestoreStateOptions = SaveStateOptions; -const normalizeCookies = (cookies: Array): Array => - cookies - .filter(c => c.name && c.value && c.domain) - .map(c => { - const cookie: Partial = { - name: c.name, - value: c.value, - domain: c.domain, - path: c.path || "/", - }; - - if (c.expires) { - cookie.expires = - typeof c.expires === "string" - ? Math.floor(new Date(c.expires).getTime() / 1000) - : Math.floor(c.expires as number); - } - - if (c.secure !== undefined) cookie.secure = c.secure; - if (c.httpOnly !== undefined) cookie.httpOnly = c.httpOnly; - if (c.sameSite) cookie.sameSite = c.sameSite; - - if (!c.domain?.startsWith(".")) { - cookie.url = `https://${c.domain}`; - } else { - cookie.url = `https://${c.domain.replace(/^\./, "")}`; - } - - return cookie as Protocol.Network.CookieParam; - }); - -type FrameData = { - localStorage: Record; - sessionStorage: Record; - indexDB: Record; -}; - -type RestoreState = { - cookies: Array; - framesData: Record; -}; - export default (browser: Browser): void => { const { publicAPI: session } = browser; session.addCommand("restoreState", async (_options: RestoreStateOptions) => { const options = { ...defaultOptions, ..._options }; - const restoreState: RestoreState = await fs.readJson(options.path); + const restoreState: SaveStateData = await fs.readJson(options.path); - const puppeteer = await session.getPuppeteer(); - const pages = await puppeteer.pages(); - const page = pages[0]; - const frames = page.frames(); + switch (browser.config.automationProtocol) { + case WEBDRIVER_PROTOCOL: { + if (restoreState.cookies && options.cookies) { + await session.setCookies(restoreState.cookies); + } - if (restoreState.cookies && options.cookies) { - const normalized = normalizeCookies(restoreState.cookies); + if (restoreState.framesData) { + await session.switchToParentFrame(); - await page.setCookie(...normalized); - } + const frames = await getWebdriverFrames(session); - for (const frame of frames) { - const origin = new URL(frame.url()).origin; + for (let i = -1; i < frames.length; i++) { + await session.switchToParentFrame(); - if (origin === "null" || !restoreState.framesData[origin]) { - continue; - } + // start with -1 for get data from main page + if (i > -1) { + await session.switchFrame(frames[i]); + } - const frameData = restoreState.framesData[origin]; + const origin = await session.execute(() => window.location.origin); - if (frameData.localStorage && options.localStorage) { - await frame.evaluate( - restoreStorage, - frameData.localStorage as Record, - "localStorage" as const, - ); - } + const frameData = restoreState.framesData[origin]; - if (frameData.sessionStorage && options.sessionStorage) { - await frame.evaluate( - restoreStorage, - frameData.sessionStorage as Record, - "sessionStorage" as const, - ); - } + if (frameData) { + if (frameData.localStorage && options.localStorage) { + await session.execute, "localStorage"]>( + restoreStorage, + frameData.localStorage, + "localStorage", + ); + } - if (frameData.indexDB) { - // @todo: Doesn't work now - await frame.evaluate(clearAllIndexedDB); - await frame.evaluate(restoreIndexedDB, frameData.indexDB); + if (frameData.sessionStorage && options.sessionStorage) { + await session.execute, "sessionStorage"]>( + restoreStorage, + frameData.sessionStorage, + "sessionStorage", + ); + } + + if (frameData.indexDB && options.indexDB) { + // @todo: Doesn't work now + await session.execute(clearAllIndexedDB); + await session.execute(restoreIndexedDB, frameData.indexDB); + } + } + } + + await session.switchToParentFrame(); + } + + break; + } + case DEVTOOLS_PROTOCOL: { + const puppeteer = await session.getPuppeteer(); + const pages = await puppeteer.pages(); + const page = pages[0]; + const frames = page.frames(); + + if (restoreState.cookies && options.cookies) { + await page.setCookie( + ...restoreState.cookies.map(cookie => ({ + ...cookie, + sameSite: _.startCase(_.toLower(cookie.sameSite)) as Protocol.Network.CookieSameSite, + })), + ); + } + + for (const frame of frames) { + const origin = new URL(frame.url()).origin; + + if (origin === "null" || !restoreState.framesData[origin]) { + continue; + } + + const frameData = restoreState.framesData[origin]; + + if (!frameData) { + continue; + } + + if (frameData.localStorage && options.localStorage) { + await frame.evaluate( + restoreStorage, + frameData.localStorage as Record, + "localStorage" as const, + ); + } + + if (frameData.sessionStorage && options.sessionStorage) { + await frame.evaluate( + restoreStorage, + frameData.sessionStorage as Record, + "sessionStorage" as const, + ); + } + + if (frameData.indexDB) { + // @todo: Doesn't work now + await frame.evaluate(clearAllIndexedDB); + await frame.evaluate(restoreIndexedDB, frameData.indexDB); + } + } + break; } } }); diff --git a/src/browser/commands/saveState/dumpIndexedDB.ts b/src/browser/commands/saveState/dumpIndexedDB.ts index 419e26ffd..cb0e932e2 100644 --- a/src/browser/commands/saveState/dumpIndexedDB.ts +++ b/src/browser/commands/saveState/dumpIndexedDB.ts @@ -18,14 +18,14 @@ export interface DumpStoreIndexDB { }[]; } -export async function dumpIndexedDB(): Promise | undefined> { +export async function dumpIndexedDB(): Promise | undefined> { try { if (!("databases" in indexedDB)) { throw new Error("Your browser don't indexedDB.databases()"); } const dbList = await indexedDB.databases(); // список баз - const result: Record = {}; + const result: Record = {}; for (const dbInfo of dbList) { const name = dbInfo.name; diff --git a/src/browser/commands/saveState/dumpStorage.ts b/src/browser/commands/saveState/dumpStorage.ts index 04e7e38a7..4dffa549b 100644 --- a/src/browser/commands/saveState/dumpStorage.ts +++ b/src/browser/commands/saveState/dumpStorage.ts @@ -1,10 +1,10 @@ export type StorageData = { - localStorage?: Record; - sessionStorage?: Record; + localStorage?: Record; + sessionStorage?: Record; }; export const dumpStorage = (): StorageData => { - const getData = (storage: Storage): Record | undefined => { + const getData = (storage: Storage): Record | undefined => { const data: Record = {}; for (let i = 0; i < storage.length; i++) { diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts index ea21b410c..f6fedc3e1 100644 --- a/src/browser/commands/saveState/index.ts +++ b/src/browser/commands/saveState/index.ts @@ -1,10 +1,10 @@ import fs from "fs-extra"; import type { Browser } from "../../types"; -import { dumpIndexedDB } from "./dumpIndexedDB"; +import { DumpIndexDB, dumpIndexedDB } from "./dumpIndexedDB"; import { dumpStorage, StorageData } from "./dumpStorage"; -import { Protocol } from "devtools-protocol"; import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../../../constants/config"; +import { Cookie } from "@testplane/wdio-protocols"; export type SaveStateOptions = { path: string; @@ -15,13 +15,13 @@ export type SaveStateOptions = { indexDB?: boolean; }; -type FrameData = StorageData & { - indexDB?: Record; +export type FrameData = StorageData & { + indexDB?: Record; }; export type SaveStateData = { - cookies?: Array; - framesData?: Record; + cookies?: Array; + framesData: Record; }; export const defaultOptions = { @@ -31,13 +31,22 @@ export const defaultOptions = { indexDB: false, }; +export const getWebdriverFrames = async (session: WebdriverIO.Browser): Promise => + session.execute(() => + Array.from(document.getElementsByTagName("iframe")) + .map(el => el.getAttribute("src") as string) + .filter(src => src !== null && src !== "about:blank"), + ); + export default (browser: Browser): void => { const { publicAPI: session } = browser; session.addCommand("saveState", async (_options: SaveStateOptions) => { const options = { ...defaultOptions, ..._options }; - const data: SaveStateData = {}; + const data: SaveStateData = { + framesData: {}, + }; switch (browser.config.automationProtocol) { case WEBDRIVER_PROTOCOL: { @@ -45,25 +54,26 @@ export default (browser: Browser): void => { const storageCookies = await session.storageGetCookies({}); data.cookies = storageCookies.cookies.map(cookie => ({ - ...cookie, + name: cookie.name, value: cookie.value.value, - sameSite: cookie.sameSite.toLowerCase() as Protocol.Network.CookieSameSite, + domain: cookie.domain, + path: cookie.path, + expires: cookie.expiry, + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite, })); } await session.switchToParentFrame(); - const frames = await session.execute(() => - Array.from(document.getElementsByTagName("iframe")) - .map(el => el.getAttribute("src") as string) - .filter(src => src !== null && src !== "about:blank"), - ); - + const frames = await getWebdriverFrames(session); const framesData: Record = {}; for (let i = -1; i < frames.length; i++) { await session.switchToParentFrame(); + // start with -1 for get data from main page if (i > -1) { await session.switchFrame(frames[i]); } @@ -74,20 +84,22 @@ export default (browser: Browser): void => { continue; } - const { localStorage, sessionStorage } = await session.execute(dumpStorage); - const frameData: FrameData = {}; - if (localStorage && options.localStorage) { - frameData.localStorage = localStorage; - } + if (options.localStorage || options.sessionStorage) { + const { localStorage, sessionStorage } = await session.execute(dumpStorage); + + if (localStorage && options.localStorage) { + frameData.localStorage = localStorage; + } - if (sessionStorage && options.sessionStorage) { - frameData.sessionStorage = sessionStorage; + if (sessionStorage && options.sessionStorage) { + frameData.sessionStorage = sessionStorage; + } } if (options.indexDB) { - const indexDB: Record | undefined = await session.execute(dumpIndexedDB); + const indexDB: Record | undefined = await session.execute(dumpIndexedDB); if (indexDB) { frameData.indexDB = indexDB; @@ -120,20 +132,22 @@ export default (browser: Browser): void => { continue; } - const { localStorage, sessionStorage }: StorageData = await frame.evaluate(dumpStorage); - const frameData: FrameData = {}; - if (localStorage && options.localStorage) { - frameData.localStorage = localStorage; - } + if (options.localStorage || options.sessionStorage) { + const { localStorage, sessionStorage }: StorageData = await frame.evaluate(dumpStorage); - if (sessionStorage && options.sessionStorage) { - frameData.sessionStorage = sessionStorage; + if (localStorage && options.localStorage) { + frameData.localStorage = localStorage; + } + + if (sessionStorage && options.sessionStorage) { + frameData.sessionStorage = sessionStorage; + } } if (options.indexDB) { - const indexDB: Record | undefined = await frame.evaluate(dumpIndexedDB); + const indexDB: Record | undefined = await frame.evaluate(dumpIndexedDB); if (indexDB) { frameData.indexDB = indexDB; diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index 1518a245c..3d9f2e92f 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -134,7 +134,16 @@ export class ExistingBrowser extends Browser { }); } - return [...this.allCookies.values()]; + return [...this.allCookies.values()].map(cookie => ({ + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path, + expires: cookie.expires ? cookie.expires : undefined, + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite?.toLowerCase(), + })); }); const puppeteer = await this._session.getPuppeteer(); @@ -148,10 +157,15 @@ export class ExistingBrowser extends Browser { parseCookiesString(headers["set-cookie"], { map: false }).forEach( (cookie: Record) => { const index = [cookie.name, cookie.domain, cookie.path].join("-"); + const expires = + typeof cookie.expires === "string" + ? Math.floor(new Date(cookie.expires).getTime() / 1000) + : Math.floor(cookie.expires as number); this.allCookies.set(index, { ...cookie, domain: cookie.domain ?? new URL(res.url()).hostname, + expires, } as Protocol.Network.CookieParam); }, ); diff --git a/src/browser/types.ts b/src/browser/types.ts index f72dc5a9d..9a675e3c8 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -10,7 +10,7 @@ import type { Test, Hook } from "../test-reader/test-object"; import type { CaptureSnapshotOptions, CaptureSnapshotResult } from "./commands/captureDomSnapshot"; import type { Options } from "@testplane/wdio-types"; import { SaveStateOptions } from "./commands/saveState"; -import { Protocol } from "devtools-protocol"; +import { Cookie } from "@testplane/wdio-protocols"; export const BrowserName = { CHROME: "chrome" as PuppeteerBrowser.CHROME, @@ -77,7 +77,7 @@ declare global { getConfig(this: WebdriverIO.Browser): Promise; - getAllRequestsCookies(): Promise>; + getAllRequestsCookies(): Promise>; saveState(options: SaveStateOptions): Promise; restoreState(options: SaveStateOptions): Promise; From c9c2a6553012659bac11c1908e14dcb9fc426502 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Tue, 23 Sep 2025 04:22:45 +0700 Subject: [PATCH 10/15] fix(testplane): fix ff and add logs --- src/browser/commands/restoreState/index.ts | 11 +++++++---- src/browser/commands/saveState/index.ts | 4 ++++ src/browser/existing-browser.ts | 6 ++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/browser/commands/restoreState/index.ts b/src/browser/commands/restoreState/index.ts index b7ecdc7e5..81724bc94 100644 --- a/src/browser/commands/restoreState/index.ts +++ b/src/browser/commands/restoreState/index.ts @@ -1,25 +1,26 @@ import fs from "fs-extra"; +import _ from "lodash"; import { clearAllIndexedDB } from "./clearAllIndexedDB"; import { restoreIndexedDB } from "./restoreIndexedDB"; import { restoreStorage } from "./restoreStorage"; -import _ from "lodash"; +import * as logger from "../../../utils/logger"; import type { Browser } from "../../types"; import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../../../constants/config"; import { defaultOptions, getWebdriverFrames, SaveStateData, SaveStateOptions } from "../saveState"; import { Protocol } from "devtools-protocol"; -type RestoreStateOptions = SaveStateOptions; - export default (browser: Browser): void => { const { publicAPI: session } = browser; - session.addCommand("restoreState", async (_options: RestoreStateOptions) => { + session.addCommand("restoreState", async (_options: SaveStateOptions) => { const options = { ...defaultOptions, ..._options }; const restoreState: SaveStateData = await fs.readJson(options.path); + logger.log("Restore state", options); + switch (browser.config.automationProtocol) { case WEBDRIVER_PROTOCOL: { if (restoreState.cookies && options.cookies) { @@ -126,5 +127,7 @@ export default (browser: Browser): void => { break; } } + + logger.log("State restored"); }); }; diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts index f6fedc3e1..aaaea9f5e 100644 --- a/src/browser/commands/saveState/index.ts +++ b/src/browser/commands/saveState/index.ts @@ -1,5 +1,6 @@ import fs from "fs-extra"; +import * as logger from "../../../utils/logger"; import type { Browser } from "../../types"; import { DumpIndexDB, dumpIndexedDB } from "./dumpIndexedDB"; import { dumpStorage, StorageData } from "./dumpStorage"; @@ -48,6 +49,8 @@ export default (browser: Browser): void => { framesData: {}, }; + logger.log("Save state", options); + switch (browser.config.automationProtocol) { case WEBDRIVER_PROTOCOL: { if (options.cookies) { @@ -165,6 +168,7 @@ export default (browser: Browser): void => { } if (options && options.path) { + logger.log("State saved"); await fs.writeJson(options.path, data, { spaces: 2 }); } }); diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index 3d9f2e92f..17c452846 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -10,7 +10,7 @@ import { Camera, PageMeta } from "./camera"; import { type ClientBridge, build as buildClientBridge } from "./client-bridge"; import * as history from "./history"; import * as logger from "../utils/logger"; -import { WEBDRIVER_PROTOCOL } from "../constants/config"; +import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../constants/config"; import { MIN_CHROME_VERSION_SUPPORT_ISOLATION } from "../constants/browser"; import { isSupportIsolation } from "../utils/browser"; import { isRunInNodeJsEnv } from "../utils/config"; @@ -98,7 +98,9 @@ export class ExistingBrowser extends Browser { await isolationPromise; - await this.startCollectCookies(); + if (this.config.automationProtocol === DEVTOOLS_PROTOCOL) { + await this.startCollectCookies(); + } this._callstackHistory?.clear(); From d0969cdd0b428c8ecc342c22502b4472e35cac2b Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Tue, 23 Sep 2025 12:24:07 +0700 Subject: [PATCH 11/15] fix(testplane): fix tests --- src/browser/existing-browser.ts | 57 ++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index 17c452846..0d4a6cce8 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -149,33 +149,38 @@ export class ExistingBrowser extends Browser { }); const puppeteer = await this._session.getPuppeteer(); - const pages = await puppeteer.pages(); - - pages[0].on("response", async res => { - try { - const headers = res.headers(); - - if (headers["set-cookie"]) { - parseCookiesString(headers["set-cookie"], { map: false }).forEach( - (cookie: Record) => { - const index = [cookie.name, cookie.domain, cookie.path].join("-"); - const expires = - typeof cookie.expires === "string" - ? Math.floor(new Date(cookie.expires).getTime() / 1000) - : Math.floor(cookie.expires as number); - - this.allCookies.set(index, { - ...cookie, - domain: cookie.domain ?? new URL(res.url()).hostname, - expires, - } as Protocol.Network.CookieParam); - }, - ); - } - } catch (err) { - console.error(err); + + if (puppeteer) { + const pages = await puppeteer.pages(); + + if (pages.length) { + pages[0].on("response", async res => { + try { + const headers = res.headers(); + + if (headers["set-cookie"]) { + parseCookiesString(headers["set-cookie"], { map: false }).forEach( + (cookie: Record) => { + const index = [cookie.name, cookie.domain, cookie.path].join("-"); + const expires = + typeof cookie.expires === "string" + ? Math.floor(new Date(cookie.expires).getTime() / 1000) + : Math.floor(cookie.expires as number); + + this.allCookies.set(index, { + ...cookie, + domain: cookie.domain ?? new URL(res.url()).hostname, + expires, + } as Protocol.Network.CookieParam); + }, + ); + } + } catch (err) { + console.error(err); + } + }); } - }); + } } markAsBroken(): void { From 8b6e748048d1958f0d206f5e1e0480feb35de902 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Tue, 23 Sep 2025 12:35:47 +0700 Subject: [PATCH 12/15] fix(testplane): fix types --- package-lock.json | 19 +++++++++++++++++++ package.json | 1 + src/browser/existing-browser.ts | 32 +++++++++++++++----------------- test/src/browser/utils.js | 1 + 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b93a7126..3d9638dc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,7 @@ "@types/mocha": "10.0.1", "@types/node": "18.19.3", "@types/proxyquire": "1.3.28", + "@types/set-cookie-parser": "2.4.10", "@types/sinon": "17.0.1", "@types/sinonjs__fake-timers": "8.1.2", "@types/strftime": "0.9.8", @@ -2915,6 +2916,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/sinon": { "version": "17.0.1", "dev": true, @@ -15980,6 +15990,15 @@ "version": "7.5.6", "dev": true }, + "@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/sinon": { "version": "17.0.1", "dev": true, diff --git a/package.json b/package.json index d73a2cebd..411c06dc9 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "@types/mocha": "10.0.1", "@types/node": "18.19.3", "@types/proxyquire": "1.3.28", + "@types/set-cookie-parser": "2.4.10", "@types/sinon": "17.0.1", "@types/sinonjs__fake-timers": "8.1.2", "@types/strftime": "0.9.8", diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index 0d4a6cce8..0a2f39382 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -1,7 +1,6 @@ import url from "url"; import _ from "lodash"; -// @ts-expect-error no typings for "set-cookie-parser" -import { parse as parseCookiesString } from "set-cookie-parser"; +import { parse as parseCookiesString, Cookie } from "set-cookie-parser"; import { attach, type AttachOptions, type ElementArray } from "@testplane/webdriverio"; import { sessionEnvironmentDetector } from "@testplane/wdio-utils"; import { Browser, BrowserOpts } from "./browser"; @@ -150,6 +149,8 @@ export class ExistingBrowser extends Browser { const puppeteer = await this._session.getPuppeteer(); + console.log("getPuppeteer", puppeteer); + if (puppeteer) { const pages = await puppeteer.pages(); @@ -159,21 +160,18 @@ export class ExistingBrowser extends Browser { const headers = res.headers(); if (headers["set-cookie"]) { - parseCookiesString(headers["set-cookie"], { map: false }).forEach( - (cookie: Record) => { - const index = [cookie.name, cookie.domain, cookie.path].join("-"); - const expires = - typeof cookie.expires === "string" - ? Math.floor(new Date(cookie.expires).getTime() / 1000) - : Math.floor(cookie.expires as number); - - this.allCookies.set(index, { - ...cookie, - domain: cookie.domain ?? new URL(res.url()).hostname, - expires, - } as Protocol.Network.CookieParam); - }, - ); + parseCookiesString(headers["set-cookie"], { map: false }).forEach((cookie: Cookie) => { + const index = [cookie.name, cookie.domain, cookie.path].join("-"); + const expires = cookie.expires + ? Math.floor(new Date(cookie.expires).getTime() / 1000) + : undefined; + + this.allCookies.set(index, { + ...cookie, + domain: cookie.domain ?? new URL(res.url()).hostname, + expires, + } as Protocol.Network.CookieParam); + }); } } catch (err) { console.error(err); diff --git a/test/src/browser/utils.js b/test/src/browser/utils.js index ed10a8289..d77bb45c4 100644 --- a/test/src/browser/utils.js +++ b/test/src/browser/utils.js @@ -113,6 +113,7 @@ export const mkCDPBrowserCtx_ = () => ({ export const mkCDPStub_ = () => ({ browserContexts: sinon.stub().named("browserContexts").returns([]), createIncognitoBrowserContext: sinon.stub().named("createIncognitoBrowserContext").resolves(mkCDPBrowserCtx_()), + pages: () => [], }); export const mkSessionStub_ = () => { From fec8e76389462948e56945873eca2042867bad25 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Tue, 23 Sep 2025 15:26:16 +0700 Subject: [PATCH 13/15] fix(testplane): add save state in memory --- src/browser/commands/restoreState.ts | 1 + src/browser/commands/restoreState/index.ts | 19 ++++++++++++++++--- src/browser/commands/saveState/index.ts | 11 +++++++---- src/browser/existing-browser.ts | 2 -- src/browser/types.ts | 7 ++++--- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/browser/commands/restoreState.ts b/src/browser/commands/restoreState.ts index d8d9dda9d..4cd71905a 100644 --- a/src/browser/commands/restoreState.ts +++ b/src/browser/commands/restoreState.ts @@ -1,3 +1,4 @@ import restoreState from "./restoreState/index"; export default restoreState; +export * from "./restoreState/index"; diff --git a/src/browser/commands/restoreState/index.ts b/src/browser/commands/restoreState/index.ts index 81724bc94..32b14b59d 100644 --- a/src/browser/commands/restoreState/index.ts +++ b/src/browser/commands/restoreState/index.ts @@ -11,15 +11,28 @@ import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../../../constants/config import { defaultOptions, getWebdriverFrames, SaveStateData, SaveStateOptions } from "../saveState"; import { Protocol } from "devtools-protocol"; +export type RestoreStateOptions = SaveStateOptions & { + data?: SaveStateData; +}; + export default (browser: Browser): void => { const { publicAPI: session } = browser; - session.addCommand("restoreState", async (_options: SaveStateOptions) => { + session.addCommand("restoreState", async (_options: RestoreStateOptions) => { const options = { ...defaultOptions, ..._options }; - const restoreState: SaveStateData = await fs.readJson(options.path); + let restoreState: SaveStateData | undefined = options.data; + + if (options.path) { + restoreState = await fs.readJson(options.path); + } + + if (!restoreState) { + logger.error("Can't restore state: please provide a path to file or data"); + return; + } - logger.log("Restore state", options); + logger.log("Restore state"); switch (browser.config.automationProtocol) { case WEBDRIVER_PROTOCOL: { diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts index aaaea9f5e..1fffee8f1 100644 --- a/src/browser/commands/saveState/index.ts +++ b/src/browser/commands/saveState/index.ts @@ -8,7 +8,7 @@ import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../../../constants/config import { Cookie } from "@testplane/wdio-protocols"; export type SaveStateOptions = { - path: string; + path?: string; cookies?: boolean; localStorage?: boolean; @@ -42,14 +42,14 @@ export const getWebdriverFrames = async (session: WebdriverIO.Browser): Promise< export default (browser: Browser): void => { const { publicAPI: session } = browser; - session.addCommand("saveState", async (_options: SaveStateOptions) => { + session.addCommand("saveState", async (_options: SaveStateOptions): Promise => { const options = { ...defaultOptions, ..._options }; const data: SaveStateData = { framesData: {}, }; - logger.log("Save state", options); + logger.log("Save state"); switch (browser.config.automationProtocol) { case WEBDRIVER_PROTOCOL: { @@ -168,8 +168,11 @@ export default (browser: Browser): void => { } if (options && options.path) { - logger.log("State saved"); await fs.writeJson(options.path, data, { spaces: 2 }); } + + logger.log("State saved"); + + return data; }); }; diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index 0a2f39382..71dfd6488 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -149,8 +149,6 @@ export class ExistingBrowser extends Browser { const puppeteer = await this._session.getPuppeteer(); - console.log("getPuppeteer", puppeteer); - if (puppeteer) { const pages = await puppeteer.pages(); diff --git a/src/browser/types.ts b/src/browser/types.ts index 9a675e3c8..535998b49 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -9,8 +9,9 @@ import type { Callstack } from "./history/callstack"; import type { Test, Hook } from "../test-reader/test-object"; import type { CaptureSnapshotOptions, CaptureSnapshotResult } from "./commands/captureDomSnapshot"; import type { Options } from "@testplane/wdio-types"; -import { SaveStateOptions } from "./commands/saveState"; +import { SaveStateData, SaveStateOptions } from "./commands/saveState"; import { Cookie } from "@testplane/wdio-protocols"; +import { RestoreStateOptions } from "./commands/restoreState"; export const BrowserName = { CHROME: "chrome" as PuppeteerBrowser.CHROME, @@ -78,8 +79,8 @@ declare global { getConfig(this: WebdriverIO.Browser): Promise; getAllRequestsCookies(): Promise>; - saveState(options: SaveStateOptions): Promise; - restoreState(options: SaveStateOptions): Promise; + saveState(options: SaveStateOptions): Promise; + restoreState(options: RestoreStateOptions): Promise; overwriteCommand( name: CommandName, From 74ec0a980c7974ef17c33046de5dfa0a8bd2284b Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Tue, 23 Sep 2025 18:22:53 +0700 Subject: [PATCH 14/15] fix(testplane): add tests --- package.json | 2 +- src/browser/commands/restoreState/index.ts | 4 - src/browser/commands/saveState/index.ts | 8 +- src/browser/types.ts | 2 +- src/index.ts | 1 + test/integration/standalone/constants.ts | 1 + .../mock-auth-page/public/index.html | 193 +++++++++++++++ .../standalone/mock-auth-page/server.ts | 234 ++++++++++++++++++ .../standalone/standalone-save-state.test.ts | 89 +++++++ 9 files changed, 521 insertions(+), 13 deletions(-) create mode 100644 test/integration/standalone/mock-auth-page/public/index.html create mode 100644 test/integration/standalone/mock-auth-page/server.ts create mode 100644 test/integration/standalone/standalone-save-state.test.ts diff --git a/package.json b/package.json index 411c06dc9..7b7e52068 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "prettier-watch": "onchange '**' --exclude-path .prettierignore -- prettier --write {{changed}}", "test-unit": "_mocha \"test/!(integration)/**/*.js\"", "test": "npm run test-unit && npm run check-types && npm run lint", - "test-integration": "mocha -r ts-node/register -r test/integration/standalone/preload-browser.fixture.ts test/integration/standalone/standalone.test.ts", + "test-integration": "mocha -r ts-node/register -r test/integration/standalone/preload-browser.fixture.ts test/integration/standalone/standalone.test.ts test/integration/standalone/standalone-save-state.test.ts", "toc": "doctoc docs --title '### Contents'", "precommit": "npm run lint", "prepack": "npm run clean && npm run build", diff --git a/src/browser/commands/restoreState/index.ts b/src/browser/commands/restoreState/index.ts index 32b14b59d..36314d8d6 100644 --- a/src/browser/commands/restoreState/index.ts +++ b/src/browser/commands/restoreState/index.ts @@ -32,8 +32,6 @@ export default (browser: Browser): void => { return; } - logger.log("Restore state"); - switch (browser.config.automationProtocol) { case WEBDRIVER_PROTOCOL: { if (restoreState.cookies && options.cookies) { @@ -140,7 +138,5 @@ export default (browser: Browser): void => { break; } } - - logger.log("State restored"); }); }; diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts index 1fffee8f1..f491ee838 100644 --- a/src/browser/commands/saveState/index.ts +++ b/src/browser/commands/saveState/index.ts @@ -1,6 +1,5 @@ import fs from "fs-extra"; -import * as logger from "../../../utils/logger"; import type { Browser } from "../../types"; import { DumpIndexDB, dumpIndexedDB } from "./dumpIndexedDB"; import { dumpStorage, StorageData } from "./dumpStorage"; @@ -42,15 +41,13 @@ export const getWebdriverFrames = async (session: WebdriverIO.Browser): Promise< export default (browser: Browser): void => { const { publicAPI: session } = browser; - session.addCommand("saveState", async (_options: SaveStateOptions): Promise => { + session.addCommand("saveState", async (_options: SaveStateOptions = {}): Promise => { const options = { ...defaultOptions, ..._options }; const data: SaveStateData = { framesData: {}, }; - logger.log("Save state"); - switch (browser.config.automationProtocol) { case WEBDRIVER_PROTOCOL: { if (options.cookies) { @@ -64,7 +61,6 @@ export default (browser: Browser): void => { expires: cookie.expiry, httpOnly: cookie.httpOnly, secure: cookie.secure, - sameSite: cookie.sameSite, })); } @@ -171,8 +167,6 @@ export default (browser: Browser): void => { await fs.writeJson(options.path, data, { spaces: 2 }); } - logger.log("State saved"); - return data; }); }; diff --git a/src/browser/types.ts b/src/browser/types.ts index 535998b49..ca9a5c888 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -79,7 +79,7 @@ declare global { getConfig(this: WebdriverIO.Browser): Promise; getAllRequestsCookies(): Promise>; - saveState(options: SaveStateOptions): Promise; + saveState(options?: SaveStateOptions): Promise; restoreState(options: RestoreStateOptions): Promise; overwriteCommand( diff --git a/src/index.ts b/src/index.ts index 332f8c071..78ecb69a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ export type { FormatterListTest, } from "./test-collection"; export type { StatsResult } from "./stats"; +export type { SaveStateData } from "./browser/commands/saveState"; import type { TestDefinition, SuiteDefinition, TestHookDefinition } from "./test-reader/test-object/types"; export type { TestDefinition, SuiteDefinition, TestHookDefinition }; diff --git a/test/integration/standalone/constants.ts b/test/integration/standalone/constants.ts index edc9bd327..bc362e544 100644 --- a/test/integration/standalone/constants.ts +++ b/test/integration/standalone/constants.ts @@ -6,6 +6,7 @@ export const BROWSER_NAME = (process.env.BROWSER || "chrome").toLowerCase() as k export const BROWSER_CONFIG = { desiredCapabilities: { browserName: BROWSER_NAME, + webSocketUrl: true, }, headless: true, system: { diff --git a/test/integration/standalone/mock-auth-page/public/index.html b/test/integration/standalone/mock-auth-page/public/index.html new file mode 100644 index 000000000..f3b272fc9 --- /dev/null +++ b/test/integration/standalone/mock-auth-page/public/index.html @@ -0,0 +1,193 @@ + + + + + + Authentication Test + + + +
+

Authentication Test Page

+ +
+ + + + +
+ + + + diff --git a/test/integration/standalone/mock-auth-page/server.ts b/test/integration/standalone/mock-auth-page/server.ts new file mode 100644 index 000000000..b7c8af3b5 --- /dev/null +++ b/test/integration/standalone/mock-auth-page/server.ts @@ -0,0 +1,234 @@ +import * as http from "http"; +import * as fs from "fs"; +import * as path from "path"; +import * as url from "url"; +import { Connect } from "vite"; +import IncomingMessage = Connect.IncomingMessage; + +interface User { + login: string; + password: string; +} + +export class AuthServer { + private readonly users: User[] = [ + { login: "admin", password: "admin123" }, + { login: "user", password: "user123" }, + { login: "test", password: "test123" }, + ]; + + private readonly port: number = 3000; + private readonly sessions: Map = new Map(); + private server: http.Server | undefined; + + public start(): void { + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + + this.server.listen(this.port, () => { + console.log(`Server running at http://localhost:${this.port}`); + }); + } + + public stop(): void { + if (this.server) { + this.server.close(); + } + } + + private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + const parsedUrl = url.parse(req.url || "", true); + const pathname = parsedUrl.pathname; + + // CORS headers + res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + + if (req.method === "OPTIONS") { + res.writeHead(200); + res.end(); + return; + } + + if (pathname === "/api/login" && req.method === "POST") { + this.handleLogin(req, res); + } else if (pathname === "/api/logout" && req.method === "POST") { + this.handleLogout(req, res); + } else if (pathname === "/api/check-auth" && req.method === "GET") { + this.handleCheckAuth(req, res); + } else { + this.serveStaticFile(req, res); + } + } + + private serveStaticFile(req: http.IncomingMessage, res: http.ServerResponse): void { + const parsedUrl = url.parse(req.url || ""); + let pathname = path.join( + __dirname, + "public", + parsedUrl.pathname === "/" ? "index.html" : parsedUrl.pathname || "", + ); + + // Security: prevent directory traversal + pathname = path.normalize(pathname); + if (!pathname.startsWith(path.join(__dirname, "public"))) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + fs.readFile(pathname, (err, data) => { + if (err) { + if (err.code === "ENOENT") { + // If file not found, serve index.html for SPA routing + fs.readFile(path.join(__dirname, "public", "index.html"), (err, data) => { + if (err) { + res.writeHead(404); + res.end("File not found"); + } else { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(data); + } + }); + } else { + res.writeHead(500); + res.end("Server error"); + } + } else { + const ext = path.extname(pathname); + const contentType = this.getContentType(ext); + res.writeHead(200, { "Content-Type": contentType }); + res.end(data); + } + }); + } + + private getContentType(ext: string): string { + const contentTypes: { [key: string]: string } = { + ".html": "text/html", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + }; + return contentTypes[ext] || "text/plain"; + } + + private handleLogin(req: http.IncomingMessage, res: http.ServerResponse): void { + let body = ""; + + req.on("data", chunk => { + body += chunk.toString(); + }); + + req.on("end", () => { + try { + const { login, password, rememberMe } = JSON.parse(body); + const user = this.users.find(u => u.login === login && u.password === password); + + if (user) { + const sessionId = this.generateSessionId(); + this.sessions.set(sessionId, { + login: user.login, + timestamp: Date.now(), + }); + + // Set session cookie + const cookieOptions = [ + `sessionId=${sessionId}`, + "HttpOnly", + "Path=/", + rememberMe ? `Max-Age=${60 * 60 * 24 * 7}` : "", // 1 week if remember me + ] + .filter(opt => opt) + .join("; "); + + res.setHeader("Set-Cookie", cookieOptions); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + success: true, + message: "Login successful", + login: user.login, + }), + ); + } else { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + success: false, + message: "Invalid login or password", + }), + ); + } + } catch (error) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + success: false, + message: "Invalid request format", + }), + ); + } + }); + } + + private handleLogout(req: http.IncomingMessage, res: http.ServerResponse): void { + const cookies = this.parseCookies(req.headers.cookie || ""); + const sessionId = cookies.sessionId; + + if (sessionId && this.sessions.has(sessionId)) { + this.sessions.delete(sessionId); + } + + // Clear session cookie + res.setHeader("Set-Cookie", "sessionId=; HttpOnly; Path=/; Max-Age=0"); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + success: true, + message: "Logout successful", + }), + ); + } + + private handleCheckAuth(req: http.IncomingMessage, res: http.ServerResponse): void { + const cookies = this.parseCookies(req.headers.cookie || ""); + const sessionId = cookies.sessionId; + + if (sessionId && this.sessions.has(sessionId)) { + const session = this.sessions.get(sessionId)!; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + authenticated: true, + login: session.login, + }), + ); + } else { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + authenticated: false, + }), + ); + } + } + + private parseCookies(cookieHeader: string): { [key: string]: string } { + const cookies: { [key: string]: string } = {}; + cookieHeader.split(";").forEach(cookie => { + const [name, value] = cookie.trim().split("="); + if (name && value) { + cookies[name] = value; + } + }); + return cookies; + } + + private generateSessionId(): string { + return Math.random().toString(36).substring(2) + Date.now().toString(36); + } +} diff --git a/test/integration/standalone/standalone-save-state.test.ts b/test/integration/standalone/standalone-save-state.test.ts new file mode 100644 index 000000000..8aed02783 --- /dev/null +++ b/test/integration/standalone/standalone-save-state.test.ts @@ -0,0 +1,89 @@ +import { strict as assert } from "assert"; +import { launchBrowser } from "../../../src/browser/standalone"; +import { BROWSER_CONFIG } from "./constants"; +import { SaveStateData } from "../../../src"; + +import { AuthServer } from "./mock-auth-page/server"; +import process from "node:process"; + +describe("Standalone Browser E2E Tests", function () { + this.timeout(25000); + + setTimeout(() => { + console.error( + "ERROR! Standalone test failed to complete in 120 seconds.\n" + + "If all tests have passed, most likely this is caused by a bug in browser cleanup logic, e.g. deleteSession() command.", + ); + process.exit(1); + }, 120000).unref(); + + let browser: WebdriverIO.Browser & { getDriverPid?: () => number | undefined }; + + let loginState: SaveStateData; + let status: WebdriverIO.Element; + const mockAuthServer = new AuthServer(); + + before(async () => { + console.log("Start mock server"); + mockAuthServer.start(); + }); + + beforeEach(async () => { + browser = await launchBrowser(BROWSER_CONFIG); + + assert.ok(browser, "Browser should be initialized"); + assert.ok(browser.sessionId, "Browser should have a valid session ID"); + + // go to mock page + await browser.url("http://localhost:3000/"); + + status = await browser.$("#status"); + + // check that we are not logged in + assert.strictEqual(await status.getText(), "You are not logged in"); + }); + + it("saveState", async function () { + // input login + const emailInput = await browser.$("#login"); + await emailInput.setValue("admin"); + + // input password + const passwordInput = await browser.$("#password"); + await passwordInput.setValue("admin123"); + + // click to login + const logInButton = await browser.$('[type="submit"]'); + await logInButton.click(); + + // check that now we logged in + assert.strictEqual(await status.getText(), "You are logged in"); + + // save state + loginState = await browser.saveState(); + }); + + it("restoreState", async function () { + // restore state + if (loginState) { + await browser.restoreState({ + data: loginState, + }); + } + + // reload page + await browser.refresh(); + + // check that now we logged in + assert.strictEqual(await status.getText(), "You are logged in"); + }); + + afterEach(async () => { + await browser.deleteSession(); + }); + + after(async () => { + console.log("Stop mock server"); + mockAuthServer.stop(); + }); +}); From 20bd391af66354b1801a0d2b8d7000ec4d3e34d0 Mon Sep 17 00:00:00 2001 From: rocketraccoon Date: Wed, 24 Sep 2025 16:25:37 +0700 Subject: [PATCH 15/15] fix(testplane): hide save and restore indexDB --- src/browser/commands/restoreState/index.ts | 26 +++++++------- src/browser/commands/saveState/index.ts | 42 +++++++++++----------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/browser/commands/restoreState/index.ts b/src/browser/commands/restoreState/index.ts index 36314d8d6..b3a2acd60 100644 --- a/src/browser/commands/restoreState/index.ts +++ b/src/browser/commands/restoreState/index.ts @@ -1,8 +1,8 @@ import fs from "fs-extra"; import _ from "lodash"; -import { clearAllIndexedDB } from "./clearAllIndexedDB"; -import { restoreIndexedDB } from "./restoreIndexedDB"; +// import { clearAllIndexedDB } from "./clearAllIndexedDB"; +// import { restoreIndexedDB } from "./restoreIndexedDB"; import { restoreStorage } from "./restoreStorage"; import * as logger from "../../../utils/logger"; @@ -72,11 +72,12 @@ export default (browser: Browser): void => { ); } - if (frameData.indexDB && options.indexDB) { - // @todo: Doesn't work now - await session.execute(clearAllIndexedDB); - await session.execute(restoreIndexedDB, frameData.indexDB); - } + // @TODO: will make it later + // if (frameData.indexDB && options.indexDB) { + // // @todo: Doesn't work now + // await session.execute(clearAllIndexedDB); + // await session.execute(restoreIndexedDB, frameData.indexDB); + // } } } @@ -129,11 +130,12 @@ export default (browser: Browser): void => { ); } - if (frameData.indexDB) { - // @todo: Doesn't work now - await frame.evaluate(clearAllIndexedDB); - await frame.evaluate(restoreIndexedDB, frameData.indexDB); - } + // @TODO: will make it later + // if (frameData.indexDB) { + // // @todo: Doesn't work now + // await frame.evaluate(clearAllIndexedDB); + // await frame.evaluate(restoreIndexedDB, frameData.indexDB); + // } } break; } diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts index f491ee838..181de3866 100644 --- a/src/browser/commands/saveState/index.ts +++ b/src/browser/commands/saveState/index.ts @@ -1,7 +1,7 @@ import fs from "fs-extra"; import type { Browser } from "../../types"; -import { DumpIndexDB, dumpIndexedDB } from "./dumpIndexedDB"; +// import { DumpIndexDB } from "./dumpIndexedDB"; import { dumpStorage, StorageData } from "./dumpStorage"; import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../../../constants/config"; import { Cookie } from "@testplane/wdio-protocols"; @@ -12,11 +12,11 @@ export type SaveStateOptions = { cookies?: boolean; localStorage?: boolean; sessionStorage?: boolean; - indexDB?: boolean; + // indexDB?: boolean; }; export type FrameData = StorageData & { - indexDB?: Record; + // indexDB?: Record; }; export type SaveStateData = { @@ -28,7 +28,7 @@ export const defaultOptions = { cookies: true, localStorage: true, sessionStorage: true, - indexDB: false, + // indexDB: false, }; export const getWebdriverFrames = async (session: WebdriverIO.Browser): Promise => @@ -97,15 +97,16 @@ export default (browser: Browser): void => { } } - if (options.indexDB) { - const indexDB: Record | undefined = await session.execute(dumpIndexedDB); + // @TODO: will make it later + // if (options.indexDB) { + // const indexDB: Record | undefined = await session.execute(dumpIndexedDB); + // + // if (indexDB) { + // frameData.indexDB = indexDB; + // } + // } - if (indexDB) { - frameData.indexDB = indexDB; - } - } - - if (frameData.localStorage || frameData.sessionStorage || frameData.indexDB) { + if (frameData.localStorage || frameData.sessionStorage) { framesData[origin] = frameData; } } @@ -145,15 +146,16 @@ export default (browser: Browser): void => { } } - if (options.indexDB) { - const indexDB: Record | undefined = await frame.evaluate(dumpIndexedDB); - - if (indexDB) { - frameData.indexDB = indexDB; - } - } + // @TODO: will make it later + // if (options.indexDB) { + // const indexDB: Record | undefined = await frame.evaluate(dumpIndexedDB); + // + // if (indexDB) { + // frameData.indexDB = indexDB; + // } + // } - if (frameData.localStorage || frameData.sessionStorage || frameData.indexDB) { + if (frameData.localStorage || frameData.sessionStorage) { framesData[origin] = frameData; } }