diff --git a/buildscripts/resmokelib/config.py b/buildscripts/resmokelib/config.py index 693c2ac8266..1f1af9d29d8 100644 --- a/buildscripts/resmokelib/config.py +++ b/buildscripts/resmokelib/config.py @@ -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 ## diff --git a/buildscripts/resmokelib/configure_resmoke.py b/buildscripts/resmokelib/configure_resmoke.py index 1905229a959..a183a51d6e5 100644 --- a/buildscripts/resmokelib/configure_resmoke.py +++ b/buildscripts/resmokelib/configure_resmoke.py @@ -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") diff --git a/buildscripts/resmokelib/core/programs.py b/buildscripts/resmokelib/core/programs.py index 3f2fe58f96f..8796045e0af 100644 --- a/buildscripts/resmokelib/core/programs.py +++ b/buildscripts/resmokelib/core/programs.py @@ -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 diff --git a/buildscripts/resmokelib/run/__init__.py b/buildscripts/resmokelib/run/__init__.py index 62160aa4895..fed9191ad3f 100644 --- a/buildscripts/resmokelib/run/__init__.py +++ b/buildscripts/resmokelib/run/__init__.py @@ -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", diff --git a/buildscripts/tests/resmoke_end2end/suites/resmoke_debugger_nodb.yml b/buildscripts/tests/resmoke_end2end/suites/resmoke_debugger_nodb.yml new file mode 100644 index 00000000000..3c0ecfe2189 --- /dev/null +++ b/buildscripts/tests/resmoke_end2end/suites/resmoke_debugger_nodb.yml @@ -0,0 +1,10 @@ +test_kind: js_test + +selector: + roots: + - buildscripts/tests/resmoke_end2end/testfiles/debugger/*.js + +executor: + config: + shell_options: + nodb: "" diff --git a/buildscripts/tests/resmoke_end2end/test_resmoke_js_debugger.py b/buildscripts/tests/resmoke_end2end/test_resmoke_js_debugger.py new file mode 100644 index 00000000000..afbd995a61f --- /dev/null +++ b/buildscripts/tests/resmoke_end2end/test_resmoke_js_debugger.py @@ -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) diff --git a/buildscripts/tests/resmoke_end2end/testfiles/debugger/debugger_statement.js b/buildscripts/tests/resmoke_end2end/testfiles/debugger/debugger_statement.js new file mode 100644 index 00000000000..0614ed721ea --- /dev/null +++ b/buildscripts/tests/resmoke_end2end/testfiles/debugger/debugger_statement.js @@ -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!"); diff --git a/buildscripts/tests/resmoke_end2end/testfiles/debugger/simple_debugger.js b/buildscripts/tests/resmoke_end2end/testfiles/debugger/simple_debugger.js new file mode 100644 index 00000000000..dad20be4c04 --- /dev/null +++ b/buildscripts/tests/resmoke_end2end/testfiles/debugger/simple_debugger.js @@ -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!"); diff --git a/poetry.lock b/poetry.lock index d0d0e9a4056..672eb4b41a5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index b881fd54b93..adf38690c14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/mongo/shell/debugger/README.md b/src/mongo/shell/debugger/README.md index bde54fc0d00..b47cbd83490 100644 --- a/src/mongo/shell/debugger/README.md +++ b/src/mongo/shell/debugger/README.md @@ -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! +```