From e6c37bdf62ca9fe6f96fdd39338adc8c321f5d6e Mon Sep 17 00:00:00 2001 From: shadowusr Date: Fri, 15 May 2026 02:14:39 +0300 Subject: [PATCH 1/3] feat: implement saveState/restoreState tools --- packages/mcp/README.md | 16 ++ packages/mcp/test/server.e2e.test.ts | 2 + packages/tools/src/index.ts | 3 + packages/tools/src/tools/browser-state.ts | 205 ++++++++++++++++++ .../tools/test/tools/browser-state.test.ts | 180 +++++++++++++++ 5 files changed, 406 insertions(+) create mode 100644 packages/tools/src/tools/browser-state.ts create mode 100644 packages/tools/test/tools/browser-state.test.ts diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 4ece4bd..d25de7c 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -169,6 +169,22 @@ Launch a new browser session with custom configuration options. > **Note:** Testplane MCP automatically downloads Chrome and Firefox. To launch additional browsers (for example, Safari, Edge, or mobile-specific builds), use the `gridUrl` parameter to point to your Selenium grid. +### `save-state` +Save the current browser state to a JSON file. The saved state can include cookies, localStorage, and sessionStorage. + +- **Parameters:** + - `path` (string, required): Path to the JSON file. Relative paths are resolved from the current working directory. + - `cookies` (boolean, optional): Whether to include cookies. Default: `true`. + - `localStorage` (boolean, optional): Whether to include localStorage. Default: `true`. + - `sessionStorage` (boolean, optional): Whether to include sessionStorage. Default: `true`. + +### `restore-state` +Restore browser state from a JSON file. The tool restores whatever is available in the file. + +- **Parameters:** + - `path` (string, required): Path to the JSON state file. Relative paths are resolved from the current working directory. + - `refresh` (boolean, optional): Whether to reload the current page after restoring state. Default: `true`. When enabled, the page reloads so application code can immediately read restored cookies and storage. +
diff --git a/packages/mcp/test/server.e2e.test.ts b/packages/mcp/test/server.e2e.test.ts index 3ede9a7..48d4550 100644 --- a/packages/mcp/test/server.e2e.test.ts +++ b/packages/mcp/test/server.e2e.test.ts @@ -20,6 +20,8 @@ const EXPECTED_TOOL_NAMES = [ "switch-tab", "new-tab", "close-tab", + "save-state", + "restore-state", // report tools "test-results", "inspect-result", diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index d95057b..bcf1214 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -17,6 +17,7 @@ import { attachToBrowser } from "./tools/attach-to-browser.js"; import { attachRepl } from "./tools/attach-repl.js"; import { closeBrowser } from "./tools/close-browser.js"; import { runCode } from "./tools/run-code.js"; +import { saveState, restoreState } from "./tools/browser-state.js"; import { inspectResult } from "./tools/inspect-result/index.js"; import { testResults } from "./tools/test-results/index.js"; import { timeTravelSnapshot } from "./tools/time-travel-snapshot/index.js"; @@ -44,6 +45,8 @@ export const tools = typeCheckedTools([ attachToBrowser, attachRepl, closeBrowser, + saveState, + restoreState, runCode, testResults, inspectResult, diff --git a/packages/tools/src/tools/browser-state.ts b/packages/tools/src/tools/browser-state.ts new file mode 100644 index 0000000..6c4b83f --- /dev/null +++ b/packages/tools/src/tools/browser-state.ts @@ -0,0 +1,205 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import type { SaveStateData, WdioBrowser } from "testplane"; + +import { ActionTool, ToolKind } from "../types.js"; +import { createBrowserStateResponse, createErrorResponse } from "../responses/index.js"; + +type BrowserWithState = WdioBrowser & { + saveState(options?: { + cookies?: boolean; + localStorage?: boolean; + sessionStorage?: boolean; + }): Promise; + restoreState(options?: { data?: SaveStateData; refresh?: boolean }): Promise; +}; + +interface StateSummary { + cookies: number; + origins: number; + localStorageItems: number; + sessionStorageItems: number; +} + +const filePathSchema = z + .string() + .trim() + .min(1, "Path must not be empty") + .describe( + "Path to the JSON file with saved browser state. Relative paths are resolved from the current working directory.", + ); + +export const saveStateSchema = { + path: filePathSchema, + cookies: z.boolean().optional().describe("Whether to include cookies in the saved state. Default: true"), + localStorage: z.boolean().optional().describe("Whether to include localStorage in the saved state. Default: true"), + sessionStorage: z + .boolean() + .optional() + .describe("Whether to include sessionStorage in the saved state. Default: true"), +}; + +export const restoreStateSchema = { + path: filePathSchema, + refresh: z + .boolean() + .optional() + .describe( + "Whether to reload the current page after restoring state. Default: true. Reloading makes the page observe restored cookies and storage immediately.", + ), +}; + +function resolveStatePath(filePath: string): string { + return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath); +} + +function getStateSummary(data: SaveStateData): StateSummary { + const framesData = data.framesData ?? {}; + const frameValues = Object.values(framesData); + + return { + cookies: data.cookies?.length ?? 0, + origins: Object.keys(framesData).length, + localStorageItems: frameValues.reduce( + (count, frameData) => count + Object.keys(frameData.localStorage ?? {}).length, + 0, + ), + sessionStorageItems: frameValues.reduce( + (count, frameData) => count + Object.keys(frameData.sessionStorage ?? {}).length, + 0, + ), + }; +} + +function formatStateSummary(summary: StateSummary): string { + return [ + `Cookies: ${summary.cookies}`, + `Origins with storage: ${summary.origins}`, + `localStorage items: ${summary.localStorageItems}`, + `sessionStorage items: ${summary.sessionStorageItems}`, + ].join("\n"); +} + +function ensureSaveStateData(data: unknown): SaveStateData { + if (!data || typeof data !== "object" || Array.isArray(data)) { + throw new Error("State file must contain a JSON object"); + } + + const record = data as Record; + if (record.cookies !== undefined && !Array.isArray(record.cookies)) { + throw new Error('"cookies" must be an array when present'); + } + + if (!record.framesData || typeof record.framesData !== "object" || Array.isArray(record.framesData)) { + throw new Error('"framesData" must be an object'); + } + + return data as SaveStateData; +} + +function stringifyState(data: SaveStateData): string { + return JSON.stringify(data, null, 2) + "\n"; +} + +function createSaveOptions(args: { cookies?: boolean; localStorage?: boolean; sessionStorage?: boolean }): { + cookies?: boolean; + localStorage?: boolean; + sessionStorage?: boolean; +} { + const options: { + cookies?: boolean; + localStorage?: boolean; + sessionStorage?: boolean; + } = {}; + + if (args.cookies !== undefined) { + options.cookies = args.cookies; + } + if (args.localStorage !== undefined) { + options.localStorage = args.localStorage; + } + if (args.sessionStorage !== undefined) { + options.sessionStorage = args.sessionStorage; + } + + return options; +} + +const saveStateCb: ActionTool["cb"] = async (args, browser) => { + try { + const resolvedPath = resolveStatePath(args.path); + const saveOptions = createSaveOptions(args); + const data = await browser.saveState(saveOptions); + + await mkdir(path.dirname(resolvedPath), { recursive: true }); + await writeFile(resolvedPath, stringifyState(data), "utf8"); + + return await createBrowserStateResponse(browser, { + action: `Saved browser state to ${resolvedPath}`, + testplaneCode: `const state = await browser.saveState(${JSON.stringify(saveOptions, null, 2)});`, + additionalInfo: formatStateSummary(getStateSummary(data)), + isSnapshotNeeded: false, + }); + } catch (error) { + console.error("Error saving browser state:", error); + return createErrorResponse("Error saving browser state", error instanceof Error ? error : undefined); + } +}; + +const restoreStateCb: ActionTool["cb"] = async (args, browser) => { + try { + const resolvedPath = resolveStatePath(args.path); + const data = ensureSaveStateData(JSON.parse(await readFile(resolvedPath, "utf8"))); + const refresh = args.refresh ?? true; + + await browser.restoreState({ + data, + refresh, + }); + + return await createBrowserStateResponse(browser, { + action: `Restored browser state from ${resolvedPath}`, + testplaneCode: `await browser.restoreState({ data: state, refresh: ${JSON.stringify(refresh)} });`, + additionalInfo: [ + formatStateSummary(getStateSummary(data)), + `Refresh: ${refresh ? "enabled" : "disabled"}${ + refresh + ? " - the current page was reloaded after restore so it can read the restored state." + : " - the current page was not reloaded after restore." + }`, + ].join("\n"), + isSnapshotNeeded: false, + }); + } catch (error) { + console.error("Error restoring browser state:", error); + return createErrorResponse("Error restoring browser state", error instanceof Error ? error : undefined); + } +}; + +export const saveState: ActionTool = { + kind: ToolKind.Action, + name: "save-state", + description: "Save the current browser state, including cookies and web storage, to a JSON file", + supportedTransports: ["launch-browser"], + schema: saveStateSchema, + cb: saveStateCb, + cli: { + positional: ["path"], + section: "State", + }, +}; + +export const restoreState: ActionTool = { + kind: ToolKind.Action, + name: "restore-state", + description: + "Restore browser state from a JSON file. By default the page is refreshed after restore so it can observe restored cookies and web storage.", + supportedTransports: ["launch-browser"], + schema: restoreStateSchema, + cb: restoreStateCb, + cli: { + positional: ["path"], + section: "State", + }, +}; diff --git a/packages/tools/test/tools/browser-state.test.ts b/packages/tools/test/tools/browser-state.test.ts new file mode 100644 index 0000000..d01c367 --- /dev/null +++ b/packages/tools/test/tools/browser-state.test.ts @@ -0,0 +1,180 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { WdioBrowser } from "testplane"; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; + +import { restoreState, saveState } from "../../src/tools/browser-state.js"; +import { PlaygroundServer } from "../test-server.js"; +import { launchHeadlessBrowser, getTextContent } from "../setup.js"; +import { INTEGRATION_TEST_TIMEOUT } from "../constants.js"; + +describe( + "tools/browser-state", + () => { + let browser: WdioBrowser; + let testServer: PlaygroundServer; + let playgroundUrl: string; + let tempDir: string; + + beforeAll(async () => { + testServer = new PlaygroundServer(); + playgroundUrl = await testServer.start(); + browser = await launchHeadlessBrowser(); + tempDir = await mkdtemp(path.join(os.tmpdir(), "testplane-mcp-state-")); + }, 20000); + + afterAll(async () => { + if (browser) await browser.deleteSession(); + if (testServer) await testServer.stop(); + if (tempDir) await rm(tempDir, { recursive: true, force: true }); + }); + + beforeEach(async () => { + await browser.url(playgroundUrl); + await clearBrowserState(browser); + }); + + it("saves and restores cookies, localStorage, and sessionStorage from a file", async () => { + const statePath = path.join(tempDir, "full-state.json"); + + await setBrowserState(browser, { + cookie: "saved-cookie", + localStorage: "saved-local", + sessionStorage: "saved-session", + }); + + const saveResult = await saveState.cb({ path: statePath }, browser); + expect(saveResult.isError).toBe(false); + expect(getTextContent(saveResult)).toContain(`Saved browser state to ${statePath}`); + + await clearBrowserState(browser); + await expect(readBrowserState(browser)).resolves.toEqual({ + cookie: "", + localStorage: null, + sessionStorage: null, + }); + + const restoreResult = await restoreState.cb({ path: statePath }, browser); + expect(restoreResult.isError).toBe(false); + + const text = getTextContent(restoreResult); + expect(text).toContain(`Restored browser state from ${statePath}`); + expect(text).toContain("Refresh: enabled - the current page was reloaded after restore"); + + expect(await readBrowserState(browser)).toEqual({ + cookie: "saved-cookie", + localStorage: "saved-local", + sessionStorage: "saved-session", + }); + }); + + it("can skip selected state kinds when saving", async () => { + const statePath = path.join(tempDir, "storage-only-state.json"); + + await setBrowserState(browser, { + cookie: "excluded-cookie", + localStorage: "included-local", + sessionStorage: "excluded-session", + }); + + const result = await saveState.cb( + { + path: statePath, + cookies: false, + localStorage: true, + sessionStorage: false, + }, + browser, + ); + + expect(result.isError).toBe(false); + + const savedState = JSON.parse(await readFile(statePath, "utf8")) as { + cookies?: unknown[]; + framesData: Record; sessionStorage?: unknown }>; + }; + const frameData = savedState.framesData[new URL(playgroundUrl).origin]; + + expect(savedState.cookies).toBeUndefined(); + expect(frameData.localStorage).toEqual({ mcpLocalState: "included-local" }); + expect(frameData.sessionStorage).toBeUndefined(); + }); + + it("writes an empty state file", async () => { + const statePath = path.join(tempDir, "empty-state.json"); + + const result = await saveState.cb( + { + path: statePath, + cookies: false, + localStorage: false, + sessionStorage: false, + }, + browser, + ); + + expect(result.isError).toBe(false); + expect(JSON.parse(await readFile(statePath, "utf8"))).toEqual({ framesData: {} }); + }); + + it("surfaces invalid restore files as tool errors", async () => { + const statePath = path.join(tempDir, "invalid-state.json"); + await writeFile(statePath, "{not-json", "utf8"); + + const result = await restoreState.cb({ path: statePath }, browser); + + expect(result.isError).toBe(true); + expect(getTextContent(result)).toContain("Error restoring browser state"); + }); + + it("explains that a real page must be opened before saving state", async () => { + await browser.url("about:blank"); + + const result = await saveState.cb({ path: path.join(tempDir, "about-blank-state.json") }, browser); + + expect(result.isError).toBe(true); + expect(getTextContent(result)).toContain("Before saveState first open page using url command"); + }); + }, + INTEGRATION_TEST_TIMEOUT, +); + +async function setBrowserState( + browser: WdioBrowser, + values: { cookie: string; localStorage: string; sessionStorage: string }, +): Promise { + await browser.execute((state: typeof values) => { + document.cookie = `mcpState=${state.cookie}; path=/`; + window.localStorage.setItem("mcpLocalState", state.localStorage); + window.sessionStorage.setItem("mcpSessionState", state.sessionStorage); + }, values); +} + +async function clearBrowserState(browser: WdioBrowser): Promise { + await browser.execute(() => { + document.cookie = "mcpState=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/"; + window.localStorage.clear(); + window.sessionStorage.clear(); + }); +} + +async function readBrowserState(browser: WdioBrowser): Promise<{ + cookie: string; + localStorage: string | null; + sessionStorage: string | null; +}> { + return browser.execute(() => { + const cookie = + document.cookie + .split("; ") + .find(value => value.startsWith("mcpState=")) + ?.slice("mcpState=".length) ?? ""; + + return { + cookie, + localStorage: window.localStorage.getItem("mcpLocalState"), + sessionStorage: window.sessionStorage.getItem("mcpSessionState"), + }; + }); +} From ca74660efe7e637f3731b021e5b2a2d093383d6e Mon Sep 17 00:00:00 2001 From: shadowusr Date: Fri, 15 May 2026 02:18:14 +0300 Subject: [PATCH 2/3] chore: bump html-reporter and testplane versions --- packages/cli/package.json | 4 ++-- packages/mcp/package.json | 4 ++-- packages/tools/package.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 37dc28c..f8c28a9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -29,8 +29,8 @@ "@rrweb/replay": "^2.0.0-alpha.18", "commander": "^14.0.3", "debug": "^4.3.4", - "html-reporter": "^11.10.0-rc.2", - "testplane": "^8.47.1", + "html-reporter": "11.11.0", + "testplane": "8.47.1", "zod": "^3.22.4" }, "devDependencies": { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 3adc0a4..d177e17 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -29,8 +29,8 @@ "@testplane/testing-library": "^1.0.5", "@rrweb/replay": "^2.0.0-alpha.18", "commander": "^13.1.0", - "html-reporter": "^11.10.0-rc.2", - "testplane": "^8.47.1", + "html-reporter": "11.11.0", + "testplane": "8.47.1", "zod": "^3.22.4" }, "devDependencies": { diff --git a/packages/tools/package.json b/packages/tools/package.json index f173acf..35f2e2d 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -32,9 +32,9 @@ "@rrweb/types": "^2.0.0-alpha.18", "acorn": "^8.16.0", "fflate": "^0.8.2", - "html-reporter": "^11.10.0-rc.2", + "html-reporter": "11.11.0", "lodash.escaperegexp": "^4.1.2", - "testplane": "^8.47.1", + "testplane": "8.47.1", "zod": "^3.22.4" }, "devDependencies": { From 883ccdeac780fcd30b3af339245d3d09ab694dc8 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Fri, 15 May 2026 13:42:55 +0300 Subject: [PATCH 3/3] fix: specify concrete dependencies versions and bump package versions --- package-lock.json | 22 +++++++++++----------- packages/cli/package.json | 2 +- packages/mcp/package.json | 2 +- packages/tools/package.json | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 023a521..70ff417 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5561,9 +5561,9 @@ } }, "node_modules/html-reporter": { - "version": "11.10.0-rc.2", - "resolved": "https://registry.npmjs.org/html-reporter/-/html-reporter-11.10.0-rc.2.tgz", - "integrity": "sha512-znXwRf4t/jb3Ksg2bgmAFXCLV3kSYZOg1pUKGTjoJaatxp+HhXmit/7IGbrzWY7aQdlgR9Ak5yaQrn1iETeR3A==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/html-reporter/-/html-reporter-11.11.0.tgz", + "integrity": "sha512-ePzo2cuyVV6LUANJsxnUsdr+ZX0HGrgKE0DQJuHkMuey4JIN9u/IaxwqEx0ziiWGsCF4qIjksC5urppYRGv23w==", "license": "MIT", "workspaces": [ "test/func/fixtures/*", @@ -10883,15 +10883,15 @@ }, "packages/cli": { "name": "@testplane/cli", - "version": "0.0.1", + "version": "0.1.0", "license": "ISC", "dependencies": { "@rrweb/replay": "^2.0.0-alpha.18", "@testplane/testing-library": "^1.0.5", "commander": "^14.0.3", "debug": "^4.3.4", - "html-reporter": "^11.10.0-rc.2", - "testplane": "^8.47.1", + "html-reporter": "11.11.0", + "testplane": "8.47.1", "zod": "^3.22.4" }, "bin": { @@ -11382,15 +11382,15 @@ }, "packages/mcp": { "name": "@testplane/mcp", - "version": "0.6.0", + "version": "0.7.0", "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.11.2", "@rrweb/replay": "^2.0.0-alpha.18", "@testplane/testing-library": "^1.0.5", "commander": "^13.1.0", - "html-reporter": "^11.10.0-rc.2", - "testplane": "^8.47.1", + "html-reporter": "11.11.0", + "testplane": "8.47.1", "zod": "^3.22.4" }, "bin": { @@ -11878,9 +11878,9 @@ "@testplane/testing-library": "^1.0.5", "acorn": "^8.16.0", "fflate": "^0.8.2", - "html-reporter": "^11.10.0-rc.2", + "html-reporter": "11.11.0", "lodash.escaperegexp": "^4.1.2", - "testplane": "^8.47.1", + "testplane": "8.47.1", "zod": "^3.22.4" }, "devDependencies": { diff --git a/packages/cli/package.json b/packages/cli/package.json index f8c28a9..ad3b4dc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@testplane/cli", - "version": "0.0.1", + "version": "0.1.0", "description": "Daemon-backed CLI for Testplane browser-automation tools", "main": "build/cli.js", "type": "module", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index d177e17..4ca6757 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@testplane/mcp", - "version": "0.6.0", + "version": "0.7.0", "description": "MCP server for Testplane tool", "main": "build/cli.js", "type": "module", diff --git a/packages/tools/package.json b/packages/tools/package.json index 35f2e2d..6e7201a 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -2,7 +2,7 @@ "name": "@testplane/tools", "version": "0.0.0", "private": true, - "description": "Testplane MCP tools — browser automation tool implementations", + "description": "Testplane MCP tools — browser automation tools implementation", "type": "module", "main": "build/index.js", "types": "build/index.d.ts",