diff --git a/package-lock.json b/package-lock.json index f2e15c780..3d9638dc4 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", @@ -88,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", @@ -2914,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, @@ -11778,6 +11789,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", @@ -15974,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, @@ -21806,6 +21831,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..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", @@ -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", @@ -139,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/commands/index.ts b/src/browser/commands/index.ts index 3586f6c71..0b360ddd4 100644 --- a/src/browser/commands/index.ts +++ b/src/browser/commands/index.ts @@ -9,4 +9,6 @@ export const customCommandFileNames = [ "switchToRepl", "moveCursorTo", "captureDomSnapshot", + "saveState", + "restoreState", ]; diff --git a/src/browser/commands/restoreState.ts b/src/browser/commands/restoreState.ts new file mode 100644 index 000000000..4cd71905a --- /dev/null +++ b/src/browser/commands/restoreState.ts @@ -0,0 +1,4 @@ +import restoreState from "./restoreState/index"; + +export default restoreState; +export * from "./restoreState/index"; diff --git a/src/browser/commands/restoreState/clearAllIndexedDB.ts b/src/browser/commands/restoreState/clearAllIndexedDB.ts new file mode 100644 index 000000000..4cb48fffb --- /dev/null +++ b/src/browser/commands/restoreState/clearAllIndexedDB.ts @@ -0,0 +1,27 @@ +export async function clearAllIndexedDB(): Promise { + try { + 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.addEventListener("success", () => resolve()); + deleteReq.addEventListener("error", () => reject(deleteReq.error)); + deleteReq.addEventListener("blocked", () => resolve()); + }); + }), + ); + } catch (error) { + console.error(error); + } +} diff --git a/src/browser/commands/restoreState/index.ts b/src/browser/commands/restoreState/index.ts new file mode 100644 index 000000000..b3a2acd60 --- /dev/null +++ b/src/browser/commands/restoreState/index.ts @@ -0,0 +1,144 @@ +import fs from "fs-extra"; +import _ from "lodash"; + +// import { clearAllIndexedDB } from "./clearAllIndexedDB"; +// import { restoreIndexedDB } from "./restoreIndexedDB"; +import { restoreStorage } from "./restoreStorage"; + +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"; + +export type RestoreStateOptions = SaveStateOptions & { + data?: SaveStateData; +}; + +export default (browser: Browser): void => { + const { publicAPI: session } = browser; + + session.addCommand("restoreState", async (_options: RestoreStateOptions) => { + const options = { ...defaultOptions, ..._options }; + + 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; + } + + switch (browser.config.automationProtocol) { + case WEBDRIVER_PROTOCOL: { + if (restoreState.cookies && options.cookies) { + await session.setCookies(restoreState.cookies); + } + + if (restoreState.framesData) { + await session.switchToParentFrame(); + + const frames = await getWebdriverFrames(session); + + 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]); + } + + const origin = await session.execute(() => window.location.origin); + + const frameData = restoreState.framesData[origin]; + + if (frameData) { + if (frameData.localStorage && options.localStorage) { + await session.execute, "localStorage"]>( + restoreStorage, + frameData.localStorage, + "localStorage", + ); + } + + if (frameData.sessionStorage && options.sessionStorage) { + await session.execute, "sessionStorage"]>( + restoreStorage, + frameData.sessionStorage, + "sessionStorage", + ); + } + + // @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); + // } + } + } + + 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, + ); + } + + // @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/restoreState/restoreIndexedDB.ts b/src/browser/commands/restoreState/restoreIndexedDB.ts new file mode 100644 index 000000000..4170163eb --- /dev/null +++ b/src/browser/commands/restoreState/restoreIndexedDB.ts @@ -0,0 +1,67 @@ +import { DumpIndexDB, DumpStoreIndexDB } from "../saveState/dumpIndexedDB"; + +export async function restoreIndexedDB(dump: Record): Promise { + try { + 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.addEventListener("upgradeneeded", (): 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.addEventListener("success", (): 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.addEventListener("success", (): void => { + db.close(); + resolve(); + }); + + tx.addEventListener("error", (): void => reject(tx.error)); + }); + + openReq.addEventListener("error", (): 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..23e3f736e --- /dev/null +++ b/src/browser/commands/restoreState/restoreStorage.ts @@ -0,0 +1,8 @@ +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]); + }); +}; diff --git a/src/browser/commands/saveState.ts b/src/browser/commands/saveState.ts new file mode 100644 index 000000000..1b8d2c3e7 --- /dev/null +++ b/src/browser/commands/saveState.ts @@ -0,0 +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 new file mode 100644 index 000000000..cb0e932e2 --- /dev/null +++ b/src/browser/commands/saveState/dumpIndexedDB.ts @@ -0,0 +1,106 @@ +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 | 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); + + 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 Object.keys(result).length === 0 ? undefined : result; + } catch (error) { + return; + } +} diff --git a/src/browser/commands/saveState/dumpStorage.ts b/src/browser/commands/saveState/dumpStorage.ts new file mode 100644 index 000000000..4dffa549b --- /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 new file mode 100644 index 000000000..181de3866 --- /dev/null +++ b/src/browser/commands/saveState/index.ts @@ -0,0 +1,174 @@ +import fs from "fs-extra"; + +import type { Browser } from "../../types"; +// import { DumpIndexDB } from "./dumpIndexedDB"; +import { dumpStorage, StorageData } from "./dumpStorage"; +import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../../../constants/config"; +import { Cookie } from "@testplane/wdio-protocols"; + +export type SaveStateOptions = { + path?: string; + + cookies?: boolean; + localStorage?: boolean; + sessionStorage?: boolean; + // indexDB?: boolean; +}; + +export type FrameData = StorageData & { + // indexDB?: Record; +}; + +export type SaveStateData = { + cookies?: Array; + framesData: Record; +}; + +export const defaultOptions = { + cookies: true, + localStorage: true, + sessionStorage: true, + // 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 = {}): Promise => { + const options = { ...defaultOptions, ..._options }; + + const data: SaveStateData = { + framesData: {}, + }; + + switch (browser.config.automationProtocol) { + case WEBDRIVER_PROTOCOL: { + if (options.cookies) { + const storageCookies = await session.storageGetCookies({}); + + data.cookies = storageCookies.cookies.map(cookie => ({ + name: cookie.name, + value: cookie.value.value, + domain: cookie.domain, + path: cookie.path, + expires: cookie.expiry, + httpOnly: cookie.httpOnly, + secure: cookie.secure, + })); + } + + await session.switchToParentFrame(); + + 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]); + } + + const origin = await session.execute(() => window.location.origin); + + if (!origin || origin === "null" || framesData[origin]) { + continue; + } + + const frameData: FrameData = {}; + + 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; + } + } + + // @TODO: will make it later + // if (options.indexDB) { + // const indexDB: Record | undefined = await session.execute(dumpIndexedDB); + // + // if (indexDB) { + // frameData.indexDB = indexDB; + // } + // } + + if (frameData.localStorage || frameData.sessionStorage) { + framesData[origin] = frameData; + } + } + + await session.switchToParentFrame(); + + 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 frameData: FrameData = {}; + + if (options.localStorage || options.sessionStorage) { + const { localStorage, sessionStorage }: StorageData = await frame.evaluate(dumpStorage); + + if (localStorage && options.localStorage) { + frameData.localStorage = localStorage; + } + + if (sessionStorage && options.sessionStorage) { + frameData.sessionStorage = sessionStorage; + } + } + + // @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) { + framesData[origin] = frameData; + } + } + + data.framesData = framesData; + break; + } + } + + if (options && options.path) { + await fs.writeJson(options.path, data, { spaces: 2 }); + } + + return data; + }); +}; diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index 6d6e932df..71dfd6488 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -1,5 +1,6 @@ import url from "url"; import _ from "lodash"; +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"; @@ -8,7 +9,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"; @@ -18,6 +19,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"]; @@ -60,6 +62,7 @@ export class ExistingBrowser extends Browser { protected _meta: Record; protected _calibration?: CalibrationResult; protected _clientBridge?: ClientBridge; + private allCookies: Map = new Map(); constructor(config: Config, opts: BrowserOpts) { super(config, opts); @@ -94,6 +97,10 @@ export class ExistingBrowser extends Browser { await isolationPromise; + if (this.config.automationProtocol === DEVTOOLS_PROTOCOL) { + await this.startCollectCookies(); + } + this._callstackHistory?.clear(); try { @@ -111,6 +118,67 @@ export class ExistingBrowser extends Browser { return this; } + async startCollectCookies(): Promise { + if (!this._session) { + return; + } + + 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()].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(); + + 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: 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); + } + }); + } + } + } + markAsBroken(): void { if (this.state.isBroken) { return; diff --git a/src/browser/types.ts b/src/browser/types.ts index a3283ac15..ca9a5c888 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -9,6 +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 { SaveStateData, SaveStateOptions } from "./commands/saveState"; +import { Cookie } from "@testplane/wdio-protocols"; +import { RestoreStateOptions } from "./commands/restoreState"; export const BrowserName = { CHROME: "chrome" as PuppeteerBrowser.CHROME, @@ -75,6 +78,10 @@ declare global { getConfig(this: WebdriverIO.Browser): Promise; + getAllRequestsCookies(): Promise>; + saveState(options?: SaveStateOptions): Promise; + restoreState(options: RestoreStateOptions): Promise; + overwriteCommand( name: CommandName, func: OverwriteCommandFn, 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(); + }); +}); 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_ = () => {