SERVER-119407: Add resmoke --shellJSDebugMode flag to enable JS debugger handling (#48095)
GitOrigin-RevId: 0191e52d17ebcc4c42781e53cfec43124d4e8570
This commit is contained in:
parent
bde73f5d49
commit
a0c8c2e8fe
@ -219,6 +219,8 @@ DEFAULTS = {
|
||||
"no_hooks": False,
|
||||
# Avoids performing signature verification on test extensions at load time.
|
||||
"skip_extensions_signature_verification": False,
|
||||
# Enable shell JS debugging
|
||||
"shell_jsdebugmode": False,
|
||||
}
|
||||
|
||||
_SuiteOptions = collections.namedtuple(
|
||||
@ -739,6 +741,9 @@ SHARD_INDEX = None
|
||||
# JSON containing historic test runtimes
|
||||
HISTORIC_TEST_RUNTIMES = None
|
||||
|
||||
# Shell debug options
|
||||
SHELL_JSDEBUGMODE = None
|
||||
|
||||
##
|
||||
# Internally used configuration options that aren't exposed to the user
|
||||
##
|
||||
|
||||
@ -837,6 +837,8 @@ flags in common: {common_set}
|
||||
_config.NO_HOOKS = config.pop("no_hooks")
|
||||
_config.HANG_ANALYZER_HOOK_TIMEOUT = config.pop("hang_analyzer_hook_timeout")
|
||||
|
||||
_config.SHELL_JSDEBUGMODE = config.pop("shell_jsdebugmode")
|
||||
|
||||
# Internal testing options.
|
||||
_config.INTERNAL_PARAMS = config.pop("internal_params")
|
||||
|
||||
|
||||
@ -547,6 +547,10 @@ def mongo_shell_program(
|
||||
if config.SHELL_GRPC or mongod_set_parameters.get("useGrpcForSearch"):
|
||||
args.append("--gRPC")
|
||||
|
||||
if config.SHELL_JSDEBUGMODE:
|
||||
# relay to the shell flags
|
||||
kwargs["jsDebugMode"] = ""
|
||||
|
||||
if connection_string is not None:
|
||||
# The --host and --port options are ignored by the mongo shell when an explicit connection
|
||||
# string is specified. We remove these options to avoid any ambiguity with what server the
|
||||
|
||||
@ -1873,6 +1873,13 @@ class RunPlugin(PluginInterface):
|
||||
help="Regex to filter mocha-style tests to run.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--shellJSDebugMode",
|
||||
dest="shell_jsdebugmode",
|
||||
action="store_true",
|
||||
help="Enable JavaScript debugger for spawned mongo shells.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--noValidateSelectorPaths",
|
||||
dest="validate_selector_paths",
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
test_kind: js_test
|
||||
|
||||
selector:
|
||||
roots:
|
||||
- buildscripts/tests/resmoke_end2end/testfiles/debugger/*.js
|
||||
|
||||
executor:
|
||||
config:
|
||||
shell_options:
|
||||
nodb: ""
|
||||
300
buildscripts/tests/resmoke_end2end/test_resmoke_js_debugger.py
Normal file
300
buildscripts/tests/resmoke_end2end/test_resmoke_js_debugger.py
Normal file
@ -0,0 +1,300 @@
|
||||
"""Test resmoke's JavaScript debugger functionality."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from shutil import rmtree
|
||||
|
||||
import pexpect
|
||||
|
||||
|
||||
class _ResmokeSelftest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.test_dir = os.path.normpath("/data/db/selftest")
|
||||
|
||||
def setUp(self):
|
||||
self.logger = logging.getLogger(self._testMethodName)
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(logging.Formatter(fmt="%(message)s"))
|
||||
self.logger.addHandler(handler)
|
||||
|
||||
self.logger.info("Cleaning temp directory %s", self.test_dir)
|
||||
rmtree(self.test_dir, ignore_errors=True)
|
||||
os.makedirs(self.test_dir, mode=0o755, exist_ok=True)
|
||||
|
||||
|
||||
def execute_resmoke(resmoke_args, subcommand="run"):
|
||||
return subprocess.run(
|
||||
[sys.executable, "buildscripts/resmoke.py", subcommand] + resmoke_args,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
|
||||
class TestJSDebugger(_ResmokeSelftest):
|
||||
"""Test suite for JavaScript debugger functionality."""
|
||||
|
||||
def test_debugger_without_flag(self):
|
||||
"""Test that debugger statements are no-ops when --shellJSDebugMode is not set."""
|
||||
# Without the flag, debugger statements should be no-ops, so this test passes
|
||||
resmoke_args = [
|
||||
"--suites=buildscripts/tests/resmoke_end2end/suites/resmoke_debugger_nodb.yml",
|
||||
"buildscripts/tests/resmoke_end2end/testfiles/debugger/debugger_statement.js",
|
||||
]
|
||||
result = execute_resmoke(resmoke_args)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
def test_debugger_waits_for_input(self):
|
||||
"""Test that debugger pauses execution when hitting a debugger statement."""
|
||||
# This test verifies the debugger is activated by confirming it pauses execution
|
||||
# Note: The mongo shell's debugger requires /dev/tty for interactive input,
|
||||
# so we can only verify that it pauses, not that it responds to commands in automation
|
||||
resmoke_cmd = [
|
||||
sys.executable,
|
||||
"buildscripts/resmoke.py",
|
||||
"run",
|
||||
f"--dbpathPrefix={self.test_dir}",
|
||||
"--shellJSDebugMode",
|
||||
"--suites=buildscripts/tests/resmoke_end2end/suites/resmoke_debugger_nodb.yml",
|
||||
"buildscripts/tests/resmoke_end2end/testfiles/debugger/debugger_statement.js",
|
||||
]
|
||||
|
||||
process = subprocess.Popen(
|
||||
resmoke_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Wait for a short time - the debugger should pause and not complete
|
||||
try:
|
||||
stdout, _ = process.communicate(timeout=10)
|
||||
# If we get here without timeout, the debugger didn't activate properly
|
||||
self.logger.error("Test output:\n%s", stdout)
|
||||
self.fail("Expected debugger to pause execution, but test completed")
|
||||
except subprocess.TimeoutExpired:
|
||||
# This is the expected behavior - debugger is waiting for input
|
||||
process.kill()
|
||||
stdout, _ = process.communicate()
|
||||
self.logger.info("Debugger correctly paused execution. Output:\n%s", stdout)
|
||||
# Verify the debugger prompt appeared
|
||||
self.assertIn("JSDEBUG>", stdout)
|
||||
self.assertIn("paused in 'debugger' statement", stdout)
|
||||
|
||||
|
||||
class TestJSDebuggerInteractive(_ResmokeSelftest):
|
||||
"""Test suite for interactive JavaScript debugger functionality."""
|
||||
|
||||
def run_debugger_test(self, test_file, commands, timeout=30):
|
||||
"""Helper to run debugger and execute commands.
|
||||
|
||||
Args:
|
||||
test_file: Path to the JS test file
|
||||
commands: List of (command, expected_output) tuples
|
||||
timeout: Maximum time to wait for each command
|
||||
|
||||
Returns:
|
||||
Full output from the session
|
||||
"""
|
||||
resmoke_cmd = " ".join(
|
||||
[
|
||||
sys.executable,
|
||||
"buildscripts/resmoke.py",
|
||||
"run",
|
||||
f"--dbpathPrefix={self.test_dir}",
|
||||
"--shellJSDebugMode",
|
||||
"--suites=buildscripts/tests/resmoke_end2end/suites/resmoke_debugger_nodb.yml",
|
||||
test_file,
|
||||
]
|
||||
)
|
||||
|
||||
child = pexpect.spawn(resmoke_cmd, timeout=timeout, encoding="utf-8")
|
||||
|
||||
# Use StringIO to capture all output
|
||||
output_buffer = io.StringIO()
|
||||
child.logfile = output_buffer
|
||||
|
||||
try:
|
||||
# Wait for initial debugger prompts - there are usually two in the initial pause
|
||||
child.expect("JSDEBUG>", timeout=20)
|
||||
self.logger.info("First debugger prompt detected")
|
||||
|
||||
# Wait for the "Type 'dbcont' to continue" prompt
|
||||
child.expect("JSDEBUG>", timeout=5)
|
||||
self.logger.info("Second debugger prompt detected, ready for commands")
|
||||
|
||||
# Execute each command
|
||||
for cmd, expected in commands:
|
||||
self.logger.info(f"Sending command: {cmd}")
|
||||
child.sendline(cmd)
|
||||
|
||||
# Wait for the next prompt to ensure command completed
|
||||
# and capture what comes before it
|
||||
try:
|
||||
child.expect("JSDEBUG>", timeout=5)
|
||||
command_output = child.before if hasattr(child, "before") else ""
|
||||
except pexpect.TIMEOUT:
|
||||
command_output = ""
|
||||
|
||||
# Also check what's in the full buffer
|
||||
full_buffer = output_buffer.getvalue()
|
||||
self.logger.info(
|
||||
f"Full buffer so far ({len(full_buffer)} chars, last 1000):\n{full_buffer[-1000:]}"
|
||||
)
|
||||
|
||||
if expected:
|
||||
# Log what we're looking for and what we got back
|
||||
self.logger.info(f"Expect to find in output: {expected}")
|
||||
self.logger.info(
|
||||
f"child.before output (last 1000 chars): {command_output[-1000:] if command_output else 'empty'}"
|
||||
)
|
||||
|
||||
# Check if expected is in the command output or full buffer
|
||||
search_text = full_buffer # Use full buffer instead of just child.before
|
||||
if expected not in search_text:
|
||||
# Try to match as regex
|
||||
if not re.search(expected, search_text):
|
||||
self.logger.error(f"Pattern '{expected}' not found")
|
||||
self.logger.error(f"Full buffer:\n{full_buffer}")
|
||||
raise AssertionError(f"Pattern '{expected}' not found in output")
|
||||
self.logger.info(f"Successfully found expected pattern: {expected}")
|
||||
|
||||
# Wait for process to finish or timeout
|
||||
try:
|
||||
child.expect(pexpect.EOF, timeout=10)
|
||||
except pexpect.TIMEOUT:
|
||||
pass
|
||||
|
||||
# Return all collected output
|
||||
full_output = output_buffer.getvalue()
|
||||
self.logger.info(f"Full output length: {len(full_output)} chars")
|
||||
return full_output
|
||||
|
||||
finally:
|
||||
child.close(force=True)
|
||||
output_buffer.close()
|
||||
|
||||
def test_debugger_continue_with_dbcont(self):
|
||||
"""Test that dbcont command continues execution."""
|
||||
commands = [
|
||||
("dbcont", None), # Continue execution
|
||||
]
|
||||
|
||||
self.run_debugger_test(
|
||||
"buildscripts/tests/resmoke_end2end/testfiles/debugger/debugger_statement.js",
|
||||
commands,
|
||||
)
|
||||
|
||||
# If we get here without timeout, the test passed
|
||||
pass
|
||||
|
||||
def test_debugger_inspect_variables(self):
|
||||
"""Test inspecting variables at debugger breakpoint."""
|
||||
commands = [
|
||||
# Check variable outputs
|
||||
("x", "42"),
|
||||
("y", r'[ "a", 15, [ 1, 2, 3 ] ]'),
|
||||
("z", r'{ "foo" : [ 3, "bar" ] }'),
|
||||
# ("z", r'[ 3, "bar" ] }'),
|
||||
("dbcont", None), # Continue
|
||||
]
|
||||
|
||||
output = self.run_debugger_test(
|
||||
"buildscripts/tests/resmoke_end2end/testfiles/debugger/debugger_statement.js",
|
||||
commands,
|
||||
)
|
||||
|
||||
self.assertIn("42", output)
|
||||
|
||||
def test_debugger_undefined_variable(self):
|
||||
"""Test that accessing undefined variables shows ReferenceError."""
|
||||
commands = [
|
||||
("q", "ReferenceError"), # q is not defined
|
||||
("dbcont", None),
|
||||
]
|
||||
|
||||
output = self.run_debugger_test(
|
||||
"buildscripts/tests/resmoke_end2end/testfiles/debugger/debugger_statement.js",
|
||||
commands,
|
||||
)
|
||||
|
||||
self.assertIn("ReferenceError", output)
|
||||
|
||||
def test_debugger_syntax_error(self):
|
||||
"""Test that syntax errors are caught in debugger."""
|
||||
commands = [
|
||||
("]]", "SyntaxError"), # Invalid syntax
|
||||
("dbcont", None),
|
||||
]
|
||||
|
||||
output = self.run_debugger_test(
|
||||
"buildscripts/tests/resmoke_end2end/testfiles/debugger/debugger_statement.js",
|
||||
commands,
|
||||
)
|
||||
|
||||
self.assertIn("SyntaxError", output)
|
||||
|
||||
def test_debugger_assertion_error(self):
|
||||
"""Test that assertion failures are shown in debugger."""
|
||||
commands = [
|
||||
("assert.eq(1, 2)", None), # Should fail - don't expect specific text
|
||||
("dbcont", None),
|
||||
]
|
||||
|
||||
output = self.run_debugger_test(
|
||||
"buildscripts/tests/resmoke_end2end/testfiles/debugger/debugger_statement.js",
|
||||
commands,
|
||||
)
|
||||
|
||||
# The assertion should fail with an error message containing relevant keywords
|
||||
# The mongo shell may show different error formats
|
||||
self.assertTrue(
|
||||
("1" in output and "2" in output) # Numbers from assertion
|
||||
or "Error" in output
|
||||
or "assert" in output.lower(),
|
||||
f"Expected assertion error output, got: {output[:500]}",
|
||||
)
|
||||
|
||||
def test_debugger_modify_variables(self):
|
||||
"""Test modifying variables at debugger breakpoint."""
|
||||
# Create a test file that expects modified variables
|
||||
commands = [
|
||||
("x", "42"), # Check original value
|
||||
("x = 7", None), # Modify x
|
||||
("y[1]", "15"), # Check array element
|
||||
("y[1] = 99", None), # Modify array
|
||||
("dbcont", None), # Continue - should pass assertions
|
||||
]
|
||||
|
||||
output = self.run_debugger_test(
|
||||
"buildscripts/tests/resmoke_end2end/testfiles/debugger/simple_debugger.js",
|
||||
commands,
|
||||
)
|
||||
|
||||
# The test should pass because we modified the variables
|
||||
self.assertIn("Test Passed", output)
|
||||
|
||||
def test_debugger_complex_expressions(self):
|
||||
"""Test evaluating complex expressions in debugger."""
|
||||
commands = [
|
||||
("x + 10", "52"), # Math expression
|
||||
("typeof x", "number"), # Type check
|
||||
("[1,2,3].length", "3"), # Array operations
|
||||
("dbcont", None),
|
||||
]
|
||||
|
||||
output = self.run_debugger_test(
|
||||
"buildscripts/tests/resmoke_end2end/testfiles/debugger/debugger_statement.js",
|
||||
commands,
|
||||
)
|
||||
|
||||
self.assertIn("52", output)
|
||||
self.assertIn("number", output)
|
||||
@ -0,0 +1,9 @@
|
||||
let x = 42;
|
||||
let y = ["a", 15, [1, 2, 3]];
|
||||
let z = {"foo": [3, "bar"]};
|
||||
|
||||
// should be no-op without debug mode
|
||||
debugger; // eslint-disable-line no-debugger
|
||||
|
||||
assert.eq(x, 42);
|
||||
print("Test Passed!");
|
||||
@ -0,0 +1,10 @@
|
||||
// Test that the debugger statement is hit and variables can be inspected/modified
|
||||
let x = 42;
|
||||
let y = ["a", 15, [1, 2, 3]];
|
||||
|
||||
debugger; // eslint-disable-line no-debugger
|
||||
|
||||
// These assertions will fail unless debugger modifies the variables
|
||||
assert.eq(x, 7);
|
||||
assert.eq(y[1], 99);
|
||||
print("Test Passed!");
|
||||
31
poetry.lock
generated
31
poetry.lock
generated
@ -2829,6 +2829,22 @@ files = [
|
||||
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pexpect"
|
||||
version = "4.9.0"
|
||||
description = "Pexpect allows easy control of interactive console applications."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["testing"]
|
||||
markers = "platform_machine != \"s390x\" and platform_machine != \"ppc64le\" or platform_machine == \"s390x\" or platform_machine == \"ppc64le\""
|
||||
files = [
|
||||
{file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"},
|
||||
{file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
ptyprocess = ">=0.5"
|
||||
|
||||
[[package]]
|
||||
name = "pipx"
|
||||
version = "1.6.0"
|
||||
@ -3059,6 +3075,19 @@ files = [
|
||||
dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel"]
|
||||
test = ["pytest", "pytest-xdist", "setuptools"]
|
||||
|
||||
[[package]]
|
||||
name = "ptyprocess"
|
||||
version = "0.7.0"
|
||||
description = "Run a subprocess in a pseudo terminal"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["testing"]
|
||||
markers = "platform_machine != \"s390x\" and platform_machine != \"ppc64le\" or platform_machine == \"s390x\" or platform_machine == \"ppc64le\""
|
||||
files = [
|
||||
{file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
|
||||
{file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "puremagic"
|
||||
version = "1.28"
|
||||
@ -5855,4 +5884,4 @@ libdeps = ["cxxfilt", "eventlet", "flask", "flask-cors", "gevent", "lxml", "prog
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<4.0"
|
||||
content-hash = "e49289dec8b835ef0ea7669f80b251da77afc4d70bd6fbd8ee4d6a2b0f04677e"
|
||||
content-hash = "c4a064d90a866bf24bdc2d77d8f10147a1a6cf2d94f193e064cf8405250449cd"
|
||||
|
||||
@ -167,6 +167,7 @@ distro = "^1.9.0"
|
||||
dnspython = "^2.6.1"
|
||||
proxy-protocol = "^0.11.3"
|
||||
pkce = "^1.0.3"
|
||||
pexpect = "^4.9.0"
|
||||
oauthlib = "^3.1.1"
|
||||
requests-oauthlib = "^2.0.0"
|
||||
packaging = "^25.0"
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
# JS Debugging in the MongoDB Shell
|
||||
|
||||
|
||||
Use the `--shellJSDebugMode` flag for resmoke (or the `--jsDebugMode` flag directly on the mongo shell) to trigger an interactive debug prompt when `debugger` statements are hit in JS test code.
|
||||
|
||||
Sample JS Test:
|
||||
```js
|
||||
let x = 42;
|
||||
@ -66,3 +68,22 @@ Test Passed!
|
||||
All pids dead / alive (0):
|
||||
Searching for files in: /home/ubuntu/mongo
|
||||
```
|
||||
|
||||
## Resmoke
|
||||
|
||||
Use the `--shellJSDebugMode` flag in resmoke to stop on debugger statements:
|
||||
```bash
|
||||
buildscripts/resmoke.py run --suites=no_passthrough --shellJSDebugMode jstests/my_test.js
|
||||
```
|
||||
|
||||
Update variables `x` and `q` to repair the failing assertions:
|
||||
```
|
||||
JSDEBUG> JavaScript execution paused in 'debugger' statement.
|
||||
JSDEBUG> Type 'dbcont' to continue
|
||||
JSDEBUG@jstests/my_test.js:5> x = 7
|
||||
7
|
||||
JSDEBUG@jstests/my_test.js:5> q = "foo"
|
||||
foo
|
||||
JSDEBUG@jstests/my_test.js:5> dbcont
|
||||
[js_test:my_test] Test Passed!
|
||||
```
|
||||
|
||||
Loading…
Reference in New Issue
Block a user