tenseleyflow/bensch / 1d652be

Browse files

extract utility modules

keys.py — 60+ VT100/xterm key definitions (100% shell-agnostic)
matchers.py — output matching library (exact, regex, contains, etc.)

Verbatim copies, no changes needed.

Source: fortsh/tests/interactive/utils/
Authored by espadonne
SHA
1d652be92b91c3923b4b9b7d161a6bafcf2820c6
Parents
bea315a
Tree
1fbee36

3 changed files

StatusFile+-
A framework/utils/__init__.py 1 0
A framework/utils/keys.py 188 0
A framework/utils/matchers.py 362 0
framework/utils/__init__.pyadded
@@ -0,0 +1,1 @@
1
+
framework/utils/keys.pyadded
@@ -0,0 +1,188 @@
1
+"""
2
+Key sequence definitions for terminal input simulation.
3
+
4
+These escape sequences follow standard xterm/VT100 conventions.
5
+"""
6
+
7
+# Arrow keys (CSI sequences)
8
+ARROW_UP = "\x1b[A"
9
+ARROW_DOWN = "\x1b[B"
10
+ARROW_RIGHT = "\x1b[C"
11
+ARROW_LEFT = "\x1b[D"
12
+
13
+# Control keys (ASCII control characters)
14
+CTRL_A = "\x01"  # Beginning of line
15
+CTRL_B = "\x02"  # Back one character
16
+CTRL_C = "\x03"  # Interrupt (SIGINT)
17
+CTRL_D = "\x04"  # Delete char / EOF
18
+CTRL_E = "\x05"  # End of line
19
+CTRL_F = "\x06"  # Forward one character
20
+CTRL_G = "\x07"  # Cancel
21
+CTRL_H = "\x08"  # Backspace (alternative)
22
+CTRL_I = "\x09"  # Tab
23
+CTRL_J = "\x0a"  # Newline
24
+CTRL_K = "\x0b"  # Kill to end of line
25
+CTRL_L = "\x0c"  # Clear screen
26
+CTRL_M = "\x0d"  # Carriage return (Enter)
27
+CTRL_N = "\x0e"  # Next history
28
+CTRL_O = "\x0f"  # Operate and get next
29
+CTRL_P = "\x10"  # Previous history
30
+CTRL_Q = "\x11"  # Resume output
31
+CTRL_R = "\x12"  # Reverse search
32
+CTRL_S = "\x13"  # Forward search / Suspend output
33
+CTRL_T = "\x14"  # Transpose characters
34
+CTRL_U = "\x15"  # Kill to beginning of line
35
+CTRL_V = "\x16"  # Quoted insert
36
+CTRL_W = "\x17"  # Kill word backward
37
+CTRL_X = "\x18"  # Prefix
38
+CTRL_Y = "\x19"  # Yank
39
+CTRL_Z = "\x1a"  # Suspend (SIGTSTP)
40
+
41
+# Alt/Meta keys (ESC + key)
42
+ALT_B = "\x1bb"  # Back one word
43
+ALT_C = "\x1bc"  # Capitalize word
44
+ALT_D = "\x1bd"  # Delete word forward
45
+ALT_F = "\x1bf"  # Forward one word
46
+ALT_L = "\x1bl"  # Lowercase word
47
+ALT_T = "\x1bt"  # Transpose words
48
+ALT_U = "\x1bu"  # Uppercase word
49
+ALT_Y = "\x1by"  # Yank pop
50
+ALT_DOT = "\x1b."  # Insert last argument
51
+ALT_BACKSPACE = "\x1b\x7f"  # Delete word backward
52
+
53
+# Special keys
54
+TAB = "\t"
55
+ENTER = "\r"
56
+NEWLINE = "\n"
57
+BACKSPACE = "\x7f"
58
+ESCAPE = "\x1b"
59
+
60
+# Extended keys (xterm sequences)
61
+DELETE = "\x1b[3~"
62
+HOME = "\x1b[H"
63
+END = "\x1b[F"
64
+PAGE_UP = "\x1b[5~"
65
+PAGE_DOWN = "\x1b[6~"
66
+INSERT = "\x1b[2~"
67
+
68
+# Alternative Home/End sequences (some terminals use these)
69
+HOME_ALT = "\x1b[1~"
70
+END_ALT = "\x1b[4~"
71
+
72
+# Function keys
73
+F1 = "\x1bOP"
74
+F2 = "\x1bOQ"
75
+F3 = "\x1bOR"
76
+F4 = "\x1bOS"
77
+F5 = "\x1b[15~"
78
+F6 = "\x1b[17~"
79
+F7 = "\x1b[18~"
80
+F8 = "\x1b[19~"
81
+F9 = "\x1b[20~"
82
+F10 = "\x1b[21~"
83
+F11 = "\x1b[23~"
84
+F12 = "\x1b[24~"
85
+
86
+# Key name to sequence mapping (for YAML test specs)
87
+KEYS = {
88
+    # Arrow keys
89
+    "Up": ARROW_UP,
90
+    "Down": ARROW_DOWN,
91
+    "Right": ARROW_RIGHT,
92
+    "Left": ARROW_LEFT,
93
+
94
+    # Control keys
95
+    "C-a": CTRL_A,
96
+    "C-b": CTRL_B,
97
+    "C-c": CTRL_C,
98
+    "C-d": CTRL_D,
99
+    "C-e": CTRL_E,
100
+    "C-f": CTRL_F,
101
+    "C-g": CTRL_G,
102
+    "C-h": CTRL_H,
103
+    "C-k": CTRL_K,
104
+    "C-l": CTRL_L,
105
+    "C-n": CTRL_N,
106
+    "C-p": CTRL_P,
107
+    "C-r": CTRL_R,
108
+    "C-s": CTRL_S,
109
+    "C-t": CTRL_T,
110
+    "C-u": CTRL_U,
111
+    "C-w": CTRL_W,
112
+    "C-y": CTRL_Y,
113
+    "C-z": CTRL_Z,
114
+
115
+    # Alt/Meta keys
116
+    "M-b": ALT_B,
117
+    "M-c": ALT_C,
118
+    "M-d": ALT_D,
119
+    "M-f": ALT_F,
120
+    "M-l": ALT_L,
121
+    "M-t": ALT_T,
122
+    "M-u": ALT_U,
123
+    "M-y": ALT_Y,
124
+    "M-.": ALT_DOT,
125
+    "M-Backspace": ALT_BACKSPACE,
126
+
127
+    # Special keys
128
+    "Tab": TAB,
129
+    "Enter": ENTER,
130
+    "Return": ENTER,
131
+    "Backspace": BACKSPACE,
132
+    "Delete": DELETE,
133
+    "Home": HOME,
134
+    "End": END,
135
+    "PageUp": PAGE_UP,
136
+    "PageDown": PAGE_DOWN,
137
+    "Insert": INSERT,
138
+    "Escape": ESCAPE,
139
+    "Esc": ESCAPE,
140
+
141
+    # Function keys
142
+    "F1": F1,
143
+    "F2": F2,
144
+    "F3": F3,
145
+    "F4": F4,
146
+    "F5": F5,
147
+    "F6": F6,
148
+    "F7": F7,
149
+    "F8": F8,
150
+    "F9": F9,
151
+    "F10": F10,
152
+    "F11": F11,
153
+    "F12": F12,
154
+}
155
+
156
+
157
+def get_key(name: str) -> str:
158
+    """
159
+    Get the escape sequence for a key name.
160
+
161
+    Args:
162
+        name: Key name (e.g., "Up", "C-a", "M-f", "Enter")
163
+
164
+    Returns:
165
+        The escape sequence for that key
166
+
167
+    Raises:
168
+        KeyError: If the key name is not recognized
169
+    """
170
+    if name not in KEYS:
171
+        raise KeyError(f"Unknown key: {name}. Available keys: {sorted(KEYS.keys())}")
172
+    return KEYS[name]
173
+
174
+
175
+def key_sequence(*keys: str) -> str:
176
+    """
177
+    Build a sequence of multiple keys.
178
+
179
+    Args:
180
+        *keys: Key names to combine
181
+
182
+    Returns:
183
+        Combined escape sequence string
184
+
185
+    Example:
186
+        key_sequence("C-a", "C-k")  # Move to beginning, kill to end
187
+    """
188
+    return "".join(get_key(k) for k in keys)
framework/utils/matchers.pyadded
@@ -0,0 +1,362 @@
1
+"""
2
+Output matching utilities for interactive tests.
3
+
4
+Provides flexible matching for verifying shell output, supporting
5
+exact matches, substrings, regex patterns, and structured output.
6
+"""
7
+
8
+import re
9
+from typing import Union, List, Optional
10
+from dataclasses import dataclass
11
+
12
+
13
+@dataclass
14
+class MatchResult:
15
+    """Result of a match operation."""
16
+    matched: bool
17
+    message: str
18
+    actual: str
19
+    expected: str
20
+
21
+
22
+def match_exact(actual: str, expected: str) -> MatchResult:
23
+    """
24
+    Match output exactly (after stripping whitespace).
25
+
26
+    Args:
27
+        actual: Actual output
28
+        expected: Expected output
29
+
30
+    Returns:
31
+        MatchResult indicating success/failure
32
+    """
33
+    actual_stripped = actual.strip()
34
+    expected_stripped = expected.strip()
35
+
36
+    if actual_stripped == expected_stripped:
37
+        return MatchResult(True, "Exact match", actual_stripped, expected_stripped)
38
+
39
+    return MatchResult(
40
+        False,
41
+        f"Exact match failed",
42
+        actual_stripped,
43
+        expected_stripped
44
+    )
45
+
46
+
47
+def match_contains(actual: str, expected: str) -> MatchResult:
48
+    """
49
+    Check if output contains expected substring.
50
+
51
+    Args:
52
+        actual: Actual output
53
+        expected: Expected substring
54
+
55
+    Returns:
56
+        MatchResult indicating success/failure
57
+    """
58
+    if expected in actual:
59
+        return MatchResult(True, "Contains match", actual, expected)
60
+
61
+    return MatchResult(
62
+        False,
63
+        f"Substring not found",
64
+        actual,
65
+        expected
66
+    )
67
+
68
+
69
+def match_regex(actual: str, pattern: str, flags: int = 0) -> MatchResult:
70
+    """
71
+    Match output against a regex pattern.
72
+
73
+    Args:
74
+        actual: Actual output
75
+        pattern: Regex pattern
76
+        flags: Regex flags (e.g., re.IGNORECASE)
77
+
78
+    Returns:
79
+        MatchResult indicating success/failure
80
+    """
81
+    try:
82
+        if re.search(pattern, actual, flags):
83
+            return MatchResult(True, "Regex match", actual, pattern)
84
+        return MatchResult(False, "Regex not matched", actual, pattern)
85
+    except re.error as e:
86
+        return MatchResult(False, f"Invalid regex: {e}", actual, pattern)
87
+
88
+
89
+def match_lines(actual: str, expected_lines: List[str]) -> MatchResult:
90
+    """
91
+    Match output line by line.
92
+
93
+    Args:
94
+        actual: Actual output
95
+        expected_lines: List of expected lines
96
+
97
+    Returns:
98
+        MatchResult indicating success/failure
99
+    """
100
+    actual_lines = actual.strip().split('\n')
101
+
102
+    if len(actual_lines) != len(expected_lines):
103
+        return MatchResult(
104
+            False,
105
+            f"Line count mismatch: got {len(actual_lines)}, expected {len(expected_lines)}",
106
+            '\n'.join(actual_lines),
107
+            '\n'.join(expected_lines)
108
+        )
109
+
110
+    for i, (act, exp) in enumerate(zip(actual_lines, expected_lines)):
111
+        if act.strip() != exp.strip():
112
+            return MatchResult(
113
+                False,
114
+                f"Line {i+1} mismatch",
115
+                act.strip(),
116
+                exp.strip()
117
+            )
118
+
119
+    return MatchResult(
120
+        True,
121
+        "All lines match",
122
+        '\n'.join(actual_lines),
123
+        '\n'.join(expected_lines)
124
+    )
125
+
126
+
127
+def match_startswith(actual: str, prefix: str) -> MatchResult:
128
+    """
129
+    Check if output starts with expected prefix.
130
+
131
+    Args:
132
+        actual: Actual output
133
+        prefix: Expected prefix
134
+
135
+    Returns:
136
+        MatchResult indicating success/failure
137
+    """
138
+    stripped = actual.strip()
139
+    if stripped.startswith(prefix):
140
+        return MatchResult(True, "Prefix match", stripped, prefix)
141
+
142
+    return MatchResult(
143
+        False,
144
+        "Prefix not found",
145
+        stripped[:len(prefix)+20] + "..." if len(stripped) > len(prefix)+20 else stripped,
146
+        prefix
147
+    )
148
+
149
+
150
+def match_endswith(actual: str, suffix: str) -> MatchResult:
151
+    """
152
+    Check if output ends with expected suffix.
153
+
154
+    Args:
155
+        actual: Actual output
156
+        suffix: Expected suffix
157
+
158
+    Returns:
159
+        MatchResult indicating success/failure
160
+    """
161
+    stripped = actual.strip()
162
+    if stripped.endswith(suffix):
163
+        return MatchResult(True, "Suffix match", stripped, suffix)
164
+
165
+    return MatchResult(
166
+        False,
167
+        "Suffix not found",
168
+        "..." + stripped[-(len(suffix)+20):] if len(stripped) > len(suffix)+20 else stripped,
169
+        suffix
170
+    )
171
+
172
+
173
+def match_not_contains(actual: str, unwanted: str) -> MatchResult:
174
+    """
175
+    Verify output does NOT contain a substring.
176
+
177
+    Args:
178
+        actual: Actual output
179
+        unwanted: Substring that should not be present
180
+
181
+    Returns:
182
+        MatchResult indicating success/failure
183
+    """
184
+    if unwanted not in actual:
185
+        return MatchResult(True, "Correctly absent", actual, f"NOT {unwanted}")
186
+
187
+    return MatchResult(
188
+        False,
189
+        f"Unwanted substring found",
190
+        actual,
191
+        f"NOT {unwanted}"
192
+    )
193
+
194
+
195
+def match_empty(actual: str) -> MatchResult:
196
+    """
197
+    Verify output is empty (after stripping whitespace).
198
+
199
+    Args:
200
+        actual: Actual output
201
+
202
+    Returns:
203
+        MatchResult indicating success/failure
204
+    """
205
+    stripped = actual.strip()
206
+    if not stripped:
207
+        return MatchResult(True, "Output is empty", stripped, "<empty>")
208
+
209
+    return MatchResult(
210
+        False,
211
+        "Output is not empty",
212
+        stripped,
213
+        "<empty>"
214
+    )
215
+
216
+
217
+def match_not_empty(actual: str) -> MatchResult:
218
+    """
219
+    Verify output is not empty.
220
+
221
+    Args:
222
+        actual: Actual output
223
+
224
+    Returns:
225
+        MatchResult indicating success/failure
226
+    """
227
+    stripped = actual.strip()
228
+    if stripped:
229
+        return MatchResult(True, "Output is not empty", stripped, "<non-empty>")
230
+
231
+    return MatchResult(
232
+        False,
233
+        "Output is unexpectedly empty",
234
+        stripped,
235
+        "<non-empty>"
236
+    )
237
+
238
+
239
+class OutputMatcher:
240
+    """
241
+    Flexible output matcher supporting multiple match types.
242
+
243
+    Usage:
244
+        matcher = OutputMatcher()
245
+        result = matcher.match(output, expected="hello", match_type="contains")
246
+
247
+        # Or use the builder pattern:
248
+        result = matcher.contains("hello").match(output)
249
+    """
250
+
251
+    def __init__(self):
252
+        self._match_type = "exact"
253
+        self._expected = ""
254
+        self._flags = 0
255
+
256
+    def exact(self, expected: str) -> 'OutputMatcher':
257
+        """Set up exact match."""
258
+        self._match_type = "exact"
259
+        self._expected = expected
260
+        return self
261
+
262
+    def contains(self, substring: str) -> 'OutputMatcher':
263
+        """Set up contains match."""
264
+        self._match_type = "contains"
265
+        self._expected = substring
266
+        return self
267
+
268
+    def regex(self, pattern: str, flags: int = 0) -> 'OutputMatcher':
269
+        """Set up regex match."""
270
+        self._match_type = "regex"
271
+        self._expected = pattern
272
+        self._flags = flags
273
+        return self
274
+
275
+    def startswith(self, prefix: str) -> 'OutputMatcher':
276
+        """Set up prefix match."""
277
+        self._match_type = "startswith"
278
+        self._expected = prefix
279
+        return self
280
+
281
+    def endswith(self, suffix: str) -> 'OutputMatcher':
282
+        """Set up suffix match."""
283
+        self._match_type = "endswith"
284
+        self._expected = suffix
285
+        return self
286
+
287
+    def match(
288
+        self,
289
+        actual: str,
290
+        expected: Optional[str] = None,
291
+        match_type: Optional[str] = None
292
+    ) -> MatchResult:
293
+        """
294
+        Perform the match.
295
+
296
+        Args:
297
+            actual: Actual output to match
298
+            expected: Expected value (overrides builder)
299
+            match_type: Match type (overrides builder)
300
+
301
+        Returns:
302
+            MatchResult
303
+        """
304
+        exp = expected if expected is not None else self._expected
305
+        mt = match_type if match_type is not None else self._match_type
306
+
307
+        if mt == "exact":
308
+            return match_exact(actual, exp)
309
+        elif mt == "contains":
310
+            return match_contains(actual, exp)
311
+        elif mt == "regex":
312
+            return match_regex(actual, exp, self._flags)
313
+        elif mt == "startswith":
314
+            return match_startswith(actual, exp)
315
+        elif mt == "endswith":
316
+            return match_endswith(actual, exp)
317
+        elif mt == "empty":
318
+            return match_empty(actual)
319
+        elif mt == "not_empty":
320
+            return match_not_empty(actual)
321
+        elif mt == "not_contains":
322
+            return match_not_contains(actual, exp)
323
+        elif mt == "lines":
324
+            if isinstance(exp, str):
325
+                exp = exp.split('\n')
326
+            return match_lines(actual, exp)
327
+        else:
328
+            return MatchResult(False, f"Unknown match type: {mt}", actual, exp)
329
+
330
+
331
+# Convenience functions for pytest-style assertions
332
+def assert_output_equals(actual: str, expected: str, msg: str = "") -> None:
333
+    """Assert output equals expected (pytest-friendly)."""
334
+    result = match_exact(actual, expected)
335
+    if not result.matched:
336
+        raise AssertionError(
337
+            f"{msg}\n{result.message}\n"
338
+            f"Expected: {result.expected}\n"
339
+            f"Actual:   {result.actual}"
340
+        )
341
+
342
+
343
+def assert_output_contains(actual: str, substring: str, msg: str = "") -> None:
344
+    """Assert output contains substring (pytest-friendly)."""
345
+    result = match_contains(actual, substring)
346
+    if not result.matched:
347
+        raise AssertionError(
348
+            f"{msg}\n{result.message}\n"
349
+            f"Expected to contain: {result.expected}\n"
350
+            f"Actual: {result.actual}"
351
+        )
352
+
353
+
354
+def assert_output_matches(actual: str, pattern: str, msg: str = "") -> None:
355
+    """Assert output matches regex pattern (pytest-friendly)."""
356
+    result = match_regex(actual, pattern)
357
+    if not result.matched:
358
+        raise AssertionError(
359
+            f"{msg}\n{result.message}\n"
360
+            f"Pattern: {result.expected}\n"
361
+            f"Actual:  {result.actual}"
362
+        )