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
59 changes: 59 additions & 0 deletions docs/debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,62 @@ Testpalne supports [OpenTelemetry](https://opentelemetry.io) standard for tracin

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

### Keep Browser for Debugging

Testplane provides CLI options to keep browser sessions alive after test completion for debugging purposes. This feature has two main use cases:

1. **Local debugging**: After test fails, you can still interact with the browser and see what's wrong
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

#### Why not REPL?

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.

#### `--keep-browser`

Keep browser session alive after test completion for debugging.

```bash
npx testplane --keep-browser --browser chrome tests/login.test.js
```

#### `--keep-browser-on-fail`

Keep browser session alive only when test fails for debugging.

```bash
npx testplane --keep-browser-on-fail --browser chrome tests/login.test.js
```

**Note**: These options work only when running a single test in a single browser.

#### Session Information

When a browser is kept alive, Testplane outputs a message with session information for programmatic access:

```
Testplane run has finished, but the browser won't be closed, because you passed the --keep-browser argument.
You may attach to this browser using the following capabilities:
{
"sessionId": "abc123...",
"capabilities": {
"browserName": "chrome",
"debuggerAddress": "127.0.0.1:9222"
},
"sessionOptions": {
"hostname": "127.0.0.1",
"port": 4444,
"path": "/wd/hub",
"protocol": "http"
}
}
```

#### Attaching to Kept Session

You can use the outputted session information to attach to the kept browser through:

- **MCP (Model Context Protocol)** tools that support WebDriver session attachment
- **CDP (Chrome DevTools Protocol)** using the `debuggerAddress` from capabilities
- **Direct WebDriver** connection using the session ID and connection details
- **Custom automation tools** that can reuse existing browser sessions
19 changes: 15 additions & 4 deletions src/browser-installer/chrome/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { getMilestone } from "../utils";
import { installChrome, resolveLatestChromeVersion } from "./browser";
import { installChromeDriver } from "./driver";
import { isUbuntu, getUbuntuLinkerEnv } from "../ubuntu-packages";
import RuntimeConfig from "../../config/runtime-config";

export { installChrome, resolveLatestChromeVersion, installChromeDriver };

export const runChromeDriver = async (
chromeVersion: string,
{ debug = false } = {},
): Promise<{ gridUrl: string; process: ChildProcess; port: number }> => {
): Promise<{ gridUrl: string; process: ChildProcess; port: number; kill: () => void }> => {
const [chromeDriverPath, randomPort, chromeDriverEnv] = await Promise.all([
installChromeDriver(chromeVersion),
getPort(),
Expand All @@ -22,9 +23,12 @@ export const runChromeDriver = async (
.then(extraEnv => (extraEnv ? { ...process.env, ...extraEnv } : process.env)),
]);

const runtimeConfig = RuntimeConfig.getInstance();
const keepBrowserModeEnabled = runtimeConfig.keepBrowserMode.enabled;

const chromeDriver = spawn(chromeDriverPath, [`--port=${randomPort}`, debug ? `--verbose` : "--silent"], {
windowsHide: true,
detached: false,
detached: keepBrowserModeEnabled || false,
env: chromeDriverEnv,
});

Expand All @@ -34,9 +38,16 @@ export const runChromeDriver = async (

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

process.once("exit", () => chromeDriver.kill());
if (!keepBrowserModeEnabled) {
process.once("exit", () => chromeDriver.kill());
}

await waitPort({ port: randomPort, output: "silent", timeout: DRIVER_WAIT_TIMEOUT });

return { gridUrl, process: chromeDriver, port: randomPort };
return {
gridUrl,
process: chromeDriver,
port: randomPort,
kill: () => chromeDriver.kill(),
};
};
14 changes: 10 additions & 4 deletions src/browser-installer/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@ import getPort from "get-port";
import waitPort from "wait-port";
import { pipeLogsWithPrefix } from "../../dev-server/utils";
import { DRIVER_WAIT_TIMEOUT } from "../constants";
import RuntimeConfig from "../../config/runtime-config";

export { resolveEdgeVersion } from "./browser";
export { installEdgeDriver };

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

const runtimeConfig = RuntimeConfig.getInstance();
const keepBrowserModeEnabled = runtimeConfig.keepBrowserMode.enabled;

const edgeDriver = spawn(edgeDriverPath, [`--port=${randomPort}`, debug ? `--verbose` : "--silent"], {
windowsHide: true,
detached: false,
detached: keepBrowserModeEnabled || false,
});

if (debug) {
Expand All @@ -25,9 +29,11 @@ export const runEdgeDriver = async (

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

process.once("exit", () => edgeDriver.kill());
if (!keepBrowserModeEnabled) {
process.once("exit", () => edgeDriver.kill());
}

await waitPort({ port: randomPort, output: "silent", timeout: DRIVER_WAIT_TIMEOUT });

return { gridUrl, process: edgeDriver, port: randomPort };
return { gridUrl, process: edgeDriver, port: randomPort, kill: () => edgeDriver.kill() };
};
14 changes: 10 additions & 4 deletions src/browser-installer/firefox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { installLatestGeckoDriver } from "./driver";
import { pipeLogsWithPrefix } from "../../dev-server/utils";
import { DRIVER_WAIT_TIMEOUT } from "../constants";
import { getUbuntuLinkerEnv, isUbuntu } from "../ubuntu-packages";
import RuntimeConfig from "../../config/runtime-config";

export { installFirefox, resolveLatestFirefoxVersion, installLatestGeckoDriver };

export const runGeckoDriver = async (
firefoxVersion: string,
{ debug = false } = {},
): Promise<{ gridUrl: string; process: ChildProcess; port: number }> => {
): Promise<{ gridUrl: string; process: ChildProcess; port: number; kill: () => void }> => {
const [geckoDriverPath, randomPort, geckoDriverEnv] = await Promise.all([
installLatestGeckoDriver(firefoxVersion),
getPort(),
Expand All @@ -22,13 +23,16 @@ export const runGeckoDriver = async (
.then(extraEnv => (extraEnv ? { ...process.env, ...extraEnv } : process.env)),
]);

const runtimeConfig = RuntimeConfig.getInstance();
const keepBrowserModeEnabled = runtimeConfig.keepBrowserMode.enabled;

const geckoDriver = await startGeckoDriver({
customGeckoDriverPath: geckoDriverPath,
port: randomPort,
log: debug ? "debug" : "fatal",
spawnOpts: {
windowsHide: true,
detached: false,
detached: keepBrowserModeEnabled || false,
env: geckoDriverEnv,
},
});
Expand All @@ -39,9 +43,11 @@ export const runGeckoDriver = async (

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

process.once("exit", () => geckoDriver.kill());
if (!keepBrowserModeEnabled) {
process.once("exit", () => geckoDriver.kill());
}

await waitPort({ port: randomPort, output: "silent", timeout: DRIVER_WAIT_TIMEOUT });

return { gridUrl, process: geckoDriver, port: randomPort };
return { gridUrl, process: geckoDriver, port: randomPort, kill: () => geckoDriver.kill() };
};
13 changes: 10 additions & 3 deletions src/browser-installer/safari/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,24 @@ import getPort from "get-port";
import waitPort from "wait-port";
import { pipeLogsWithPrefix } from "../../dev-server/utils";
import { DRIVER_WAIT_TIMEOUT, SAFARIDRIVER_PATH } from "../constants";
import RuntimeConfig from "../../config/runtime-config";

export { resolveSafariVersion } from "./browser";

export const runSafariDriver = async ({ debug = false }: { debug?: boolean } = {}): Promise<{
gridUrl: string;
process: ChildProcess;
port: number;
kill: () => void;
}> => {
const randomPort = await getPort();

const runtimeConfig = RuntimeConfig.getInstance();
const keepBrowserModeEnabled = runtimeConfig.keepBrowserMode.enabled;

const safariDriver = spawn(SAFARIDRIVER_PATH, [`--port=${randomPort}`], {
windowsHide: true,
detached: false,
detached: keepBrowserModeEnabled || false,
});

if (debug) {
Expand All @@ -24,9 +29,11 @@ export const runSafariDriver = async ({ debug = false }: { debug?: boolean } = {

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

process.once("exit", () => safariDriver.kill());
if (!keepBrowserModeEnabled) {
process.once("exit", () => safariDriver.kill());
}

await waitPort({ port: randomPort, output: "silent", timeout: DRIVER_WAIT_TIMEOUT });

return { gridUrl, process: safariDriver, port: randomPort };
return { gridUrl, process: safariDriver, port: randomPort, kill: () => safariDriver.kill() };
};
2 changes: 2 additions & 0 deletions src/browser/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type BrowserState = {
testXReqId?: string;
traceparent?: string;
isBroken?: boolean;
isLastTestFailed?: boolean;
};

export type CustomCommand = { name: string; elementScope: boolean };
Expand Down Expand Up @@ -76,6 +77,7 @@ export class Browser {
this._state = {
...opts.state,
isBroken: false,
isLastTestFailed: false,
};
this._customCommands = new Set();
this._wdPool = opts.wdPool;
Expand Down
8 changes: 8 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export const run = async (opts: TestplaneRunOpts = {}): Promise<void> => {
.option("--repl-on-fail [type]", "open repl interface on test fail only", Boolean, false)
.option("--devtools", "switches the browser to the devtools mode with using CDP protocol")
.option("--local", "use local browsers, managed by testplane (same as 'gridUrl': 'local')")
.option("--keep-browser", "do not close browser session after test completion")
.option("--keep-browser-on-fail", "do not close browser session when test fails")
.arguments("[paths...]")
.action(async (paths: string[]) => {
try {
Expand All @@ -93,6 +95,8 @@ export const run = async (opts: TestplaneRunOpts = {}): Promise<void> => {
replOnFail,
devtools,
local,
keepBrowser,
keepBrowserOnFail,
} = program;

const isTestsSuccess = await testplane.run(paths, {
Expand All @@ -110,6 +114,10 @@ export const run = async (opts: TestplaneRunOpts = {}): Promise<void> => {
},
devtools: devtools || false,
local: local || false,
keepBrowserMode: {
enabled: keepBrowser || keepBrowserOnFail || false,
onFail: keepBrowserOnFail || false,
},
});

process.exit(isTestsSuccess ? 0 : 1);
Expand Down
36 changes: 36 additions & 0 deletions src/runner/test-runner/regular-test-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { Runner } = require("../runner");
const logger = require("../../utils/logger");
const { MasterEvents } = require("../../events");
const AssertViewResults = require("../../browser/commands/assert-view/assert-view-results");
const RuntimeConfig = require("../../config/runtime-config");

module.exports = class RegularTestRunner extends Runner {
constructor(test, browserAgent) {
Expand Down Expand Up @@ -121,6 +122,19 @@ module.exports = class RegularTestRunner extends Runner {
return;
}

const runtimeConfig = RuntimeConfig.getInstance();
const keepBrowserMode = runtimeConfig.keepBrowserMode;

if (keepBrowserMode?.enabled) {
const hasError = !!this._test.err || !!browserState?.isLastTestFailed;
const shouldKeep = keepBrowserMode.onFail ? hasError : true;

if (shouldKeep) {
this._logKeepBrowserInfo();
return;
}
}

const browser = this._browser;
this._browser = null;

Expand All @@ -132,4 +146,26 @@ module.exports = class RegularTestRunner extends Runner {
logger.warn(`WARNING: can not release browser: ${error}`);
}
}

_logKeepBrowserInfo() {
if (!this._browser) {
return;
}

logger.log(
"Testplane run has finished, but the browser won't be closed, because you passed the --keep-browser argument.",
);
logger.log("You may attach to this browser using the following capabilities:");
logger.log(
JSON.stringify(
{
sessionId: this._browser.sessionId,
capabilities: this._browser.capabilities,
sessionOptions: this._browser.publicAPI.options,
},
null,
2,
),
);
}
};
11 changes: 8 additions & 3 deletions src/test-reader/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,18 @@ export class TestReader extends EventEmitter {
function validateTests(testsByBro: Record<string, Test[]>, options: TestReaderOpts): void {
const tests = _.flatten(Object.values(testsByBro));

if (options.replMode?.enabled) {
const singleTestModes = [
{ condition: options.replMode?.enabled, name: "repl mode" },
{ condition: options.keepBrowserMode?.enabled, name: "keep-browser mode" },
].filter(mode => mode.condition);

for (const mode of singleTestModes) {
const testsToRun = tests.filter(test => !test.disabled && !test.pending);
const browsersToRun = _.uniq(testsToRun.map(test => test.browserId));

if (testsToRun.length !== 1) {
throw new Error(
`In repl mode only 1 test in 1 browser should be run, but found ${testsToRun.length} tests` +
`In ${mode.name} only 1 test in 1 browser should be run, but found ${testsToRun.length} tests` +
`${testsToRun.length === 0 ? ". " : ` that run in ${browsersToRun.join(", ")} browsers. `}` +
`Try to specify cli-options: "--grep" and "--browser" or use "testplane.only.in" in the test file.`,
);
Expand All @@ -79,7 +84,7 @@ function validateTests(testsByBro: Record<string, Test[]>, options: TestReaderOp
return;
}

const stringifiedOpts = convertOptions(_.omit(options, "replMode"));
const stringifiedOpts = convertOptions(_.omit(options, "replMode", "replMode"));
if (_.isEmpty(stringifiedOpts)) {
throw new Error(`There are no tests found. Try to specify [${Object.keys(options).join(", ")}] options`);
} else {
Expand Down
Loading