Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8,932 changes: 5,811 additions & 3,121 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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.44.1-rc.1",
"html-reporter": "11.9.4",
"testplane": "8.44.2-rc.1",
"zod": "^3.22.4"
},
"devDependencies": {
Expand Down
16 changes: 16 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</details>

<details>
Expand Down
6 changes: 3 additions & 3 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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.44.1-rc.1",
"html-reporter": "11.9.4",
"testplane": "8.44.2-rc.1",
"zod": "^3.22.4"
},
"devDependencies": {
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp/test/server.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const EXPECTED_TOOL_NAMES = [
"switch-tab",
"new-tab",
"close-tab",
"save-state",
"restore-state",
// report tools
"test-results",
"inspect-result",
Expand Down
4 changes: 2 additions & 2 deletions packages/tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.9.4",
"lodash.escaperegexp": "^4.1.2",
"testplane": "^8.44.1-rc.1",
"testplane": "8.44.2-rc.1",
"zod": "^3.22.4"
},
"devDependencies": {
Expand Down
3 changes: 3 additions & 0 deletions packages/tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -44,6 +45,8 @@ export const tools = typeCheckedTools([
attachToBrowser,
attachRepl,
closeBrowser,
saveState,
restoreState,
runCode,
testResults,
inspectResult,
Expand Down
205 changes: 205 additions & 0 deletions packages/tools/src/tools/browser-state.ts
Original file line number Diff line number Diff line change
@@ -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<SaveStateData>;
restoreState(options?: { data?: SaveStateData; refresh?: boolean }): Promise<void>;
};

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<string, unknown>;
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<typeof saveStateSchema, BrowserWithState>["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<typeof restoreStateSchema, BrowserWithState>["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<typeof saveStateSchema, BrowserWithState> = {
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<typeof restoreStateSchema, BrowserWithState> = {
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",
},
};
Loading