Skip to content

Commit c62aed6

Browse files
committed
aiorepl: Add integration tests for tab completion and terminal modes.
Add attribute completion test case to the existing unit test, and a new PTY-based integration test that exercises aiorepl interactively: tab completion (single, attribute, multiple-match), command execution, terminal mode switching, and Ctrl-D exit. Document how to run both in the README. Signed-off-by: Andrew Leech <andrew.leech@planet-innovation.com> Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
1 parent 84c2445 commit c62aed6

File tree

4 files changed

+222
-2
lines changed

4 files changed

+222
-2
lines changed

micropython/aiorepl/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,24 @@ The following features are unsupported:
9999
* Exception tracebacks. Only the exception type and message is shown, see demo above.
100100
* Emacs shortcuts (e.g. Ctrl-A, Ctrl-E, to move to start/end of line).
101101
* Unicode handling for input.
102+
103+
## Testing
104+
105+
### Unit tests
106+
107+
`test_autocomplete.py` tests the `micropython.repl_autocomplete` API contract
108+
that aiorepl depends on. Run on the unix port:
109+
110+
MICROPY_MICROPYTHON=ports/unix/build-standard/micropython \
111+
tests/run-tests.py lib/micropython-lib/micropython/aiorepl/test_autocomplete.py
112+
113+
Or deploy to a device with `mpremote cp` and run directly.
114+
115+
### Integration test (PTY)
116+
117+
`test_aiorepl_pty.py` exercises aiorepl interactively via a pseudo-terminal,
118+
testing tab completion, command execution, and terminal mode switching.
119+
Run with CPython against the unix build:
120+
121+
python3 lib/micropython-lib/micropython/aiorepl/test_aiorepl_pty.py \
122+
ports/unix/build-standard/micropython
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
#!/usr/bin/env python3
2+
"""PTY-based integration test for aiorepl features.
3+
4+
Requires the unix port of micropython.
5+
6+
Usage:
7+
python3 test_aiorepl_pty.py [path/to/micropython]
8+
9+
The micropython binary must have MICROPY_HELPER_REPL and
10+
MICROPY_PY_MICROPYTHON_STDIO_RAW enabled (unix standard build).
11+
MICROPYPATH is set automatically to include frozen modules and this
12+
directory (for aiorepl).
13+
"""
14+
15+
import os
16+
import pty
17+
import re
18+
import select
19+
import subprocess
20+
import sys
21+
22+
23+
MICROPYTHON = sys.argv[1] if len(sys.argv) > 1 else os.environ.get(
24+
"MICROPY_MICROPYTHON", "micropython"
25+
)
26+
27+
28+
def get(master, timeout=0.02, required=False):
29+
"""Read from PTY master until *timeout* seconds of silence."""
30+
rv = b""
31+
while True:
32+
ready = select.select([master], [], [], timeout)
33+
if ready[0] == [master]:
34+
rv += os.read(master, 1024)
35+
else:
36+
if not required or rv:
37+
return rv
38+
39+
40+
def send_get(master, data, timeout=0.02):
41+
"""Write *data* to PTY master and return response after silence."""
42+
os.write(master, data)
43+
return get(master, timeout)
44+
45+
46+
def strip_ansi(data):
47+
"""Remove ANSI escape sequences from bytes."""
48+
return re.sub(rb"\x1b\[[0-9;]*[A-Za-z]", b"", data)
49+
50+
51+
class TestFailure(Exception):
52+
pass
53+
54+
55+
def assert_in(needle, haystack, label):
56+
if needle not in haystack:
57+
raise TestFailure(
58+
f"[{label}] expected {needle!r} in output, got: {haystack!r}"
59+
)
60+
61+
62+
def assert_not_in(needle, haystack, label):
63+
if needle in haystack:
64+
raise TestFailure(
65+
f"[{label}] did not expect {needle!r} in output, got: {haystack!r}"
66+
)
67+
68+
69+
def main():
70+
master, slave = pty.openpty()
71+
72+
# Build MICROPYPATH: frozen modules + this directory (for aiorepl).
73+
this_dir = os.path.dirname(os.path.abspath(__file__))
74+
env = os.environ.copy()
75+
env["MICROPYPATH"] = ".frozen:" + this_dir
76+
77+
p = subprocess.Popen(
78+
[MICROPYTHON],
79+
stdin=slave,
80+
stdout=slave,
81+
stderr=subprocess.STDOUT,
82+
bufsize=0,
83+
env=env,
84+
)
85+
86+
passed = 0
87+
failed = 0
88+
89+
try:
90+
# Wait for the standard REPL banner and >>> prompt.
91+
banner = get(master, timeout=0.1, required=True)
92+
if b">>>" not in banner:
93+
raise TestFailure(f"No REPL banner/prompt, got: {banner!r}")
94+
95+
# --- Test 1: Start aiorepl ---
96+
# Standard REPL readline handles both \r and \n as enter.
97+
resp = send_get(
98+
master,
99+
b"import asyncio, aiorepl; asyncio.run(aiorepl.task())\r",
100+
timeout=0.1,
101+
)
102+
assert_in(b"Starting asyncio REPL", resp, "startup")
103+
assert_in(b"--> ", resp, "startup prompt")
104+
print("PASS: startup")
105+
passed += 1
106+
107+
# Once aiorepl is running, the terminal is in raw mode (ICRNL cleared),
108+
# and aiorepl only handles 0x0A (LF) as enter — not 0x0D (CR).
109+
# All subsequent commands must use \n.
110+
111+
# --- Test 2: Single tab completion ---
112+
# Type "impo" then tab. Should complete to "import " (suffix "rt ").
113+
resp = send_get(master, b"impo\x09", timeout=0.05)
114+
clean = strip_ansi(resp)
115+
assert_in(b"rt ", clean, "single tab completion")
116+
# Ctrl-C to clear and get fresh prompt.
117+
resp = send_get(master, b"\x03", timeout=0.05)
118+
assert_in(b"--> ", resp, "prompt after ctrl-c")
119+
print("PASS: single tab completion")
120+
passed += 1
121+
122+
# --- Test 3: Attribute tab completion ---
123+
# First import sys.
124+
resp = send_get(master, b"import sys\n", timeout=0.1)
125+
# Now type "sys.ver" + tab. Should complete common prefix "sion".
126+
resp = send_get(master, b"sys.ver\x09", timeout=0.05)
127+
clean = strip_ansi(resp)
128+
assert_in(b"sion", clean, "attribute tab completion")
129+
# Ctrl-C to clear.
130+
resp = send_get(master, b"\x03", timeout=0.05)
131+
print("PASS: attribute tab completion")
132+
passed += 1
133+
134+
# --- Test 4: Multiple-match completion ---
135+
# Create two globals sharing prefix "_tc".
136+
resp = send_get(master, b"_tca=1;_tcb=2\n", timeout=0.1)
137+
# Type "_tc" + tab. Multiple matches -> candidates printed, returns None.
138+
resp = send_get(master, b"_tc\x09", timeout=0.05)
139+
clean = strip_ansi(resp)
140+
assert_in(b"_tca", clean, "multi-match candidates")
141+
assert_in(b"_tcb", clean, "multi-match candidates")
142+
# The prompt and partial input should be redrawn.
143+
assert_in(b"--> ", clean, "multi-match prompt redraw")
144+
# Ctrl-C to clear.
145+
resp = send_get(master, b"\x03", timeout=0.05)
146+
print("PASS: multiple-match completion")
147+
passed += 1
148+
149+
# --- Test 5: Command execution ---
150+
resp = send_get(master, b"print(42)\n", timeout=0.1)
151+
assert_in(b"42", resp, "command execution")
152+
print("PASS: command execution")
153+
passed += 1
154+
155+
# --- Test 6: Terminal mode verification ---
156+
# aiorepl calls _stdio_raw(False) before execute(), so during execution
157+
# the terminal should be in original (non-raw) mode with LFLAG non-zero.
158+
resp = send_get(
159+
master,
160+
b"import termios; print('lflag:', termios.tcgetattr(0)[3] != 0)\n",
161+
timeout=0.1,
162+
)
163+
assert_in(b"lflag: True", resp, "terminal mode")
164+
print("PASS: terminal mode verification")
165+
passed += 1
166+
167+
# --- Test 7: Ctrl-D exit ---
168+
# Ctrl-D on empty line should exit aiorepl and return to standard REPL.
169+
resp = send_get(master, b"\x04", timeout=0.1)
170+
assert_in(b">>>", resp, "ctrl-d exit")
171+
print("PASS: ctrl-d exit")
172+
passed += 1
173+
174+
except TestFailure as e:
175+
print(f"FAIL: {e}")
176+
failed += 1
177+
except Exception as e:
178+
print(f"ERROR: {type(e).__name__}: {e}")
179+
failed += 1
180+
finally:
181+
try:
182+
p.kill()
183+
except ProcessLookupError:
184+
pass
185+
p.wait()
186+
os.close(master)
187+
os.close(slave)
188+
189+
print(f"\n{passed} passed, {failed} failed")
190+
sys.exit(1 if failed else 0)
191+
192+
193+
if __name__ == "__main__":
194+
main()

micropython/aiorepl/test_autocomplete.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Test tab completion logic used by aiorepl.
22
import sys
33
import micropython
4+
import __main__
45

56
try:
67
micropython.repl_autocomplete
@@ -20,15 +21,18 @@
2021

2122
# Multiple matches: returns None (candidates printed to stdout by C code).
2223
# Create two globals sharing a prefix so autocomplete finds multiple matches.
23-
import __main__
24-
2524
__main__.tvar_alpha = 1
2625
__main__.tvar_beta = 2
2726
result = micropython.repl_autocomplete("tvar_")
2827
del __main__.tvar_alpha
2928
del __main__.tvar_beta
3029
print("multiple:", repr(result))
3130

31+
# Attribute completion: "sys.ver" matches sys.version and sys.version_info.
32+
# Common prefix is "version" so completion suffix is "sion".
33+
result = micropython.repl_autocomplete("sys.ver")
34+
print("attr:", repr(result))
35+
3236
# Test the whitespace-before-cursor logic used for tab-as-indentation.
3337
# This validates the condition: cursor_pos > 0 and cmd[cursor_pos - 1] <= " "
3438
test_cases = [

micropython/aiorepl/test_autocomplete.py.exp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
tvar_alpha tvar_beta
55
multiple: None
6+
attr: 'sion'
67
b'x ' True
78
b'x' True
89
b'\n' True

0 commit comments

Comments
 (0)