3 # Copyright (C) 2016-2017 Petr Pavlu <setup@dagobah.cz>
5 # This file is part of CenterIM.
7 # CenterIM is free software; you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 2 of the License, or
10 # (at your option) any later version.
12 # CenterIM is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with CenterIM. If not, see <http://www.gnu.org/licenses/>.
20 """Termex terminal emulator and test framework."""
32 import xml
.etree
.ElementTree
as ElementTree
34 # The module relies on selectors.select() to automatically retry the operation
35 # with a recomputed timeout when it gets interrupted by a signal. This
36 # behaviour was introduced in Python 3.5.
37 if sys
.hexversion
< 0x03050000:
38 print("This program requires at least Python 3.5.", file=sys
.stderr
)
57 COLOR_REGISTER
= ((COLOR_BLACK
, 'black'), (COLOR_RED
, 'red'),
58 (COLOR_GREEN
, 'green'), (COLOR_YELLOW
, 'yellow'),
59 (COLOR_BLUE
, 'blue'), (COLOR_MAGENTA
, 'magenta'),
60 (COLOR_CYAN
, 'cyan'), (COLOR_WHITE
, 'white'),
61 (COLOR_DEFAULT
, 'default'))
62 COLOR_TO_STRING_MAP
= {id_
: name
for id_
, name
in COLOR_REGISTER
}
63 STRING_TO_COLOR_MAP
= {name
: id_
for id_
, name
in COLOR_REGISTER
}
64 COLORS
= {id_
for id_
, _
in COLOR_REGISTER
}
65 REAL_COLOR_NAMES
= tuple(
66 name
for id_
, name
in COLOR_REGISTER
if id_
!= COLOR_DEFAULT
)
68 COLOR_DEFAULT_FOREGROUND
= COLOR_BLACK
69 COLOR_DEFAULT_BACKGROUND
= COLOR_WHITE
72 CODE_FN
= ('\x1bOP', '\x1bOQ', '\x1bOR', '\x1bOS', '\x1b[15~', '\x1b[17~',
73 '\x1b[18~', '\x1b[19~', '\x1b[20~', '\x1b[21~', '\x1b[23~')
74 CODE_PAGE_UP
= '\x1b[5~'
75 CODE_PAGE_DOWN
= '\x1b[6~'
78 def attr_to_string(attr
):
79 """Get string representation of given attributes."""
82 if (attr
& ATTR_REVERSE
) != 0:
87 def string_to_attr(string
):
89 Convert a string to attributes. Exception ValueError is raised if some
94 for attr
in string
.split('|'):
97 elif attr
== 'reverse':
100 raise ValueError("Unrecognized attribute '{}'".format(attr
))
104 def color_to_string(color
):
105 """Get string representation of a given color."""
107 return COLOR_TO_STRING_MAP
[color
]
110 def string_to_color(string
):
112 Convert a string to a color. Exception ValueError is raised if the color
113 name is not recognized.
117 return STRING_TO_COLOR_MAP
[string
]
119 raise ValueError("Unrecognized color '{}'".format(string
))
123 """On-screen character."""
125 def __init__(self
, char
=" ", attr
=ATTR_NORMAL
, fgcolor
=COLOR_DEFAULT
,
126 bgcolor
=COLOR_DEFAULT
):
129 self
.fgcolor
= fgcolor
130 self
.bgcolor
= bgcolor
132 def __eq__(self
, other
):
133 return self
.__dict
__ == other
.__dict
__
135 def _get_translated_fgcolor(self
):
137 Return the foreground color. If the current color is COLOR_DEFAULT then
138 COLOR_DEFAULT_FOREGROUND is returned.
141 if self
.fgcolor
== COLOR_DEFAULT
:
142 return COLOR_DEFAULT_FOREGROUND
145 def _get_translated_bgcolor(self
):
147 Return the background color. If the current color is COLOR_DEFAULT then
148 COLOR_DEFAULT_BACKGROUND is returned.
151 if self
.bgcolor
== COLOR_DEFAULT
:
152 return COLOR_DEFAULT_BACKGROUND
155 def get_tag_foreground(self
):
157 Return a name of the final foreground color that should be used to
158 display the character on the screen.
161 if self
.attr
& ATTR_REVERSE
:
162 color
= self
._get
_translated
_bgcolor
()
164 color
= self
._get
_translated
_fgcolor
()
165 return color_to_string(color
)
167 def get_tag_background(self
):
169 Return a name of the final background color that should be used to
170 display the character on the screen.
173 if self
.attr
& ATTR_REVERSE
:
174 color
= self
._get
_translated
_fgcolor
()
176 color
= self
._get
_translated
_bgcolor
()
177 return color_to_string(color
)
181 """Termex terminal emulator."""
187 class _TerminalConnectionException(Exception):
189 Exception reported when communication with the pseudo-terminal fails.
193 def __init__(self
, root
, program
, mode
, terminfo
=None):
195 self
._program
= program
197 self
._terminfo
= terminfo
199 self
._child
_pid
= None
205 self
._attr
= ATTR_NORMAL
206 self
._fgcolor
= COLOR_DEFAULT
207 self
._bgcolor
= COLOR_DEFAULT
210 # Initialize the GUI if requested.
212 self
._root
.title("Termex")
213 self
._frame
= tkinter
.Frame(self
._root
)
215 self
._text
= tkinter
.Text(self
._root
, height
=ROWS
, width
=COLUMNS
)
217 foreground
=color_to_string(COLOR_DEFAULT_FOREGROUND
),
218 background
=color_to_string(COLOR_DEFAULT_BACKGROUND
))
221 # Configure tag values.
222 for fgcolor_str
in REAL_COLOR_NAMES
:
223 for bgcolor_str
in REAL_COLOR_NAMES
:
224 tag
= 'tag_{}-{}'.format(fgcolor_str
, bgcolor_str
)
225 self
._text
.tag_config(tag
, foreground
=fgcolor_str
,
226 background
=bgcolor_str
)
230 if self
._mode
== self
.MODE_RECORD
:
231 self
._test
_e
= ElementTree
.Element('test')
233 def _start_program(self
):
235 Fork, connect the child's controlling terminal to a pseudo-terminal and
236 start the selected child program.
238 Parent behaviour: Returns True when the fork was successful, False
239 otherwise. Note that the returned value does not provide information
240 whether the exec call in the child process was successful or not. That
241 must be determined by attempting communication with the child.
243 Child behaviour: Execs the selected program and does not return if the
244 call was successful, returns False otherwise.
247 # Fork and connect the child's controlling terminal to a
250 self
._child
_pid
, self
._fd
= pty
.fork()
252 print("Fork to run '{}' failed: {}".format(self
._program
, e
),
255 if self
._child
_pid
== 0:
257 env
= {'PATH': '/bin:/usr/bin', 'TERM': 'termex',
258 'LC_ALL': 'en_US.UTF-8'}
260 env
['TERMINFO'] = self
._terminfo
261 elif 'TERMINFO' in os
.environ
:
262 env
['TERMINFO'] = os
.environ
['TERMINFO']
263 os
.execle(self
._program
, self
._program
, env
)
265 print("Failed to execute '{}': {}".format(self
._program
, e
),
271 def _finalize_program(self
):
273 Close the connection to the pseudo-terminal and wait for the child
274 program to complete. Returns True when the connection was successfully
275 closed and the child completed in the timeout limit, False otherwise.
280 # Close the file descriptor that is connected to the child's
281 # controlling terminal.
285 print("Failed to close file descriptor '{}' that is connected to "
286 "the child's controlling terminal: {}.".format(self
._fd
, e
),
290 # Wait for the child to finish. It should terminate now that its input
292 for _
in range(CHILD_TIMEOUT
):
294 pid
, _status
= os
.waitpid(self
._child
_pid
, os
.WNOHANG
)
296 print("Failed to wait on child '{}' to complete: "
297 "{}.".format(pid
, e
), file=sys
.stderr
)
304 print("Child '{}' has not completed.".format(self
._child
_pid
),
310 def run_gui_mainloop(self
):
311 """Start the selected child program and run the tkinter's main loop."""
313 assert self
._mode
== self
.MODE_RUN
or self
._mode
== self
.MODE_RECORD
315 # Start the specified program.
316 if not self
._start
_program
():
320 # Prepare for running the main loop.
321 self
._root
.createfilehandler(
322 self
._fd
, tkinter
.READABLE
,
323 lambda fd
, mask
: self
._pty
_callback
())
324 self
._root
.bind('<Key>', self
._tk
_key
)
325 self
._root
.bind('<<Quit>>', lambda e
: self
._quit
_gui
_mainloop
())
326 self
._root
.protocol('WM_DELETE_WINDOW', self
._quit
_gui
_mainloop
)
330 self
._root
.mainloop()
331 except self
._TerminalConnectionException
as e
:
332 print("{}.".format(e
), file=sys
.stderr
)
334 self
._root
.deletefilehandler(self
._fd
)
336 # Finalize the run of the child program.
337 self
._finalize
_program
()
339 def _quit_gui_mainloop(self
):
340 """Exit the tkinter's main loop."""
342 assert self
._mode
== self
.MODE_RUN
or self
._mode
== self
.MODE_RECORD
345 def _pty_callback(self
):
347 Process a data event from the pseudo-terminal. Returns True when the
348 connection to the pseudo-terminal was closed, False otherwise.
349 Exception _TerminalConnectionException is raised if the read of the new
350 data from the pseudo-terminal fails.
355 char
= os
.read(self
._fd
, 1)
357 if e
.errno
== errno
.EIO
:
360 raise self
._TerminalConnectionException
(
361 "Error reading from file descriptor '{}' that is "
362 "connected to the child's controlling terminal: "
363 "{}".format(self
._fd
, e
))
365 # Check whether the descriptor referring to the pseudo-terminal slave
366 # has been closed or end of file was reached.
367 if closed
or len(char
) == 0:
372 self
._charbuf
+= char
374 if self
._handle
_sequence
(self
._charbuf
):
377 # print("Unmatched {}.".format(self._charbuf), file=sys.stderr)
381 def _send_key(self
, chars
, name
):
383 Write the specified characters that represent one key to the
384 pseudo-terminal. If the recording mode is enabled then the specified
385 key name is recorded in the test playbook. Exception
386 _TerminalConnectionException is raised if the write to the
387 pseudo-terminal fails.
390 if self
._mode
== self
.MODE_RECORD
:
392 action_e
= ElementTree
.SubElement(self
._test
_e
, 'action')
393 action_e
.set('key', name
)
394 print("Recorded key '{}'.".format(name
))
396 # Send the key to the terminal.
398 os
.write(self
._fd
, str.encode(chars
))
400 raise self
._TerminalConnectionException
(
401 "Error writing characters '{}' to file descriptor '{}' that "
402 "is connected to the child's controlling terminal: "
403 "{}".format(chars
, self
._fd
, e
))
405 def _handle_sequence(self
, seq
):
407 Process a byte sequence received from the pseudo-terminal. Returns True
408 when the sequence was recognized and successfully handled, False
412 if re
.fullmatch(b
'[^\x01-\x1f]+', seq
):
414 uchar
= seq
.decode('utf-8')
416 TermChar(uchar
, self
._attr
, self
._fgcolor
, self
._bgcolor
))
419 # Continue on the assumption that it is not yet a complete
420 # character. This assumption is wrong if the received text is
421 # actually malformed.
430 # Backspace non-destructively.
434 # Go to beginning of line.
438 # Move cursor down one line.
442 # Controls beginning with ESC.
445 match
= re
.fullmatch(b
'\x1b\\[([0-9]+)@', seq
)
447 # Insert blank characters.
448 self
._insert
_blanks
(int(match
.group(1)))
451 # Set cursor position to the default (top left).
455 match
= re
.fullmatch(b
'\x1b\\[([0-9]+);([0-9]+)H', seq
)
457 # Set cursor position to (y,x).
458 self
._cur
_y
= int(match
.group(1))
459 self
._cur
_x
= int(match
.group(2))
461 if self
._charbuf
== b
'\x1b[K':
462 # Erase in line to right.
463 for x
in range(self
._cur
_x
, COLUMNS
):
464 self
._print
_char
_at
(self
._cur
_y
, x
, TermChar())
466 if seq
== b
'\x1b[2J':
467 # Erase display completely.
471 # Normal character attribute (all attributes off).
472 self
._attr
= ATTR_NORMAL
474 if seq
== b
'\x1b[7m':
475 # Inverse character attribute.
476 self
._attr |
= ATTR_REVERSE
478 match
= re
.fullmatch(b
'\x1b\\[3([0-9]+)m', seq
)
480 # Set foreground color.
481 color
= int(match
.group(1))
483 self
._fgcolor
= color
486 match
= re
.fullmatch(b
'\x1b\\[4([0-9]+)m', seq
)
488 # Set background color.
489 color
= int(match
.group(1))
491 self
._bgcolor
= color
494 if seq
== b
'\x1b[?25l':
500 def _cursor_down(self
):
502 Move the screen cursor one line down. The screen is scrolled if the
503 cursor points to the last line.
506 if self
._cur
_y
< ROWS
- 1:
509 assert self
._cur
_y
== ROWS
- 1
511 # At the last line of the terminal, scroll up the screen.
513 self
._screen
.append([TermChar() for x
in range(COLUMNS
)])
516 self
._text
.config(state
=tkinter
.NORMAL
)
517 self
._text
.delete('1.0', '2.0')
518 self
._text
.insert(tkinter
.END
, "\n" + " " * COLUMNS
)
519 self
._text
.config(state
=tkinter
.DISABLED
)
521 def _erase_all(self
):
522 """Completely clear the terminal's screen."""
524 self
._screen
= [[TermChar() for x
in range(COLUMNS
)]
525 for y
in range(ROWS
)]
528 self
._text
.config(state
=tkinter
.NORMAL
)
529 self
._text
.delete('1.0', tkinter
.END
)
530 self
._text
.insert('1.0', "\n".join([" " * COLUMNS
] * ROWS
))
531 self
._text
.config(state
=tkinter
.DISABLED
)
533 def _insert_blanks(self
, w
):
535 Replace the specified number of characters on the current screen line
539 del self
._screen
[self
._cur
_y
][-w
:]
540 pre
= self
._screen
[self
._cur
_y
][:self
._cur
_x
]
541 post
= self
._screen
[self
._cur
_y
][self
._cur
_x
:]
542 self
._screen
[self
._cur
_y
] = pre
+ [TermChar() for x
in range(w
)] + post
545 self
._text
.config(state
=tkinter
.NORMAL
)
546 self
._text
.delete('{}.end-{}c'.format(self
._cur
_y
+ 1, w
),
547 '{}.end'.format(self
._cur
_y
+ 1))
548 self
._text
.insert('{}.{}'.format(self
._cur
_y
+ 1, self
._cur
_x
),
550 self
._text
.config(state
=tkinter
.DISABLED
)
552 def _print_char_at(self
, y
, x
, char
):
553 """Output one character on the screen at the specified coordinates."""
555 # Record the character in the internal screen representation.
556 self
._screen
[y
][x
] = char
559 # Add the character to the terminal text widget.
560 self
._text
.config(state
=tkinter
.NORMAL
)
561 pos
= '{}.{}'.format(y
+ 1, x
)
562 self
._text
.delete(pos
)
564 tag
= 'tag_{}-{}'.format(char
.get_tag_foreground(),
565 char
.get_tag_background())
566 self
._text
.insert(pos
, char
.char
, tag
)
567 self
._text
.config(state
=tkinter
.DISABLED
)
569 def _print_char(self
, char
):
570 """Output one character on the screen at the cursor position."""
572 self
._print
_char
_at
(self
._cur
_y
, self
._cur
_x
, char
)
574 # Advance the cursor.
576 if self
._cur
_x
== COLUMNS
:
580 def _tk_key(self
, event
):
581 """Process a key pressed by the user."""
583 if len(event
.char
) != 0:
584 if event
.char
== CODE_ENTER
:
585 self
._send
_key
(event
.char
, 'Enter')
587 self
._send
_key
(event
.char
, event
.char
)
590 # A special key was pressed.
591 if event
.keysym
== 'F12':
592 self
._record
_expected
_screen
()
594 if event
.keysym
== 'Prior':
595 self
._send
_key
(CODE_PAGE_UP
, 'PageUp')
597 if event
.keysym
== 'Next':
598 self
._send
_key
(CODE_PAGE_DOWN
, 'PageDown')
600 match
= re
.fullmatch('F([0-9]+)', event
.keysym
)
603 fnum
= int(match
.group(1))
604 if fnum
>= 1 and fnum
<= len(CODE_FN
):
605 self
._send
_key
(CODE_FN
[fnum
- 1], event
.keysym
)
608 print("Unrecognized key {}.".format(event
.keysym
), file=sys
.stderr
)
610 def _get_screen_xml(self
, screen
):
612 Return an ElementTree.Element that represents the current screen
616 expect_e
= ElementTree
.Element('expect')
617 data_e
= ElementTree
.SubElement(expect_e
, 'data')
622 # Print content of the screen.
623 for y
in range(ROWS
):
624 line_e
= ElementTree
.SubElement(data_e
, 'line')
629 for x
in range(COLUMNS
):
630 term_char
= screen
[y
][x
]
632 line_e
.text
+= term_char
.char
634 color
= (term_char
.attr
, term_char
.fgcolor
, term_char
.bgcolor
)
635 if color
== (ATTR_NORMAL
, COLOR_DEFAULT
, COLOR_DEFAULT
):
637 elif color
in colors
:
642 assert new_key
!= 'z'
643 new_key
= chr(ord(new_key
) + 1)
646 # Record any non-default attributes/colors.
647 if attr
!= ' ' * COLUMNS
:
648 attr_e
= ElementTree
.SubElement(data_e
, 'attr')
651 # Record used color schemes.
653 scheme_e
= ElementTree
.SubElement(expect_e
, 'scheme')
654 for color
, key
in sorted(colors
.items(), key
=lambda x
: x
[1]):
655 attr
, fgcolor
, bgcolor
= color
656 color_e
= ElementTree
.SubElement(scheme_e
, 'color')
657 color_e
.set('key', key
)
659 attr_str
= attr_to_string(attr
)
661 color_e
.set('attributes', attr_str
)
663 fgcolor_str
= color_to_string(fgcolor
)
665 color_e
.set('foreground', fgcolor_str
)
667 bgcolor_str
= color_to_string(bgcolor
)
669 color_e
.set('background', bgcolor_str
)
673 def _record_expected_screen(self
):
675 Record the current screen content as an expected screen in the test
676 playbook that is being created.
679 assert self
._mode
== self
.MODE_RUN
or self
._mode
== self
.MODE_RECORD
681 if self
._mode
!= self
.MODE_RECORD
:
682 print("Recording is not enabled.", file=sys
.stderr
)
685 expect_e
= self
._get
_screen
_xml
(self
._screen
)
686 self
._test
_e
.append(expect_e
)
687 print("Recorded expected screen.")
689 # Method _indent_xml() is based on a code from
690 # http://effbot.org/zone/element-lib.htm#prettyprint.
691 def _indent_xml(self
, elem
, level
=0):
693 Indent elements of a given ElementTree so it can be pretty-printed.
696 i
= '\n' + '\t' * level
698 if not elem
.text
or not elem
.text
.strip():
701 self
._indent
_xml
(e
, level
+1)
702 if not e
.tail
or not e
.tail
.strip():
704 if not elem
.tail
or not elem
.tail
.strip():
707 def output_test(self
, filename
):
709 Output a recorded playbook to a file with the given name. Returns True
710 when the writing of the test data succeeded, False otherwise.
713 assert self
._mode
== self
.MODE_RECORD
715 # Pretty-format the XML tree.
716 self
._indent
_xml
(self
._test
_e
)
719 tree
= ElementTree
.ElementTree(self
._test
_e
)
721 tree
.write(filename
, 'unicode', True)
722 except Exception as e
:
723 print("Failed to write playbook file '{}': {}.".format(
724 filename
, e
), file=sys
.stderr
)
729 class _TestFailure(Exception):
730 """Exception reported when a test failed."""
733 def _playbook_key(self
, cmd_e
):
735 Parse a description of one key action and send the key to the terminal.
736 Exception _TestFailure is raised if the description is malformed or
737 incomplete, exception _TerminalConnectionException can be thrown when
738 communication with the pseudo-terminal fails.
741 assert self
._mode
== self
.MODE_TEST
744 key
= cmd_e
.attrib
['key']
746 raise self
._TestFailure
("Element 'action' is missing required "
749 # Handle simple characters.
751 self
._send
_key
(key
, key
)
754 # Handle special keys.
756 self
._send
_key
(CODE_ENTER
, key
)
759 self
._send
_key
(CODE_PAGE_UP
, key
)
761 if key
== 'PageDown':
762 self
._send
_key
(CODE_PAGE_DOWN
, key
)
763 match
= re
.fullmatch('F([0-9]+)', key
)
766 fnum
= int(match
.group(1))
767 if fnum
>= 1 and fnum
<= len(CODE_FN
):
768 self
._send
_key
(CODE_FN
[fnum
- 1], key
)
771 raise self
._TestFailure
(
772 "Element 'action' specifies unrecognized key '{}'".format(key
))
774 def _parse_color_scheme(self
, scheme_e
):
776 Parse color scheme of one expected screen. Dictionary with
777 {'key': (attr, fgcolor, bgcolor), ...} is returned on success,
778 exception _TestFailure is raised if the description is malformed or
782 assert self
._mode
== self
.MODE_TEST
785 for color_e
in scheme_e
:
787 key
= color_e
.attrib
['key']
789 raise self
._TestFailure
(
790 "Element 'color' is missing required attribute 'key'")
793 if 'attributes' in color_e
.attrib
:
795 attr
= color_e
.attrib
['attributes']
796 except ValueError as e
:
797 raise self
._TestFailure
(
798 "Value of attribute 'attributes' is invalid: "
802 if 'foreground' in color_e
.attrib
:
804 attr
= color_e
.attrib
['foreground']
805 except ValueError as e
:
806 raise self
._TestFailure
(
807 "Value of attribute 'foreground' is invalid: "
811 if 'background' in color_e
.attrib
:
813 attr
= color_e
.attrib
['background']
814 except ValueError as e
:
815 raise self
._TestFailure
(
816 "Value of attribute 'background' is invalid: "
819 colors
[key
] = (attr
, fgcolor
, bgcolor
)
823 def _parse_screen_data(self
, data_e
, colors
):
825 Parse screen lines of one expected screen. Internal screen
826 representation is returned on success, exception _TestFailure is raised
827 if the description is malformed or incomplete.
830 assert self
._mode
== self
.MODE_TEST
838 for data_sub_e
in data_e
:
839 # Do common processing for both states.
840 if data_sub_e
.tag
== 'line':
841 # Append the previous line.
843 expected_screen
.append(line
)
844 # Parse the new line.
845 line
= [TermChar(char
) for char
in data_sub_e
.text
]
847 if state
== NEW_LINE
and data_sub_e
.tag
!= 'line':
848 raise self
._TestFailure
("Element '{}' is invalid, expected "
849 "'line'".format(data_sub_e
.tag
))
851 elif state
== NEW_LINE_OR_ATTR
:
852 if data_sub_e
.tag
== 'attr':
853 if len(data_sub_e
.text
) != len(line
):
854 raise self
._TestFailure
(
855 "Element 'attr' does not match the previous line, "
856 "expected '{}' attribute characters but got "
857 "'{}'".format(len(line
), len(data_sub_e
.text
)))
859 for i
, key
in enumerate(data_sub_e
.text
):
861 attr
, fgcolor
, bgcolor
= colors
[key
]
863 raise self
._TestFailure
("Color attribute '{}' is "
864 "not defined".format(key
))
866 line
[i
].fgcolor
= fgcolor
867 line
[i
].bgcolor
= bgcolor
868 elif data_sub_e
.tag
!= 'line':
869 raise self
._TestFailure
(
870 "Element '{}' is invalid, expected 'line' or "
871 "'attr'".format(data_sub_e
.tag
))
873 # Append the final line.
875 expected_screen
.append(line
)
877 return expected_screen
879 def _parse_expected_screen(self
, expect_e
):
881 Parse a description of one expected screen. Internal screen
882 representation is returned on success, exception _TestFailure is raised
883 if the description is malformed or incomplete.
886 assert self
._mode
== self
.MODE_TEST
890 for sub_e
in expect_e
:
891 if sub_e
.tag
== 'data':
893 raise self
._TestFailure
("Element 'expect' contains "
894 "multiple 'data' sub-elements")
896 elif sub_e
.tag
== 'scheme':
898 raise self
._TestFailure
("Element 'expect' contains "
899 "multiple 'scheme' sub-elements")
903 raise self
._TestFailure
(
904 "Element 'expect' is missing required sub-element 'data'")
906 # Parse the color scheme.
908 colors
= self
._parse
_color
_scheme
(scheme_e
)
912 # Parse the screen data.
913 return self
._parse
_screen
_data
(data_e
, colors
)
915 def _report_failed_expectation(self
, expected_screen
):
917 Report that the expected screen state has not been reached. The output
918 consists of the expected screen, the current screen content, followed
919 by differences between the two screens.
922 assert self
._mode
== self
.MODE_TEST
924 # Print the expected screen. The output is not verbatim as it was
925 # specified in the input file, but instead the screen is printed in the
926 # same way the current screen gets output. This allows to properly show
927 # differences between the two screens.
929 expected_screen_e
= self
._get
_screen
_xml
(expected_screen
)
930 self
._indent
_xml
(expected_screen_e
)
931 expected_screen_str
= ElementTree
.tostring(
932 expected_screen_e
, 'unicode')
933 print("Expected (normalized) screen:", file=sys
.stderr
)
934 print(expected_screen_str
, file=sys
.stderr
)
936 # Print the current screen.
937 current_screen_e
= self
._get
_screen
_xml
(self
._screen
)
938 self
._indent
_xml
(current_screen_e
)
939 current_screen_str
= ElementTree
.tostring(current_screen_e
, 'unicode')
940 print("Current screen:", file=sys
.stderr
)
941 print(current_screen_str
, file=sys
.stderr
)
944 print("Differences:", file=sys
.stderr
)
945 sys
.stderr
.writelines(difflib
.unified_diff(
946 expected_screen_str
.splitlines(keepends
=True),
947 current_screen_str
.splitlines(keepends
=True),
948 fromfile
="Expected screen", tofile
="Current screen"))
950 def _execute_playbook(self
, test_e
):
952 Run the main loop and execute the given test playbook. Normal return
953 from the method indicates that the test succeeded. Exception
954 _TestFailure is raised when the test fails and exception
955 _TerminalConnectionException can be thrown when communication with the
956 pseudo-terminal fails.
959 assert self
._mode
== self
.MODE_TEST
961 if test_e
.tag
!= 'test':
962 raise self
._TestFailure
("Root element '{}' is invalid, expected "
963 "'test'".format(test_e
.tag
))
964 cmd_iter
= iter(test_e
)
966 # Start the main loop.
967 with selectors
.DefaultSelector() as sel
:
968 sel
.register(self
._fd
, selectors
.EVENT_READ
)
970 expected_screen
= None
973 # Process any actions and find an expected screen.
974 while not expected_screen
and more_commands
:
976 cmd_e
= next(cmd_iter
)
977 if cmd_e
.tag
== 'action':
978 self
._playbook
_key
(cmd_e
)
979 elif cmd_e
.tag
== 'expect':
980 expected_screen
= self
._parse
_expected
_screen
(
982 # Stop processing more commands for now and wait
983 # for the expected screen to appear.
986 raise self
._TestFailure
(
987 "Element '{}' is invalid, expected 'action' "
988 "or 'expect'".format(cmd_e
.tag
))
989 except StopIteration:
991 more_commands
= False
993 # Wait for the expected screen.
994 events
= sel
.select(CHILD_TIMEOUT
)
997 self
._report
_failed
_expectation
(expected_screen
)
998 raise self
._TestFailure
(
999 "Timeout reached. No event received in the last {} "
1000 "second(s)".format(CHILD_TIMEOUT
))
1002 # Expect only an event on self._fd.
1003 assert len(events
) == 1
1006 assert key
.fd
== self
._fd
1008 closed
= self
._pty
_callback
()
1011 raise self
._TestFailure
(
1012 "Connection to the terminal was closed but the "
1013 "playbook contains more commands")
1016 # Check if the expected screen is present.
1017 if self
._screen
== expected_screen
:
1018 expected_screen
= None
1020 def execute_test(self
, filename
):
1022 Load test data from a given file, start the program under the test and
1023 execute the test playbook. Returns True when the test succeeded, False
1027 assert self
._mode
== self
.MODE_TEST
1029 # Read the test data.
1031 tree
= ElementTree
.ElementTree(file=filename
)
1032 except Exception as e
:
1033 print("Failed to read playbook file '{}': {}.".format(filename
, e
),
1037 # Start the specified program.
1038 if not self
._start
_program
():
1041 # Execute the test playbook.
1044 self
._execute
_playbook
(tree
.getroot())
1045 except (self
._TerminalConnectionException
, self
._TestFailure
) as e
:
1046 print("{}.".format(e
), file=sys
.stderr
)
1049 # Finalize the run of the child program.
1050 if not self
._finalize
_program
():
1053 # Return whether the test passed.
1059 Parse command line arguments and execute the operation that the user
1060 selected. Returns 0 if the operation was successful and a non-zero value
1064 # Parse command line arguments.
1065 parser
= argparse
.ArgumentParser()
1066 parser
.add_argument(
1067 '-t', '--terminfo', metavar
='PATH', help="path to terminfo directory")
1069 subparsers
= parser
.add_subparsers(dest
='command')
1070 subparsers
.required
= True
1072 program_parser
= argparse
.ArgumentParser(add_help
=False)
1073 program_parser
.add_argument('program')
1075 # Create the parser for the 'run' command.
1076 parser_run
= subparsers
.add_parser(
1077 'run', parents
=[program_parser
], help="run a command")
1078 parser_run
.set_defaults(mode
=Term
.MODE_RUN
)
1080 # Create the parser for the 'record' command.
1081 parser_record
= subparsers
.add_parser(
1082 'record', parents
=[program_parser
], help="record a test")
1083 parser_record
.set_defaults(mode
=Term
.MODE_RECORD
)
1084 parser_record
.add_argument(
1085 '-p', '--playbook', metavar
='FILE', required
=True,
1086 help="output playbook file")
1088 # Create the parser for the 'test' command.
1089 parser_test
= subparsers
.add_parser(
1090 'test', parents
=[program_parser
], help="run a test")
1091 parser_test
.set_defaults(mode
=Term
.MODE_TEST
)
1092 parser_test
.add_argument(
1093 '-p', '--playbook', metavar
='FILE', required
=True,
1094 help="input playbook file")
1096 args
= parser
.parse_args()
1099 if args
.mode
in (Term
.MODE_RUN
, Term
.MODE_RECORD
):
1100 # Start the terminal GUI.
1102 tk_root
= tkinter
.Tk()
1103 except tkinter
.TclError
as e
:
1104 print("Failed to initialize GUI: {}.".format(e
), file=sys
.stderr
)
1107 term
= Term(tk_root
, args
.program
, args
.mode
, args
.terminfo
)
1109 # Start the GUI main loop.
1110 term
.run_gui_mainloop()
1112 # Execute and check the playbook, without running GUI.
1113 ok
= term
.execute_test(args
.playbook
)
1121 print("Run of '{}' using playbook '{}' {}.".format(
1122 args
.program
, args
.playbook
, msg
))
1125 if args
.mode
== Term
.MODE_RECORD
:
1126 # Get the recorded test data and write them to a file.
1127 if not term
.output_test(args
.playbook
):
1133 if __name__
== '__main__':
1136 # vim: set tabstop=4 shiftwidth=4 textwidth=80 expandtab :