Skip to content

Commit 7bdb274

Browse files
sonic16xrocketraccoon
andauthored
feat(keep-browser): keep browser alive after end of tests
* feat(keep-browser): keep browser alive after end of tests * feat(standalone): attach browser for standalone api * feat(standalone): attach browser for standalone api #1 * feat(standalone): kill driver process * feat(standalone): kill driver process by pid * feat(standalone): kill driver process by pid * feat(standalone): add tests * feat(standalone): add tests 2 * feat(standalone): add tests 3 --------- Co-authored-by: rocketraccoon <rocketraccoon@yandex-team.ru>
1 parent a1c57b4 commit 7bdb274

26 files changed

Lines changed: 515 additions & 76 deletions

File tree

docs/debugging.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,62 @@ Testpalne supports [OpenTelemetry](https://opentelemetry.io) standard for tracin
77

88
You can use `traceparent` to trace the execution of individual tests.
99

10+
### Keep Browser for Debugging
11+
12+
Testplane provides CLI options to keep browser sessions alive after test completion for debugging purposes. This feature has two main use cases:
13+
14+
1. **Local debugging**: After test fails, you can still interact with the browser and see what's wrong
15+
2. **AI agents integration**: Let AI agents run tests to perform complex logic (e.g. custom authentication), then attach to browser via MCP and perform additional actions
16+
17+
#### Why not REPL?
18+
19+
While REPL mode pauses test execution for interactive debugging, keep-browser options preserve the final browser state after tests finish. For AI agents, it's much easier to say "write test with the same beforeEach as in this file and run it to prepare browser" rather than forcing AI to use REPL interactively.
20+
21+
#### `--keep-browser`
22+
23+
Keep browser session alive after test completion for debugging.
24+
25+
```bash
26+
npx testplane --keep-browser --browser chrome tests/login.test.js
27+
```
28+
29+
#### `--keep-browser-on-fail`
30+
31+
Keep browser session alive only when test fails for debugging.
32+
33+
```bash
34+
npx testplane --keep-browser-on-fail --browser chrome tests/login.test.js
35+
```
36+
37+
**Note**: These options work only when running a single test in a single browser.
38+
39+
#### Session Information
40+
41+
When a browser is kept alive, Testplane outputs a message with session information for programmatic access:
42+
43+
```
44+
Testplane run has finished, but the browser won't be closed, because you passed the --keep-browser argument.
45+
You may attach to this browser using the following capabilities:
46+
{
47+
"sessionId": "abc123...",
48+
"capabilities": {
49+
"browserName": "chrome",
50+
"debuggerAddress": "127.0.0.1:9222"
51+
},
52+
"sessionOptions": {
53+
"hostname": "127.0.0.1",
54+
"port": 4444,
55+
"path": "/wd/hub",
56+
"protocol": "http"
57+
}
58+
}
59+
```
60+
61+
#### Attaching to Kept Session
62+
63+
You can use the outputted session information to attach to the kept browser through:
64+
65+
- **MCP (Model Context Protocol)** tools that support WebDriver session attachment
66+
- **CDP (Chrome DevTools Protocol)** using the `debuggerAddress` from capabilities
67+
- **Direct WebDriver** connection using the session ID and connection details
68+
- **Custom automation tools** that can reuse existing browser sessions

src/browser-installer/chrome/index.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import { getMilestone } from "../utils";
77
import { installChrome, resolveLatestChromeVersion } from "./browser";
88
import { installChromeDriver } from "./driver";
99
import { isUbuntu, getUbuntuLinkerEnv } from "../ubuntu-packages";
10+
import RuntimeConfig from "../../config/runtime-config";
1011

1112
export { installChrome, resolveLatestChromeVersion, installChromeDriver };
1213

1314
export const runChromeDriver = async (
1415
chromeVersion: string,
1516
{ debug = false } = {},
16-
): Promise<{ gridUrl: string; process: ChildProcess; port: number }> => {
17+
): Promise<{ gridUrl: string; process: ChildProcess; port: number; kill: () => void }> => {
1718
const [chromeDriverPath, randomPort, chromeDriverEnv] = await Promise.all([
1819
installChromeDriver(chromeVersion),
1920
getPort(),
@@ -22,9 +23,12 @@ export const runChromeDriver = async (
2223
.then(extraEnv => (extraEnv ? { ...process.env, ...extraEnv } : process.env)),
2324
]);
2425

26+
const runtimeConfig = RuntimeConfig.getInstance();
27+
const keepBrowserModeEnabled = runtimeConfig.keepBrowserMode?.enabled;
28+
2529
const chromeDriver = spawn(chromeDriverPath, [`--port=${randomPort}`, debug ? `--verbose` : "--silent"], {
2630
windowsHide: true,
27-
detached: false,
31+
detached: keepBrowserModeEnabled || false,
2832
env: chromeDriverEnv,
2933
});
3034

@@ -34,7 +38,9 @@ export const runChromeDriver = async (
3438

3539
const gridUrl = `http://127.0.0.1:${randomPort}`;
3640

37-
process.once("exit", () => chromeDriver.kill());
41+
if (!keepBrowserModeEnabled) {
42+
process.once("exit", () => chromeDriver.kill());
43+
}
3844

3945
await waitPort({
4046
port: randomPort,
@@ -43,5 +49,10 @@ export const runChromeDriver = async (
4349
interval: DRIVER_WAIT_INTERVAL,
4450
});
4551

46-
return { gridUrl, process: chromeDriver, port: randomPort };
52+
return {
53+
gridUrl,
54+
process: chromeDriver,
55+
port: randomPort,
56+
kill: () => chromeDriver.kill(),
57+
};
4758
};

src/browser-installer/edge/index.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,23 @@ import getPort from "get-port";
44
import waitPort from "wait-port";
55
import { pipeLogsWithPrefix } from "../../dev-server/utils";
66
import { DRIVER_WAIT_INTERVAL, DRIVER_WAIT_TIMEOUT } from "../constants";
7+
import RuntimeConfig from "../../config/runtime-config";
78

89
export { resolveEdgeVersion } from "./browser";
910
export { installEdgeDriver };
1011

1112
export const runEdgeDriver = async (
1213
edgeVersion: string,
1314
{ debug = false }: { debug?: boolean } = {},
14-
): Promise<{ gridUrl: string; process: ChildProcess; port: number }> => {
15+
): Promise<{ gridUrl: string; process: ChildProcess; port: number; kill: () => void }> => {
1516
const [edgeDriverPath, randomPort] = await Promise.all([installEdgeDriver(edgeVersion), getPort()]);
1617

18+
const runtimeConfig = RuntimeConfig.getInstance();
19+
const keepBrowserModeEnabled = runtimeConfig.keepBrowserMode?.enabled;
20+
1721
const edgeDriver = spawn(edgeDriverPath, [`--port=${randomPort}`, debug ? `--verbose` : "--silent"], {
1822
windowsHide: true,
19-
detached: false,
23+
detached: keepBrowserModeEnabled || false,
2024
});
2125

2226
if (debug) {
@@ -25,7 +29,9 @@ export const runEdgeDriver = async (
2529

2630
const gridUrl = `http://127.0.0.1:${randomPort}`;
2731

28-
process.once("exit", () => edgeDriver.kill());
32+
if (!keepBrowserModeEnabled) {
33+
process.once("exit", () => edgeDriver.kill());
34+
}
2935

3036
await waitPort({
3137
port: randomPort,
@@ -34,5 +40,5 @@ export const runEdgeDriver = async (
3440
interval: DRIVER_WAIT_INTERVAL,
3541
});
3642

37-
return { gridUrl, process: edgeDriver, port: randomPort };
43+
return { gridUrl, process: edgeDriver, port: randomPort, kill: () => edgeDriver.kill() };
3844
};

src/browser-installer/firefox/index.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import { installLatestGeckoDriver } from "./driver";
77
import { pipeLogsWithPrefix } from "../../dev-server/utils";
88
import { DRIVER_WAIT_INTERVAL, DRIVER_WAIT_TIMEOUT } from "../constants";
99
import { getUbuntuLinkerEnv, isUbuntu } from "../ubuntu-packages";
10+
import RuntimeConfig from "../../config/runtime-config";
1011

1112
export { installFirefox, resolveLatestFirefoxVersion, installLatestGeckoDriver };
1213

1314
export const runGeckoDriver = async (
1415
firefoxVersion: string,
1516
{ debug = false } = {},
16-
): Promise<{ gridUrl: string; process: ChildProcess; port: number }> => {
17+
): Promise<{ gridUrl: string; process: ChildProcess; port: number; kill: () => void }> => {
1718
const [geckoDriverPath, randomPort, geckoDriverEnv] = await Promise.all([
1819
installLatestGeckoDriver(firefoxVersion),
1920
getPort(),
@@ -22,13 +23,16 @@ export const runGeckoDriver = async (
2223
.then(extraEnv => (extraEnv ? { ...process.env, ...extraEnv } : process.env)),
2324
]);
2425

26+
const runtimeConfig = RuntimeConfig.getInstance();
27+
const keepBrowserModeEnabled = runtimeConfig.keepBrowserMode?.enabled;
28+
2529
const geckoDriver = await startGeckoDriver({
2630
customGeckoDriverPath: geckoDriverPath,
2731
port: randomPort,
2832
log: debug ? "debug" : "fatal",
2933
spawnOpts: {
3034
windowsHide: true,
31-
detached: false,
35+
detached: keepBrowserModeEnabled || false,
3236
env: geckoDriverEnv,
3337
},
3438
});
@@ -39,7 +43,9 @@ export const runGeckoDriver = async (
3943

4044
const gridUrl = `http://127.0.0.1:${randomPort}`;
4145

42-
process.once("exit", () => geckoDriver.kill());
46+
if (!keepBrowserModeEnabled) {
47+
process.once("exit", () => geckoDriver.kill());
48+
}
4349

4450
await waitPort({
4551
port: randomPort,
@@ -48,5 +54,5 @@ export const runGeckoDriver = async (
4854
interval: DRIVER_WAIT_INTERVAL,
4955
});
5056

51-
return { gridUrl, process: geckoDriver, port: randomPort };
57+
return { gridUrl, process: geckoDriver, port: randomPort, kill: () => geckoDriver.kill() };
5258
};

src/browser-installer/safari/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,24 @@ import getPort from "get-port";
33
import waitPort from "wait-port";
44
import { pipeLogsWithPrefix } from "../../dev-server/utils";
55
import { DRIVER_WAIT_INTERVAL, DRIVER_WAIT_TIMEOUT, SAFARIDRIVER_PATH } from "../constants";
6+
import RuntimeConfig from "../../config/runtime-config";
67

78
export { resolveSafariVersion } from "./browser";
89

910
export const runSafariDriver = async ({ debug = false }: { debug?: boolean } = {}): Promise<{
1011
gridUrl: string;
1112
process: ChildProcess;
1213
port: number;
14+
kill: () => void;
1315
}> => {
1416
const randomPort = await getPort();
1517

18+
const runtimeConfig = RuntimeConfig.getInstance();
19+
const keepBrowserModeEnabled = runtimeConfig.keepBrowserMode?.enabled;
20+
1621
const safariDriver = spawn(SAFARIDRIVER_PATH, [`--port=${randomPort}`], {
1722
windowsHide: true,
18-
detached: false,
23+
detached: keepBrowserModeEnabled || false,
1924
});
2025

2126
if (debug) {
@@ -24,7 +29,9 @@ export const runSafariDriver = async ({ debug = false }: { debug?: boolean } = {
2429

2530
const gridUrl = `http://127.0.0.1:${randomPort}`;
2631

27-
process.once("exit", () => safariDriver.kill());
32+
if (!keepBrowserModeEnabled) {
33+
process.once("exit", () => safariDriver.kill());
34+
}
2835

2936
await waitPort({
3037
port: randomPort,
@@ -33,5 +40,5 @@ export const runSafariDriver = async ({ debug = false }: { debug?: boolean } = {
3340
interval: DRIVER_WAIT_INTERVAL,
3441
});
3542

36-
return { gridUrl, process: safariDriver, port: randomPort };
43+
return { gridUrl, process: safariDriver, port: randomPort, kill: () => safariDriver.kill() };
3744
};

src/browser-pool/webdriver-pool.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { SupportedBrowser } from "../browser-installer";
55
type BrowserVersion = string;
66
type Port = string;
77
type ChildProcessWithStatus = { process: ChildProcess; gridUrl: string; isBusy: boolean };
8-
export type WdProcess = { gridUrl: string; free: () => void; kill: () => void };
8+
export type WdProcess = { gridUrl: string; free: () => void; kill: () => void; getPid: () => number | undefined };
99

1010
export class WebdriverPool {
1111
private driverProcess: Map<SupportedBrowser, Map<BrowserVersion, Record<Port, ChildProcessWithStatus>>>;
@@ -45,6 +45,7 @@ export class WebdriverPool {
4545
gridUrl: wdProcesses[port].gridUrl,
4646
free: () => this.freeWebdriver(port),
4747
kill: () => this.killWebdriver(browserNameNormalized, browserVersionNormalized, port),
48+
getPid: () => wdProcesses[port].process.pid,
4849
};
4950
}
5051
}
@@ -98,6 +99,7 @@ export class WebdriverPool {
9899
gridUrl: driver.gridUrl,
99100
free: () => this.freeWebdriver(String(driver.port)),
100101
kill: () => this.killWebdriver(browserName, browserVersion, String(driver.port)),
102+
getPid: () => driver.process.pid,
101103
};
102104
}
103105
}

src/browser/browser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export type BrowserState = {
3939
testXReqId?: string;
4040
traceparent?: string;
4141
isBroken?: boolean;
42+
isLastTestFailed?: boolean;
4243
};
4344

4445
export type CustomCommand = { name: string; elementScope: boolean };
@@ -76,6 +77,7 @@ export class Browser {
7677
this._state = {
7778
...opts.state,
7879
isBroken: false,
80+
isLastTestFailed: false,
7981
};
8082
this._customCommands = new Set();
8183
this._wdPool = opts.wdPool;

src/browser/existing-browser.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,11 @@ import { Config } from "../config";
1616
import { Image, Rect } from "../image";
1717
import type { CalibrationResult, Calibrator } from "./calibrator";
1818
import { NEW_ISSUE_LINK } from "../constants/help";
19-
import type { Options } from "@testplane/wdio-types";
2019
import { runWithoutHistory } from "./history";
20+
import type { SessionOptions } from "./types";
2121

2222
const OPTIONAL_SESSION_OPTS = ["transformRequest", "transformResponse"];
2323

24-
interface SessionOptions {
25-
sessionId: string;
26-
sessionCaps?: WebdriverIO.Capabilities;
27-
sessionOpts?: Options.WebdriverIO & { capabilities: WebdriverIO.Capabilities };
28-
}
29-
3024
interface PrepareScreenshotOpts {
3125
disableAnimation?: boolean;
3226
// TODO: specify the rest of the options

src/browser/new-browser.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ export class NewBrowser extends Browser {
103103
}
104104
}
105105

106+
getDriverPid(): number | undefined {
107+
if (this._wdProcess) {
108+
return this._wdProcess.getPid();
109+
}
110+
111+
return undefined;
112+
}
113+
106114
protected async _createSession(): Promise<WebdriverIO.Browser> {
107115
const sessionOpts = await this._getSessionOpts();
108116

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Config } from "../../config";
2+
import { ExistingBrowser } from "./../existing-browser";
3+
import { Calibrator } from "./../calibrator";
4+
import { AsyncEmitter } from "../../events";
5+
import { BrowserName, type W3CBrowserName, type SessionOptions } from "./../types";
6+
import { getNormalizedBrowserName } from "../../utils/browser";
7+
8+
export async function attachToBrowser(session: SessionOptions): Promise<WebdriverIO.Browser> {
9+
const browserName = session.sessionCaps?.browserName || BrowserName.CHROME;
10+
const normalizedBrowserName = getNormalizedBrowserName(browserName) as W3CBrowserName;
11+
12+
if (!normalizedBrowserName) {
13+
throw new Error(
14+
[
15+
`Running browser "${browserName}" is unsupported`,
16+
`Supported browsers: "chrome", "firefox", "safari", "edge"`,
17+
].join("\n"),
18+
);
19+
}
20+
21+
const browserConfig = {
22+
desiredCapabilities: {
23+
browserName,
24+
},
25+
};
26+
27+
const config = new Config({
28+
browsers: {
29+
[browserName]: browserConfig,
30+
},
31+
});
32+
33+
if (!process.env.WDIO_LOG_LEVEL) {
34+
process.env.WDIO_LOG_LEVEL = config.system.debug ? "trace" : "error";
35+
}
36+
37+
const emitter = new AsyncEmitter();
38+
39+
const existingBrowser = new ExistingBrowser(config, {
40+
id: browserName,
41+
version: session.sessionCaps?.browserVersion,
42+
emitter,
43+
state: {},
44+
});
45+
46+
const calibrator = new Calibrator();
47+
48+
await existingBrowser.init(session, calibrator);
49+
50+
existingBrowser.publicAPI.overwriteCommand("deleteSession", async originalCommand => {
51+
await originalCommand({ shutdownDriver: true });
52+
53+
// force kill driver process by pid, because { shutdownDriver: true } in prev command doesn't work :(
54+
if (session.driverPid) {
55+
process.kill(session.driverPid, 9);
56+
}
57+
});
58+
59+
return existingBrowser.publicAPI;
60+
}

0 commit comments

Comments
 (0)