diff --git a/src/browser/commands/getState.ts b/src/browser/commands/getState.ts new file mode 100644 index 000000000..d62fe9f9c --- /dev/null +++ b/src/browser/commands/getState.ts @@ -0,0 +1,25 @@ +import fs from "fs-extra"; + +import type { Browser } from "../types"; +import type { SaveStateData } from "./saveState"; +import type { StateOpts } from "../../config/types"; +import * as logger from "../../utils/logger"; + +export type GetStateOptions = Pick; + +export default (browser: Browser): void => { + const { publicAPI: session } = browser; + + session.addCommand( + "getState", + async (options: GetStateOptions = browser.config.stateOpts || {}): Promise => { + if (options.path) { + return fs.readJson(options.path); + } + + logger.error("Please provide the path to the state file for getState"); + + return null; + }, + ); +}; diff --git a/src/browser/commands/index.ts b/src/browser/commands/index.ts index 62d633985..c6a2d961c 100644 --- a/src/browser/commands/index.ts +++ b/src/browser/commands/index.ts @@ -12,5 +12,6 @@ export const customCommandFileNames = [ "waitForStaticToLoad", "saveState", "restoreState", + "getState", "unstable_getCdp", ]; diff --git a/src/browser/commands/restoreState/index.ts b/src/browser/commands/restoreState/index.ts index c96184f9c..ed1cddb78 100644 --- a/src/browser/commands/restoreState/index.ts +++ b/src/browser/commands/restoreState/index.ts @@ -2,20 +2,14 @@ import fs from "fs-extra"; 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, - getOverridesProtocol, - getWebdriverFrames, - SaveStateData, - SaveStateOptions, -} from "../saveState"; +import { getOverridesProtocol, getWebdriverFrames, SaveStateData } from "../saveState"; import { getActivePuppeteerPage } from "../../existing-browser"; import { Cookie } from "@testplane/wdio-protocols"; +import { StateOpts } from "../../../config/types"; -export type RestoreStateOptions = SaveStateOptions & { +export type RestoreStateOptions = Omit & { data?: SaveStateData; refresh?: boolean; }; @@ -23,15 +17,14 @@ export type RestoreStateOptions = SaveStateOptions & { export default (browser: Browser): void => { const { publicAPI: session } = browser; - session.addCommand("restoreState", async (_options: RestoreStateOptions) => { + session.addCommand("restoreState", async (_options: RestoreStateOptions = {}) => { const currentUrl = new URL(await session.getUrl()); if (!currentUrl.origin || currentUrl.origin === "null") { - logger.error("Before restoreState first open page using url command"); - process.exit(1); + throw new Error("Before restoreState first open page using url command"); } - const options = { ...defaultOptions, refresh: true, ..._options }; + const options = { ...browser.config.stateOpts, refresh: true, ..._options }; let restoreState: SaveStateData | undefined = options.data; @@ -40,8 +33,7 @@ export default (browser: Browser): void => { } if (!restoreState) { - logger.error("Can't restore state: please provide a path to file or data"); - process.exit(1); + throw new Error("Can't restore state: please provide a path to file or data"); } if (restoreState?.cookies && options.cookieFilter) { diff --git a/src/browser/commands/saveState/index.ts b/src/browser/commands/saveState/index.ts index c6c4d682f..cb0e8ac70 100644 --- a/src/browser/commands/saveState/index.ts +++ b/src/browser/commands/saveState/index.ts @@ -7,16 +7,9 @@ import { ExistingBrowser, getActivePuppeteerPage } from "../../existing-browser" import * as logger from "../../../utils/logger"; import { Cookie } from "../../../types"; import type { Browser } from "../../types"; - -export type SaveStateOptions = { - path?: string; - - cookies?: boolean; - localStorage?: boolean; - sessionStorage?: boolean; - - cookieFilter?: (cookie: Cookie) => boolean; -}; +import { MasterEvents } from "../../../events"; +import { StateOpts } from "../../../config/types"; +import { addGlobalFileToRemove } from "../../../globalFilesToRemove"; export type FrameData = StorageData; @@ -25,12 +18,6 @@ export type SaveStateData = { framesData: Record; }; -export const defaultOptions = { - cookies: true, - localStorage: true, - sessionStorage: true, -}; - // in case when we use webdriver protocol, bidi and isolation // we have to force change protocol to devtools, for use puppeteer, // because we use it for create incognito window @@ -49,15 +36,14 @@ export const getWebdriverFrames = async (session: WebdriverIO.Browser): Promise< export default (browser: ExistingBrowser): void => { const { publicAPI: session } = browser; - session.addCommand("saveState", async (_options: SaveStateOptions = {}): Promise => { + session.addCommand("saveState", async (_options: StateOpts = {}): Promise => { const currentUrl = new URL(await session.getUrl()); if (!currentUrl.origin || currentUrl.origin === "null") { - logger.error("Before saveState first open page using url command"); - process.exit(1); + throw new Error("Before saveState first open page using url command"); } - const options = { ...defaultOptions, ..._options }; + const options = { ...browser.config.stateOpts, ..._options }; const data: SaveStateData = { framesData: {}, @@ -178,8 +164,27 @@ export default (browser: ExistingBrowser): void => { data.cookies = data.cookies.filter(options.cookieFilter); } - if (options && options.path) { - await fs.writeJson(options.path, data, { spaces: 2 }); + const dataIsEmpty = data.cookies?.length === 0 && _.isEmpty(data.framesData); + + if (options && options.path && !dataIsEmpty) { + await fs.outputJson(options.path, data, { spaces: 2 }); + + if (options.keepFile) { + logger.warn( + "\x1b[31mOption keepFile in stateOpts now is true. Please be aware that the file containing authorization data will not be automatically deleted after the tests are completed!\x1b[0m", + ); + } else { + if (process.send) { + process.send({ + event: MasterEvents.ADD_FILE_TO_REMOVE, + data: options.path, + }); + } + + addGlobalFileToRemove(options.path); + + browser.emitter.emit(MasterEvents.ADD_FILE_TO_REMOVE, options.path); + } } return data; diff --git a/src/browser/standalone/attachToBrowser.ts b/src/browser/standalone/attachToBrowser.ts index bdb490b3d..f3d388c17 100644 --- a/src/browser/standalone/attachToBrowser.ts +++ b/src/browser/standalone/attachToBrowser.ts @@ -1,9 +1,11 @@ import { Config } from "../../config"; import { ExistingBrowser } from "./../existing-browser"; import { Calibrator } from "./../calibrator"; -import { AsyncEmitter } from "../../events"; +import { AsyncEmitter, MasterEvents } from "../../events"; import { BrowserName, type W3CBrowserName, type SessionOptions } from "./../types"; import { getNormalizedBrowserName } from "../../utils/browser"; +import fs from "fs-extra"; +import { hasGlobalFilesToRemove } from "../../globalFilesToRemove"; export async function attachToBrowser(session: SessionOptions): Promise { const browserName = session.sessionCaps?.browserName || BrowserName.CHROME; @@ -24,6 +26,8 @@ export async function attachToBrowser(session: SessionOptions): Promise { + filesToRemove.push(path); + }); + const existingBrowser = new ExistingBrowser(config, { id: browserName, version: session.sessionCaps?.browserVersion, @@ -54,6 +62,10 @@ export async function attachToBrowser(session: SessionOptions): Promise 0 && !hasGlobalFilesToRemove()) { + await Promise.all(filesToRemove.map(path => fs.remove(path))); + } }); return existingBrowser.publicAPI; diff --git a/src/browser/standalone/launchBrowser.ts b/src/browser/standalone/launchBrowser.ts index 314265bf2..c32b286a6 100644 --- a/src/browser/standalone/launchBrowser.ts +++ b/src/browser/standalone/launchBrowser.ts @@ -2,12 +2,14 @@ import { Config } from "../../config"; import { NewBrowser } from "./../new-browser"; import { ExistingBrowser } from "./../existing-browser"; import { Calibrator } from "./../calibrator"; -import { AsyncEmitter } from "../../events"; +import { AsyncEmitter, MasterEvents } from "../../events"; import { BrowserName, type W3CBrowserName } from "./../types"; import { getNormalizedBrowserName } from "../../utils/browser"; import { LOCAL_GRID_URL } from "../../constants/config"; import { WebdriverPool } from "../../browser-pool/webdriver-pool"; import type { StandaloneBrowserOptionsInput } from "./types"; +import fs from "fs-extra"; +import { hasGlobalFilesToRemove } from "../../globalFilesToRemove"; const webdriverPool = new WebdriverPool(); @@ -48,8 +50,11 @@ export async function launchBrowser( user: options.user, key: options.key, prepareBrowser: options.prepareBrowser, + stateOpts: options.stateOpts, }; + const filesToRemove: string[] = []; + const config = new Config({ browsers: { [browserName]: browserConfig, @@ -62,6 +67,10 @@ export async function launchBrowser( const emitter = new AsyncEmitter(); + emitter.on(MasterEvents.ADD_FILE_TO_REMOVE, (path: string) => { + filesToRemove.push(path); + }); + const newBrowser = new NewBrowser(config, { id: browserName, version: desiredCapabilities.browserVersion, @@ -97,6 +106,10 @@ export async function launchBrowser( existingBrowser.publicAPI.overwriteCommand("deleteSession", async function () { await existingBrowser.quit(); await newBrowser.kill(); + + if (filesToRemove.length > 0 && !hasGlobalFilesToRemove()) { + await Promise.all(filesToRemove.map(path => fs.remove(path))); + } }); existingBrowser.publicAPI.addCommand("getDriverPid", () => newBrowser.getDriverPid()); diff --git a/src/browser/standalone/types.ts b/src/browser/standalone/types.ts index d4a2fa055..d2ec8c205 100644 --- a/src/browser/standalone/types.ts +++ b/src/browser/standalone/types.ts @@ -20,6 +20,7 @@ export type StandaloneBrowserOptions = Pick< | "user" | "key" | "system" + | "stateOpts" >; export type StandaloneBrowserOptionsInput = Partial> & { diff --git a/src/browser/types.ts b/src/browser/types.ts index 3760a96fe..12c652a48 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -9,7 +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 type { SaveStateData, SaveStateOptions } from "./commands/saveState"; +import type { SaveStateData } from "./commands/saveState"; +import type { StateOpts } from "../config/types"; +import type { GetStateOptions } from "./commands/getState"; import type { RestoreStateOptions } from "./commands/restoreState"; import type { WaitForStaticToLoadResult } from "./commands/waitForStaticToLoad"; import type { CDP } from "./cdp"; @@ -81,8 +83,9 @@ declare global { getConfig(this: WebdriverIO.Browser): Promise; - saveState(options?: SaveStateOptions): Promise; - restoreState(options: RestoreStateOptions): Promise; + saveState(options?: StateOpts): Promise; + restoreState(options?: RestoreStateOptions): Promise; + getState(options?: GetStateOptions): Promise; overwriteCommand( name: CommandName, diff --git a/src/config/browser-options.js b/src/config/browser-options.js index d4a41a66c..54b8f5087 100644 --- a/src/config/browser-options.js +++ b/src/config/browser-options.js @@ -2,7 +2,7 @@ const _ = require("lodash"); const fs = require("fs-extra"); -const option = require("gemini-configparser").option; +const { option, section } = require("gemini-configparser"); const defaults = require("./defaults"); const optionsBuilder = require("./options-builder"); const utils = require("./utils"); @@ -15,7 +15,7 @@ const { extractSelectivityEnabledEnvVariable } = require("./utils"); const is = utils.is; function provideRootDefault(name) { - return () => defaults[name]; + return () => _.get(defaults, name); } exports.getTopLevel = () => { @@ -57,7 +57,7 @@ exports.getPerBrowser = () => { function provideTopLevelDefault(name) { return config => { - const value = config[name]; + const value = _.get(config, name); if (_.isUndefined(value)) { throw new Error(`"${name}" should be set at the top level or per-browser option`); @@ -443,5 +443,15 @@ function buildBrowserOptions(defaultFactory, extra) { ...extractSelectivityEnabledEnvVariable(ENV_PREFIXES), }), }), + + stateOpts: section({ + path: option({ + defaultValue: defaultFactory("stateOpts.path"), + }), + cookies: options.optionalBoolean("stateOpts.cookies"), + localStorage: options.optionalBoolean("stateOpts.localStorage"), + sessionStorage: options.optionalBoolean("stateOpts.sessionStorage"), + keepFile: options.optionalBoolean("stateOpts.keepFile"), + }), }); } diff --git a/src/config/defaults.js b/src/config/defaults.js index bc0e496cf..3ecdf36e2 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -20,6 +20,13 @@ module.exports = { clustersSize: 10, stopOnFirstFail: false, }, + stateOpts: { + path: null, + cookies: true, + localStorage: true, + sessionStorage: true, + keepFile: false, + }, buildDiffOpts: { ignoreAntialiasing: true, ignoreCaret: true, diff --git a/src/config/types.ts b/src/config/types.ts index a5c341861..0fcd03d08 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,6 +1,6 @@ import type { BrowserConfig } from "./browser-config"; import type { BrowserTestRunEnvOptions } from "../runner/browser-env/vite/types"; -import type { Test } from "../types"; +import type { Cookie, Test } from "../types"; import type { ChildProcessWithoutNullStreams } from "child_process"; import type { RequestOptions } from "https"; import type { Config } from "./index"; @@ -282,6 +282,17 @@ export interface TimeTravelConfig { mode: TimeTravelMode; } +export type StateOpts = { + path?: string; + + cookies?: boolean; + localStorage?: boolean; + sessionStorage?: boolean; + + cookieFilter?: (cookie: Cookie) => boolean; + keepFile?: boolean; +}; + /** * @param {Object} dependency - Object with dependency scope and posix relative path * @param {"browser"|"testplane"|string} dependency.scope - Dependency scope @@ -336,6 +347,7 @@ export interface CommonConfig { buildDiffOpts: BuildDiffOptsConfig; assertViewOpts: AssertViewOpts; expectOpts: ExpectOptsConfig; + stateOpts?: StateOpts; meta: { [name: string]: unknown }; windowSize: { width: number; height: number } | `${number}x${number}` | null; orientation: "landscape" | "portrait" | null; diff --git a/src/events/index.ts b/src/events/index.ts index 781cc611a..f202b2e01 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -44,6 +44,8 @@ export const RunnerSyncEvents = { DOM_SNAPSHOTS: "domSnapshots", + ADD_FILE_TO_REMOVE: "addFileToRemove", + TEST_DEPENDENCIES: "testDependencies", } as const; diff --git a/src/globalFilesToRemove.ts b/src/globalFilesToRemove.ts new file mode 100644 index 000000000..afe40da8f --- /dev/null +++ b/src/globalFilesToRemove.ts @@ -0,0 +1,27 @@ +/* + This functional with global determines if it's running in a browser launched from beforeAll/afterAll hooks. + In this case, it does not delete the state file and passes it to a global variable for Testplane to clean up after all tests and hooks. + */ + +const TESTPLANE_FILES_TO_REMOVE = Symbol.for("testplaneFilesToRemove"); + +type TestplaneGlobal = typeof globalThis & { + [TESTPLANE_FILES_TO_REMOVE]?: string[]; +}; + +export const initGlobalFilesToRemove = (): void => { + (global as TestplaneGlobal)[TESTPLANE_FILES_TO_REMOVE] = []; +}; + +export const hasGlobalFilesToRemove = (): boolean => + Array.isArray((global as TestplaneGlobal)[TESTPLANE_FILES_TO_REMOVE]); + +export const getGlobalFilesToRemove = (): string[] => (global as TestplaneGlobal)[TESTPLANE_FILES_TO_REMOVE] || []; + +export const addGlobalFileToRemove = (path: string): void => { + const filesToRemove = (global as TestplaneGlobal)[TESTPLANE_FILES_TO_REMOVE]; + + if (filesToRemove) { + filesToRemove.push(path); + } +}; diff --git a/src/runner/index.ts b/src/runner/index.ts index 2d020c56a..85ec1b004 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -75,6 +75,7 @@ export class MainRunner extends RunnableEmitter { MasterEvents.NEW_WORKER_PROCESS, MasterEvents.ERROR, MasterEvents.DOM_SNAPSHOTS, + MasterEvents.ADD_FILE_TO_REMOVE, MasterEvents.TEST_DEPENDENCIES, ]); diff --git a/src/testplane.ts b/src/testplane.ts index 11add5958..17f9c0a0e 100644 --- a/src/testplane.ts +++ b/src/testplane.ts @@ -21,6 +21,7 @@ import { preloadWebdriverIO } from "./utils/preload-utils"; import { updateSelectivityHashes } from "./browser/cdp/selectivity"; import { TagFilter } from "./utils/cli"; import { ViteServer } from "./runner/browser-env/vite/server"; +import { getGlobalFilesToRemove, initGlobalFilesToRemove } from "./globalFilesToRemove"; interface RunOpts { browsers: string[]; @@ -78,6 +79,8 @@ export class Testplane extends BaseTestplane { protected runner: MainRunner | null; protected viteServer: ViteServer | null; + private _filesToRemove: string[]; + constructor(config?: string | ConfigInput) { super(config); @@ -85,12 +88,18 @@ export class Testplane extends BaseTestplane { this.failedList = []; this.runner = null; this.viteServer = null; + + this._filesToRemove = []; } extendCli(parser: Command): void { this.emit(MasterEvents.CLI, parser); } + addFileToRemove(path: string): void { + this._filesToRemove.push(path); + } + protected async _init(): Promise { await initDevServer({ testplane: this, @@ -156,6 +165,8 @@ export class Testplane extends BaseTestplane { this.on(MasterEvents.RUNNER_END, async () => await this._saveFailed()); + this.on(MasterEvents.ADD_FILE_TO_REMOVE, this.addFileToRemove); + await initReporters(reporters, this); eventsUtils.passthroughEvent(this.runner, this, _.values(MasterSyncEvents)); @@ -168,6 +179,8 @@ export class Testplane extends BaseTestplane { preloadWebdriverIO(); + initGlobalFilesToRemove(); + if (this.config.beforeAll) { await this.config.beforeAll.call({ config: this.config }, { config: this.config }); } @@ -190,6 +203,12 @@ export class Testplane extends BaseTestplane { await this.config.afterAll.call({ config: this.config }, { config: this.config }); } + const filesToRemove = [...new Set([...this._filesToRemove, ...getGlobalFilesToRemove()])]; + + if (filesToRemove.length > 0) { + await Promise.all(filesToRemove.map(path => fs.remove(path))); + } + return !this.isFailed(); } diff --git a/src/types/index.ts b/src/types/index.ts index 51dc80a84..cfe99857f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -258,6 +258,7 @@ export type MasterEventHandler = { (event: Events["ERROR"], callback: (err: Error) => void): T; (event: Events["UPDATE_REFERENCE"], callback: (data: { state: string; refImg: RefImageInfo }) => void): T; + (event: Events["ADD_FILE_TO_REMOVE"], callback: (path: string) => void): T; (event: Events["NEW_BROWSER"], callback: SyncSessionEventCallback): T; }; diff --git a/src/utils/workers-registry.js b/src/utils/workers-registry.js index e9b9c9438..760533388 100644 --- a/src/utils/workers-registry.js +++ b/src/utils/workers-registry.js @@ -139,6 +139,10 @@ module.exports = class WorkersRegistry extends EventEmitter { this.emit(MasterEvents.DOM_SNAPSHOTS, data.context, data.data); break; } + case MasterEvents.ADD_FILE_TO_REMOVE: { + this.emit(MasterEvents.ADD_FILE_TO_REMOVE, data.data); + break; + } case MasterEvents.TEST_DEPENDENCIES: { this.emit(MasterEvents.TEST_DEPENDENCIES, data.context, data.data); break; diff --git a/test/integration/standalone/standalone-save-state.test.ts b/test/integration/standalone/standalone-save-state.test.ts index 5d3990828..2adc931e7 100644 --- a/test/integration/standalone/standalone-save-state.test.ts +++ b/test/integration/standalone/standalone-save-state.test.ts @@ -1,3 +1,4 @@ +import fs from "fs"; import { strict as assert } from "assert"; import { launchBrowser } from "../../../src/browser/standalone"; import { BROWSER_CONFIG, BROWSER_NAME } from "./constants"; @@ -96,6 +97,57 @@ type AutomationProtocol = typeof DEVTOOLS_PROTOCOL | typeof WEBDRIVER_PROTOCOL; assert.strictEqual(await status.getText(), "You are logged in"); }); + it("saveState: {keepFile: true}", async function () { + await browser.saveState({ + keepFile: true, + path: "./state.json", + }); + + await browser.deleteSession(); + + const fileExist = fs.existsSync("./state.json"); + assert.strictEqual(fileExist, true); + fs.rmSync("./state.json"); + }); + + it("saveState: {keepFile: false}", async function () { + await browser.saveState({ + keepFile: false, + path: "./state.json", + }); + + await browser.deleteSession(); + + const fileExist = fs.existsSync("./state.json"); + assert.strictEqual(fileExist, false); + }); + + it("saveState: emptyState", async function () { + await browser.saveState({ + keepFile: true, + cookieFilter: () => false, + path: "./state.json", + localStorage: false, + sessionStorage: false, + }); + + await browser.deleteSession(); + + const fileExist = fs.existsSync("./state.json"); + assert.strictEqual(fileExist, false); + }); + + it("getState", async function () { + const options = { + path: "./state.json", + }; + + const saveResult = await browser.saveState(options); + const getResult = await browser.getState(options); + + assert.deepEqual(saveResult, getResult); + }); + it("restoreState", async function () { if (loginState) { removeDomainFromCookies(loginState); diff --git a/test/integration/standalone/standalone.test.ts b/test/integration/standalone/standalone.test.ts index dd1f929d0..44b13397c 100644 --- a/test/integration/standalone/standalone.test.ts +++ b/test/integration/standalone/standalone.test.ts @@ -16,11 +16,11 @@ describe("Standalone Browser E2E Tests", function () { setTimeout(() => { console.error( - "ERROR! Standalone test failed to complete in 120 seconds.\n" + + "ERROR! Standalone test failed to complete in 180 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(); + }, 180000).unref(); let browser: WebdriverIO.Browser & { getDriverPid?: () => number | undefined };