Skip to content

Commit 1180d9c

Browse files
committed
fix(robotcode): make the wrapper work in VS Code
Running or debugging tests through a wrapper did not work from the editor, where robotcode runs from a bundled path instead of being installed in the interpreter. Two things are fixed: - The debug launcher no longer applies the wrapper itself; it forwards a launch.json wrapper to robotcode as --wrapper and lets robotcode apply it (so a launch.json wrapper still overrides the profile's). - When robotcode re-executes itself under the wrapper it re-runs through the same entry it was started from (--launcher-script) instead of python -m robotcode.cli, which the bundled interpreter cannot import. ROBOTCODE_BUNDLED_ROBOTCODE_MAIN is deliberately not used for this: VS Code sets it in every terminal, so it would divert a directly started robotcode to the bundled copy.
1 parent c998399 commit 1180d9c

5 files changed

Lines changed: 170 additions & 20 deletions

File tree

packages/debugger/src/robotcode/debugger/launcher/server.py

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import itertools
55
import os
6+
import shlex
67
import sys
78
from pathlib import Path
89
from typing import Any, Dict, List, Literal, Optional
@@ -11,7 +12,6 @@
1112
from robotcode.core.utils.logging import LoggingDescriptor
1213
from robotcode.jsonrpc2.protocol import rpc_method
1314
from robotcode.jsonrpc2.server import JsonRPCServer
14-
from robotcode.plugin.click_helper.wrappable import WRAPPER_APPLIED_ENV
1515
from robotcode.robot.utils import RF_VERSION
1616

1717
from ..cli import DEBUGGER_DEFAULT_PORT, DEBUGPY_DEFAULT_PORT
@@ -44,6 +44,32 @@
4444
from .client import DAPClient, DAPClientError
4545

4646

47+
def _build_robotcode_run_args(
48+
python: str,
49+
debugger_script: List[str],
50+
profiles: Optional[List[str]],
51+
paths: Optional[List[str]],
52+
wrapper: Optional[List[str]],
53+
robot_code_args: Optional[List[str]],
54+
) -> List[str]:
55+
"""Build the ``robotcode … debug`` command line.
56+
57+
A ``wrapper`` from the launch request (VS Code ``robotcode.debug.launchWrapper``)
58+
is forwarded to robotcode as ``--wrapper``; robotcode applies it itself (and
59+
``--wrapper`` overrides the profile's ``wrapper``). Without one nothing is added
60+
and robotcode resolves its profile ``wrapper`` alone.
61+
"""
62+
return [
63+
python,
64+
*debugger_script,
65+
*itertools.chain.from_iterable(["-p", p] for p in profiles or []),
66+
*itertools.chain.from_iterable(["-dp", p] for p in paths or []),
67+
*(["--wrapper", shlex.join(wrapper)] if wrapper else []),
68+
*(robot_code_args or []),
69+
"debug",
70+
]
71+
72+
4773
class OutputProtocol(asyncio.SubprocessProtocol):
4874
def __init__(self, parent: LauncherDebugAdapterProtocol) -> None:
4975
super().__init__()
@@ -147,14 +173,9 @@ async def _launch(
147173

148174
debugger_script = ["-m", "robotcode.cli"] if self.debugger_script is None else [str(Path(self.debugger_script))]
149175

150-
robotcode_run_args = [
151-
python or sys.executable,
152-
*debugger_script,
153-
*itertools.chain.from_iterable(["-p", p] for p in profiles or []),
154-
*itertools.chain.from_iterable(["-dp", p] for p in paths or []),
155-
*(robotCodeArgs or []),
156-
"debug",
157-
]
176+
robotcode_run_args = _build_robotcode_run_args(
177+
python or sys.executable, debugger_script, profiles, paths, wrapper, robotCodeArgs
178+
)
158179

159180
if no_debug:
160181
robotcode_run_args += ["--no-debug"]
@@ -242,14 +263,6 @@ async def _launch(
242263

243264
env = {k: ("" if v is None else str(v)) for k, v in env.items()} if env else {}
244265

245-
# A wrapper from the launch request (VS Code `robotcode.debug.launchWrapper`)
246-
# takes precedence: prefix the run command with it and mark the environment
247-
# so the spawned robotcode process does NOT re-wrap from its own profile or
248-
# `--wrapper`.
249-
if wrapper:
250-
robotcode_run_args = [*wrapper, *robotcode_run_args]
251-
env[WRAPPER_APPLIED_ENV] = "1"
252-
253266
if console in ["integratedTerminal", "externalTerminal"]:
254267
await self.send_request_async(
255268
RunInTerminalRequest(

src/robotcode/cli/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,17 @@ def _maybe_reexec_under_wrapper(
116116
else:
117117
return
118118

119-
command = [*wrapper, sys.executable, "-m", "robotcode.cli", *sys.argv[1:]]
119+
# Rebuild the invocation the way this process was actually started, so the
120+
# re-exec finds robotcode even when it runs from a bundled path (VS Code) and
121+
# is not installed in the interpreter. `launcher_script` is set only when we
122+
# were started through a bundled entry (its `__main__` sets it) or an explicit
123+
# `--launcher-script`; otherwise robotcode is importable directly and
124+
# `-m robotcode.cli` is correct. We deliberately do NOT consult the
125+
# ROBOTCODE_BUNDLED_ROBOTCODE_MAIN env var: VS Code sets it in every terminal,
126+
# so a directly started robotcode would be diverted to the bundled copy.
127+
# Mirrors the debug launcher's `debugger_script` handling.
128+
entry = [str(app.config.launcher_script)] if app.config.launcher_script else ["-m", "robotcode.cli"]
129+
command = [*wrapper, sys.executable, *entry, *sys.argv[1:]]
120130
app.verbose(lambda: "Re-executing under wrapper: " + " ".join(command))
121131

122132
os.environ[WRAPPER_APPLIED_ENV] = "1"

tests/robotcode/cli/test_wrapper.py

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,25 @@ def _write_wrapper(path: Path) -> Path:
4242
return path
4343

4444

45+
# A wrapper that records the command line it was handed (into "<path>.argv"),
46+
# then runs it — used to inspect how the re-exec reconstructed the invocation.
47+
_RECORDER_PY = (
48+
"import os, sys\n"
49+
"with open(__file__ + '.argv', 'w', encoding='utf-8') as f:\n"
50+
" f.write(' '.join(sys.argv[1:]))\n"
51+
"os.execv(sys.argv[1], sys.argv[1:])\n"
52+
)
53+
54+
55+
def _write_recorder(path: Path) -> Path:
56+
path.write_text(_RECORDER_PY, encoding="utf-8")
57+
return path
58+
59+
60+
def _recorded_argv(recorder: Path) -> str:
61+
return Path(str(recorder) + ".argv").read_text(encoding="utf-8")
62+
63+
4564
def _calls(wrapper: Path) -> List[str]:
4665
marker = Path(str(wrapper) + ".called")
4766
return marker.read_text(encoding="utf-8").splitlines() if marker.exists() else []
@@ -113,8 +132,8 @@ def test_wrappable_command_runs_through_the_profile_wrapper(project: Path) -> No
113132

114133
@requires_posix_exec
115134
def test_guard_env_suppresses_wrapping(project: Path) -> None:
116-
"""When an outer layer (the VS Code launcher applying its DAP wrapper) has
117-
already set ROBOTCODE_WRAPPER_APPLIED, the inner process must not wrap."""
135+
"""The re-exec sets ROBOTCODE_WRAPPER_APPLIED before replacing the process,
136+
so a robotcode that already runs under a wrapper must not wrap again."""
118137
wrapper = project / "wrap.py"
119138
result = _run_robotcode(
120139
project,
@@ -155,3 +174,76 @@ def test_non_wrappable_command_is_not_wrapped(project: Path) -> None:
155174
# `discover` only parses files; it must never run through the wrapper.
156175
_run_robotcode(project, ["-p", "x11", "discover", "all", "suite.robot"])
157176
assert _calls(wrapper) == []
177+
178+
179+
@requires_posix_exec
180+
def test_reexec_goes_through_the_launcher_script(project: Path, tmp_path: Path) -> None:
181+
"""When robotcode was started through a bundled entry (`--launcher-script` /
182+
the bundled `__main__`), the re-exec must reuse that entry — not
183+
`python -m robotcode.cli`, which a bundled interpreter can't import."""
184+
bundled_main = tmp_path / "bundled_main.py" # stand-in for the bundled entry
185+
bundled_main.write_text("from robotcode.cli import robotcode\n\nrobotcode()\n", encoding="utf-8")
186+
recorder = _write_recorder(project / "record.py")
187+
188+
result = _run_robotcode(
189+
project,
190+
[
191+
"--launcher-script",
192+
str(bundled_main),
193+
"--wrapper",
194+
f"{shlex.quote(sys.executable)} {shlex.quote(str(recorder))}",
195+
"-p",
196+
"x11",
197+
"run",
198+
"suite.robot",
199+
],
200+
)
201+
assert result.returncode == 0, result.stderr
202+
recorded = _recorded_argv(recorder)
203+
assert str(bundled_main) in recorded # re-exec went through the launcher script
204+
assert "-m robotcode.cli" not in recorded
205+
206+
207+
@requires_posix_exec
208+
def test_direct_start_ignores_bundled_main_env(project: Path, tmp_path: Path) -> None:
209+
"""A directly started robotcode (no `--launcher-script`) must NOT be diverted
210+
to the bundled copy just because ROBOTCODE_BUNDLED_ROBOTCODE_MAIN is set — the
211+
extension sets that in every VS Code terminal."""
212+
broken = tmp_path / "must_not_run.py"
213+
broken.write_text("import sys\nsys.exit('the bundled entry must not be used here')\n", encoding="utf-8")
214+
recorder = _write_recorder(project / "record.py")
215+
216+
result = _run_robotcode(
217+
project,
218+
["--wrapper", f"{shlex.quote(sys.executable)} {shlex.quote(str(recorder))}", "-p", "x11", "run", "suite.robot"],
219+
env={**os.environ, "ROBOTCODE_BUNDLED_ROBOTCODE_MAIN": str(broken)},
220+
)
221+
assert result.returncode == 0, result.stderr # succeeded => the broken bundled entry was not used
222+
recorded = _recorded_argv(recorder)
223+
assert "-m robotcode.cli" in recorded # used the direct module entry
224+
assert str(broken) not in recorded
225+
226+
227+
@requires_posix_exec
228+
def test_wrapper_propagates_the_run_exit_code(project: Path) -> None:
229+
"""The wrapper must not swallow the run's exit code (contract rule #1)."""
230+
(project / "fail.robot").write_text("*** Test Cases ***\nFails\n Should Be Equal 1 2\n", encoding="utf-8")
231+
result = _run_robotcode(project, ["-p", "x11", "run", "fail.robot"])
232+
assert result.returncode != 0 # the failure propagated through the wrapper
233+
assert _calls(project / "wrap.py") == ["xephyr"] # and it did run through the wrapper
234+
235+
236+
# --- these paths return before any re-exec, so they run on every platform ----
237+
238+
239+
def test_wrapper_ignored_and_warns_on_non_wrappable_command(project: Path) -> None:
240+
result = _run_robotcode(project, ["--wrapper", "xvfb-run", "discover", "all", "suite.robot"])
241+
assert _calls(project / "wrap.py") == [] # discover never runs through the wrapper
242+
assert "Ignoring --wrapper" in result.stderr
243+
244+
245+
def test_no_wrapper_overrides_wrapper_with_a_warning(project: Path) -> None:
246+
result = _run_robotcode(project, ["-p", "x11", "--no-wrapper", "--wrapper", "xvfb-run", "run", "suite.robot"])
247+
assert result.returncode == 0, result.stderr
248+
assert _calls(project / "wrap.py") == [] # --no-wrapper wins, nothing is wrapped
249+
assert "Ignoring --wrapper" in result.stderr

tests/robotcode/debugger/__init__.py

Whitespace-only changes.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Tests for the debug launcher building the `robotcode … debug` command line.
2+
3+
Covers how a wrapper from the launch request (VS Code
4+
`robotcode.debug.launchWrapper`) is forwarded: the launcher does not apply it
5+
itself, it hands it to robotcode as `--wrapper` and lets robotcode apply it.
6+
"""
7+
8+
import shlex
9+
10+
from robotcode.debugger.launcher.server import _build_robotcode_run_args
11+
12+
13+
def test_forwards_wrapper_as_a_cli_option() -> None:
14+
args = _build_robotcode_run_args("python", ["-m", "robotcode.cli"], ["ci"], None, ["xvfb-run", "-a"], None)
15+
16+
assert args[-1] == "debug"
17+
assert "--wrapper" in args
18+
i = args.index("--wrapper")
19+
assert args[i + 1] == "xvfb-run -a" # the list is shlex-joined into a single arg
20+
assert i < args.index("debug") # a group option, before the subcommand
21+
22+
23+
def test_no_wrapper_option_without_a_wrapper() -> None:
24+
args = _build_robotcode_run_args("python", ["-m", "robotcode.cli"], ["ci"], None, None, None)
25+
26+
assert "--wrapper" not in args
27+
assert args[-1] == "debug"
28+
29+
30+
def test_wrapper_round_trips_through_shlex() -> None:
31+
# A path with spaces must survive the join → robotcode's shlex.split.
32+
args = _build_robotcode_run_args("python", ["-m", "robotcode.cli"], None, None, ["./x 11.sh", "-a"], None)
33+
34+
joined = args[args.index("--wrapper") + 1]
35+
assert shlex.split(joined) == ["./x 11.sh", "-a"]

0 commit comments

Comments
 (0)