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..826fdb254 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/*/**", "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/index.ts b/src/browser/commands/restoreState/index.ts new file mode 100644 index 000000000..a6cb018d4 --- /dev/null +++ b/src/browser/commands/restoreState/index.ts @@ -0,0 +1,151 @@ +import fs from "fs-extra"; +import _ from "lodash"; + +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, + getCalculatedProtocol, + getWebdriverFrames, + SaveStateData, + SaveStateOptions, +} from "../saveState"; +import { getActivePuppeteerPage } from "../../existing-browser"; + +export type RestoreStateOptions = SaveStateOptions & { + data?: SaveStateData; + refresh?: boolean; +}; + +export type CookiesSameSite = "Strict" | "Lax" | "None"; + +export default (browser: Browser): void => { + const { publicAPI: session } = browser; + + session.addCommand("restoreState", async (_options: RestoreStateOptions) => { + const options = { ...defaultOptions, refresh: true, ..._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 (getCalculatedProtocol(browser)) { + case WEBDRIVER_PROTOCOL: { + await session.switchToParentFrame(); + + if (restoreState.cookies && options.cookies) { + await session.setCookies(restoreState.cookies); + } + + if (restoreState.framesData) { + await session.switchToParentFrame(); + + const frames = await getWebdriverFrames(session); + + for (let i = 0; i <= frames.length; i++) { + await session.switchToParentFrame(); + + // after last element have to set data for parent frame + if (i < frames.length) { + 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", + ); + } + } + } + + await session.switchToParentFrame(); + } + + if (options.refresh) { + await session.refresh(); + } + + break; + } + case DEVTOOLS_PROTOCOL: { + const puppeteer = await session.getPuppeteer(); + const page = await getActivePuppeteerPage(puppeteer); + + if (!page) { + return; + } + + const frames = page.frames(); + + if (restoreState.cookies && options.cookies) { + await page.setCookie( + ...restoreState.cookies.map(cookie => ({ + ...cookie, + sameSite: _.startCase(_.toLower(cookie.sameSite)) as CookiesSameSite, + })), + ); + } + + 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 (options.refresh) { + await page.reload(); + } + } + break; + } + } + }); +}; 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/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..078beba7f --- /dev/null +++ b/src/browser/commands/saveState/index.ts @@ -0,0 +1,176 @@ +import fs from "fs-extra"; + +import type { Browser } from "../../types"; +import { dumpStorage, StorageData } from "./dumpStorage"; +import { DEVTOOLS_PROTOCOL, WEBDRIVER_PROTOCOL } from "../../../constants/config"; +import { Cookie } from "@testplane/wdio-protocols"; +import { isSupportIsolation } from "../../../utils/browser"; +import { ExistingBrowser, getActivePuppeteerPage } from "../../existing-browser"; + +export type SaveStateOptions = { + path?: string; + + cookies?: boolean; + localStorage?: boolean; + sessionStorage?: boolean; +}; + +export type FrameData = StorageData; + +export type SaveStateData = { + cookies?: Array; + framesData: Record; +}; + +export const defaultOptions = { + cookies: true, + localStorage: true, + sessionStorage: true, +}; + +export const getCalculatedProtocol = (browser: Browser): typeof DEVTOOLS_PROTOCOL | typeof WEBDRIVER_PROTOCOL => { + const protocol = browser.config.automationProtocol; + + if (protocol === DEVTOOLS_PROTOCOL) { + return DEVTOOLS_PROTOCOL; + } + + if ( + protocol === WEBDRIVER_PROTOCOL && + browser.config.isolation && + isSupportIsolation(browser.publicAPI.capabilities.browserName!, browser.publicAPI.capabilities.browserVersion!) + ) { + return DEVTOOLS_PROTOCOL; + } + + return protocol; +}; + +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: ExistingBrowser): void => { + const { publicAPI: session } = browser; + + session.addCommand("saveState", async (_options: SaveStateOptions = {}): Promise => { + const options = { ...defaultOptions, ..._options }; + + const data: SaveStateData = { + framesData: {}, + }; + + switch (getCalculatedProtocol(browser)) { + 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 = 0; i < frames.length; i++) { + await session.switchToParentFrame(); + + // after last element have to get data from parent frame + if (i < frames.length) { + 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; + } + } + + if (frameData.localStorage || frameData.sessionStorage) { + framesData[origin] = frameData; + } + } + + await session.switchToParentFrame(); + + data.framesData = framesData; + break; + } + case DEVTOOLS_PROTOCOL: { + data.cookies = await browser.getAllRequestsCookies(); + + const puppeteer = await session.getPuppeteer(); + const page = await getActivePuppeteerPage(puppeteer); + + if (!page) { + break; + } + + const frames = page.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; + } + } + + 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..019e27231 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,10 @@ 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"; +import { getCalculatedProtocol } from "./commands/saveState"; +import { Cookie as WDIOCookie, SameSiteOptions } from "@testplane/wdio-protocols"; +import { Browser as PuppeteerBrowser, Page } from "puppeteer-core"; const OPTIONAL_SESSION_OPTS = ["transformRequest", "transformResponse"]; @@ -55,11 +60,28 @@ const isClientBridgeErrorData = (data: unknown): data is ClientBridgeErrorData = return Boolean(data && (data as ClientBridgeErrorData).error && (data as ClientBridgeErrorData).message); }; +export const getActivePuppeteerPage = async (puppeteer: PuppeteerBrowser): Promise => { + const pages = await puppeteer.pages(); + + if (!pages.length) { + return; + } + + for (let i = 0; i < pages.length; i++) { + if (await pages[i].evaluate(() => document.visibilityState === "visible")) { + return pages[i]; + } + } + + return; +}; + export class ExistingBrowser extends Browser { protected _camera: Camera; protected _meta: Record; protected _calibration?: CalibrationResult; protected _clientBridge?: ClientBridge; + private _allCookies: Map = new Map(); constructor(config: Config, opts: BrowserOpts) { super(config, opts); @@ -94,6 +116,10 @@ export class ExistingBrowser extends Browser { await isolationPromise; + if (getCalculatedProtocol(this) === DEVTOOLS_PROTOCOL) { + await this.startCollectCookies(); + } + this._callstackHistory?.clear(); try { @@ -111,6 +137,71 @@ export class ExistingBrowser extends Browser { return this; } + async getAllRequestsCookies(): Promise> { + 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() as SameSiteOptions, + })); + } + + async startCollectCookies(): Promise { + if (!this._session) { + return; + } + + this._allCookies = new Map(); + + const puppeteer = await this._session.getPuppeteer(); + + if (!puppeteer) { + return; + } + + const page = await getActivePuppeteerPage(puppeteer); + + if (!page) { + return; + } + + page.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; @@ -358,6 +449,13 @@ export class ExistingBrowser extends Browser { // eslint-disable-next-line @typescript-eslint/no-explicit-any const incognitoWindowId = windowIds.find(id => id.includes((page.target() as any)._targetId)); + for (let i = 0; i < windowIds.length; i++) { + if (windowIds[i] !== incognitoWindowId) { + await this._session.switchToWindow(windowIds[i]); + await this._session.closeWindow(); + } + } + await this._session.switchToWindow(incognitoWindowId!); } diff --git a/src/browser/types.ts b/src/browser/types.ts index a3283ac15..a1f169b0e 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 { SaveStateData, SaveStateOptions } from "./commands/saveState"; +import { RestoreStateOptions } from "./commands/restoreState"; export const BrowserName = { CHROME: "chrome" as PuppeteerBrowser.CHROME, @@ -75,6 +77,9 @@ declare global { getConfig(this: WebdriverIO.Browser): 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..26f403058 --- /dev/null +++ b/test/integration/standalone/standalone-save-state.test.ts @@ -0,0 +1,86 @@ +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("saveState and restoreState 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, + }); + } + + // 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_ = () => {