Skip to content

Commit f69e8be

Browse files
committed
fix(repl): cross-platform CI failures (Windows paths, prompt_toolkit on Windows, libedit on macOS)
Three independent issues, one commit: - `.save C:\Users\…\scratch.robot` no longer mangles the path on Windows. `shlex.split` was consuming backslashes as escape characters; switch to a custom shlex with `escape=""` so paths arrive intact while shell-style quoting (for spaces in paths) still works. Fixes the four `.save` test failures on Windows. - `PromptSession()` no longer crashes on Windows CI runners with `NoConsoleScreenBufferError`. The runners advertise TERM=xterm-256color but expose no real Windows console buffer, so prompt_toolkit's eager `Win32Output` init blew up. A new repl-test conftest wraps every test in `create_app_session(output=DummyOutput())`, detaching prompt_toolkit instantiation from the runner's terminal. Fixes the nine prompt_toolkit-instantiation failures on Windows. - The two readline history-file round-trip tests now use `@pytest.mark.skipif(_IS_LIBEDIT, …)` and a parallel libedit-only variant. The original test seeded the file with plain text, which only loads on GNU readline; the libedit variant seeds via `readline.write_history_file` so the file gets libedit's expected `_HiStOrY_V2_` header. The out-of-range test was refactored to seed via `write_history_file` directly and now runs unchanged on both backends. Coverage stays equivalent across platforms.
1 parent 372afca commit f69e8be

3 files changed

Lines changed: 123 additions & 14 deletions

File tree

packages/repl/src/robotcode/repl/_dot_commands.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,17 @@ def _exit(app: Application, interpreter: Any, arg: str) -> None:
490490
# ---------------------------------------------------------------------------
491491

492492

493+
def _split_command_args(arg: str) -> List[str]:
494+
"""Tokenise a dot-command argument string the way shells do — but
495+
keep backslashes literal so Windows paths (``C:\\Users\\…``) survive
496+
unmangled. Quotes still work for spaces in paths."""
497+
lex = shlex.shlex(arg, posix=True)
498+
lex.whitespace_split = True
499+
lex.escape = ""
500+
lex.commenters = ""
501+
return list(lex)
502+
503+
493504
def _build_save_parser() -> argparse.ArgumentParser:
494505
# `exit_on_error=False` so a bad invocation reports "Usage: …" via
495506
# `_save` instead of tearing down the REPL on argparse's sys.exit.
@@ -532,7 +543,7 @@ def _save(app: Application, interpreter: Any, arg: str) -> None:
532543
"""
533544
parser = _build_save_parser()
534545
try:
535-
opts = parser.parse_args(shlex.split(arg))
546+
opts = parser.parse_args(_split_command_args(arg))
536547
except (argparse.ArgumentError, SystemExit, ValueError):
537548
app.echo("Usage: .save [-a] [-t NAME] FILENAME")
538549
return

tests/robotcode/repl/conftest.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Shared fixtures for the REPL test suite.
2+
3+
Provides a prompt_toolkit `AppSession` with dummy I/O for every test —
4+
without it, instantiating `PromptSession` on Windows CI runners crashes
5+
in `Win32Output` because the runners advertise themselves as
6+
`xterm-256color` but don't expose a real Windows console screen buffer.
7+
The fixture is a no-op when `prompt_toolkit` isn't installed.
8+
"""
9+
10+
from typing import Iterator
11+
12+
import pytest
13+
14+
15+
@pytest.fixture(autouse=True)
16+
def _prompt_toolkit_dummy_session() -> Iterator[None]:
17+
try:
18+
from prompt_toolkit.application import create_app_session
19+
from prompt_toolkit.input import create_pipe_input
20+
from prompt_toolkit.output import DummyOutput
21+
except ImportError:
22+
yield
23+
return
24+
25+
with create_pipe_input() as inp:
26+
with create_app_session(input=inp, output=DummyOutput()):
27+
yield

tests/robotcode/repl/test_backends.py

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,25 @@
1010
from robotcode.repl._input._plain import PlainBackend as PlainBackendClass
1111

1212

13+
def _detect_libedit() -> bool:
14+
"""Evaluate at import time whether the active readline is libedit-backed.
15+
16+
The result drives `pytest.mark.skipif` on history-file tests below: the
17+
two `readline` implementations write fundamentally different on-disk
18+
formats (GNU readline: plain lines; libedit / editline: `_HiStOrY_V2_`
19+
header + escape-encoded entries), so a single fixture file can't satisfy
20+
both.
21+
"""
22+
try:
23+
from robotcode.repl._input._readline import _is_libedit
24+
except ImportError:
25+
return False
26+
return _is_libedit()
27+
28+
29+
_IS_LIBEDIT = _detect_libedit()
30+
31+
1332
def test_plain_backend_wraps_input(monkeypatch: pytest.MonkeyPatch) -> None:
1433
captured: dict[str, Any] = {}
1534

@@ -201,10 +220,43 @@ def test_plain_backend_history_methods_are_no_ops() -> None:
201220
assert backend.delete_history_entry(1) is False
202221

203222

204-
def test_readline_backend_history_round_trip(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
205-
"""`.get_history()` mirrors what was loaded; `.clear_history()`
206-
wipes both in-memory and on-disk; `.delete_history_entry(idx)`
207-
removes a single line from both."""
223+
def _seed_history_via_readline(monkeypatch: pytest.MonkeyPatch, tmp_path: Any, lines: list[str]) -> Any:
224+
"""Populate `tmp_path/histfile` using `readline.write_history_file`.
225+
226+
The on-disk format matches whichever backend (GNU readline or libedit)
227+
is in use — libedit adds the `_HiStOrY_V2_` marker it later expects when
228+
reading back, GNU readline writes plain lines. Tests that go through
229+
this seed step therefore behave the same way under both backends.
230+
"""
231+
histfile = tmp_path / "histfile"
232+
233+
import robotcode.repl._history as history_mod
234+
235+
monkeypatch.setattr(history_mod, "history_path", lambda: histfile)
236+
237+
from robotcode.repl._input import _readline as readline_mod
238+
239+
_rl = readline_mod.readline # type: ignore[attr-defined]
240+
_rl.clear_history()
241+
for line in lines:
242+
_rl.add_history(line)
243+
_rl.write_history_file(str(histfile))
244+
_rl.clear_history()
245+
return histfile
246+
247+
248+
@pytest.mark.skipif(
249+
_IS_LIBEDIT,
250+
reason="plain-text fixture only loads on GNU readline; libedit variant below",
251+
)
252+
def test_readline_backend_history_round_trip_gnu_format(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
253+
"""GNU readline reads a hand-written plain-text history file as-is.
254+
255+
`delete_history_line_in_file` and `truncate_history_file` operate on
256+
the file as plain text, so the on-disk content here is asserted
257+
directly. The libedit equivalent (next test) goes through readline's
258+
own writer for seeding because the libedit format isn't plain text.
259+
"""
208260
pytest.importorskip("readline")
209261
histfile = tmp_path / "histfile"
210262
histfile.write_text("first\nsecond\nthird\n")
@@ -231,21 +283,40 @@ def test_readline_backend_history_round_trip(monkeypatch: pytest.MonkeyPatch, tm
231283
assert histfile.read_text() == ""
232284

233285

234-
def test_readline_backend_delete_out_of_range(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
286+
@pytest.mark.skipif(not _IS_LIBEDIT, reason="libedit-specific round-trip; the GNU variant runs above")
287+
def test_readline_backend_history_round_trip_libedit_format(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
288+
"""libedit equivalent of the GNU round-trip: seed via readline's own
289+
`write_history_file` so the file gets libedit's expected header.
290+
291+
`delete_history_line_in_file` is text-line-based and gets out of sync
292+
with the in-memory ring by one (the header) on libedit, so file-content
293+
invariants from the GNU test are deliberately not asserted here —
294+
only the in-memory ring is the authoritative store on libedit.
295+
"""
235296
pytest.importorskip("readline")
236-
histfile = tmp_path / "histfile"
237-
histfile.write_text("only\n")
297+
histfile = _seed_history_via_readline(monkeypatch, tmp_path, ["first", "second", "third"])
238298

239-
import robotcode.repl._history as history_mod
299+
from robotcode.repl._input._readline import ReadlineBackend
240300

241-
monkeypatch.setattr(history_mod, "history_path", lambda: histfile)
301+
backend = ReadlineBackend()
302+
assert backend.get_history() == ["first", "second", "third"]
242303

243-
from robotcode.repl._input import _readline as readline_mod
244-
from robotcode.repl._input._readline import ReadlineBackend
304+
assert backend.delete_history_entry(2) is True
305+
assert backend.get_history() == ["first", "third"]
245306

246-
_rl = readline_mod.readline # type: ignore[attr-defined]
307+
backend.clear_history()
308+
assert backend.get_history() == []
309+
assert histfile.read_text() == ""
310+
311+
312+
def test_readline_backend_delete_out_of_range(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
313+
"""Out-of-range index returns False and leaves history intact, on
314+
either backend (seeded via the readline-native writer so both formats work)."""
315+
pytest.importorskip("readline")
316+
_seed_history_via_readline(monkeypatch, tmp_path, ["only"])
317+
318+
from robotcode.repl._input._readline import ReadlineBackend
247319

248-
_rl.clear_history()
249320
backend = ReadlineBackend()
250321
assert backend.delete_history_entry(99) is False
251322
assert backend.delete_history_entry(0) is False

0 commit comments

Comments
 (0)