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 # Test mode obtains the program name from the playbook.
200 if self
._mode
== self
.MODE_TEST
:
201 assert self
._program
is None
203 self
._child
_pid
= None
209 self
._attr
= ATTR_NORMAL
210 self
._fgcolor
= COLOR_DEFAULT
211 self
._bgcolor
= COLOR_DEFAULT
214 # Initialize the GUI if requested.
216 self
._root
.title("Termex")
217 self
._frame
= tkinter
.Frame(self
._root
)
219 self
._text
= tkinter
.Text(self
._root
, height
=ROWS
, width
=COLUMNS
)
221 foreground
=color_to_string(COLOR_DEFAULT_FOREGROUND
),
222 background
=color_to_string(COLOR_DEFAULT_BACKGROUND
))
225 # Configure tag values.
226 for fgcolor_str
in REAL_COLOR_NAMES
:
227 for bgcolor_str
in REAL_COLOR_NAMES
:
228 tag
= 'tag_{}-{}'.format(fgcolor_str
, bgcolor_str
)
229 self
._text
.tag_config(tag
, foreground
=fgcolor_str
,
230 background
=bgcolor_str
)
234 if self
._mode
== self
.MODE_RECORD
:
235 self
._test
_e
= ElementTree
.Element('test')
236 self
._test
_e
.set('program', self
._program
)
238 def _start_program(self
):
240 Fork, connect the child's controlling terminal to a pseudo-terminal and
241 start the selected child program.
243 Parent behaviour: Returns True when the fork was successful, False
244 otherwise. Note that the returned value does not provide information
245 whether the exec call in the child process was successful or not. That
246 must be determined by attempting communication with the child.
248 Child behaviour: Execs the selected program and does not return if the
249 call was successful, returns False otherwise.
252 # Fork and connect the child's controlling terminal to a
255 self
._child
_pid
, self
._fd
= pty
.fork()
257 print("Fork to run '{}' failed: {}".format(self
._program
, e
),
260 if self
._child
_pid
== 0:
262 env
= {'PATH': '/bin:/usr/bin', 'TERM': 'termex',
263 'LC_ALL': 'en_US.UTF-8'}
265 env
['TERMINFO'] = self
._terminfo
266 elif 'TERMINFO' in os
.environ
:
267 env
['TERMINFO'] = os
.environ
['TERMINFO']
268 os
.execle(self
._program
, self
._program
, env
)
270 print("Failed to execute '{}': {}".format(self
._program
, e
),
276 def _finalize_program(self
):
278 Close the connection to the pseudo-terminal and wait for the child
279 program to complete. Returns True when the connection was successfully
280 closed and the child completed in the timeout limit, False otherwise.
285 # Close the file descriptor that is connected to the child's
286 # controlling terminal.
290 print("Failed to close file descriptor '{}' that is connected to "
291 "the child's controlling terminal: {}.".format(self
._fd
, e
),
295 # Wait for the child to finish. It should terminate now that its input
297 for _
in range(CHILD_TIMEOUT
):
299 pid
, _status
= os
.waitpid(self
._child
_pid
, os
.WNOHANG
)
301 print("Failed to wait on child '{}' to complete: "
302 "{}.".format(pid
, e
), file=sys
.stderr
)
309 print("Child '{}' has not completed.".format(self
._child
_pid
),
315 def run_gui_mainloop(self
):
316 """Start the selected child program and run the tkinter's main loop."""
318 assert self
._mode
== self
.MODE_RUN
or self
._mode
== self
.MODE_RECORD
320 # Start the specified program.
321 if not self
._start
_program
():
325 # Prepare for running the main loop.
326 self
._root
.createfilehandler(
327 self
._fd
, tkinter
.READABLE
,
328 lambda fd
, mask
: self
._pty
_callback
())
329 self
._root
.bind('<Key>', self
._tk
_key
)
330 self
._root
.bind('<<Quit>>', lambda e
: self
._quit
_gui
_mainloop
())
331 self
._root
.protocol('WM_DELETE_WINDOW', self
._quit
_gui
_mainloop
)
335 self
._root
.mainloop()
336 except self
._TerminalConnectionException
as e
:
337 print("{}.".format(e
), file=sys
.stderr
)
339 self
._root
.deletefilehandler(self
._fd
)
341 # Finalize the run of the child program.
342 self
._finalize
_program
()
344 def _quit_gui_mainloop(self
):
345 """Exit the tkinter's main loop."""
347 assert self
._mode
== self
.MODE_RUN
or self
._mode
== self
.MODE_RECORD
350 def _pty_callback(self
):
352 Process a data event from the pseudo-terminal. Returns True when the
353 connection to the pseudo-terminal was closed, False otherwise.
354 Exception _TerminalConnectionException is raised if the read of the new
355 data from the pseudo-terminal fails.
360 char
= os
.read(self
._fd
, 1)
362 if e
.errno
== errno
.EIO
:
365 raise self
._TerminalConnectionException
(
366 "Error reading from file descriptor '{}' that is "
367 "connected to the child's controlling terminal: "
368 "{}".format(self
._fd
, e
))
370 # Check whether the descriptor referring to the pseudo-terminal slave
371 # has been closed or end of file was reached.
372 if closed
or len(char
) == 0:
377 self
._charbuf
+= char
379 if self
._handle
_sequence
(self
._charbuf
):
382 # print("Unmatched {}.".format(self._charbuf), file=sys.stderr)
386 def _send_key(self
, chars
, name
):
388 Write the specified characters that represent one key to the
389 pseudo-terminal. If the recording mode is enabled then the specified
390 key name is recorded in the test playbook. Exception
391 _TerminalConnectionException is raised if the write to the
392 pseudo-terminal fails.
395 if self
._mode
== self
.MODE_RECORD
:
397 action_e
= ElementTree
.SubElement(self
._test
_e
, 'action')
398 action_e
.set('key', name
)
399 print("Recorded key '{}'.".format(name
))
401 # Send the key to the terminal.
403 os
.write(self
._fd
, str.encode(chars
))
405 raise self
._TerminalConnectionException
(
406 "Error writing characters '{}' to file descriptor '{}' that "
407 "is connected to the child's controlling terminal: "
408 "{}".format(chars
, self
._fd
, e
))
410 def _handle_sequence(self
, seq
):
412 Process a byte sequence received from the pseudo-terminal. Returns True
413 when the sequence was recognized and successfully handled, False
417 if re
.fullmatch(b
'[^\x01-\x1f]+', seq
):
419 uchar
= seq
.decode('utf-8')
421 TermChar(uchar
, self
._attr
, self
._fgcolor
, self
._bgcolor
))
424 # Continue on the assumption that it is not yet a complete
425 # character. This assumption is wrong if the received text is
426 # actually malformed.
435 # Backspace non-destructively.
439 # Go to beginning of line.
443 # Move cursor down one line.
447 # Controls beginning with ESC.
450 match
= re
.fullmatch(b
'\x1b\\[([0-9]+)@', seq
)
452 # Insert blank characters.
453 self
._insert
_blanks
(int(match
.group(1)))
456 # Set cursor position to the default (top left).
460 match
= re
.fullmatch(b
'\x1b\\[([0-9]+);([0-9]+)H', seq
)
462 # Set cursor position to (y,x).
463 self
._cur
_y
= int(match
.group(1))
464 self
._cur
_x
= int(match
.group(2))
466 if self
._charbuf
== b
'\x1b[K':
467 # Erase in line to right.
468 for x
in range(self
._cur
_x
, COLUMNS
):
469 self
._print
_char
_at
(self
._cur
_y
, x
, TermChar())
471 if seq
== b
'\x1b[2J':
472 # Erase display completely.
476 # Normal character attribute (all attributes off).
477 self
._attr
= ATTR_NORMAL
479 if seq
== b
'\x1b[7m':
480 # Inverse character attribute.
481 self
._attr |
= ATTR_REVERSE
483 match
= re
.fullmatch(b
'\x1b\\[3([0-9]+)m', seq
)
485 # Set foreground color.
486 color
= int(match
.group(1))
488 self
._fgcolor
= color
491 match
= re
.fullmatch(b
'\x1b\\[4([0-9]+)m', seq
)
493 # Set background color.
494 color
= int(match
.group(1))
496 self
._bgcolor
= color
499 if seq
== b
'\x1b[?25l':
505 def _cursor_down(self
):
507 Move the screen cursor one line down. The screen is scrolled if the
508 cursor points to the last line.
511 if self
._cur
_y
< ROWS
- 1:
514 assert self
._cur
_y
== ROWS
- 1
516 # At the last line of the terminal, scroll up the screen.
518 self
._screen
.append([TermChar() for x
in range(COLUMNS
)])
521 self
._text
.config(state
=tkinter
.NORMAL
)
522 self
._text
.delete('1.0', '2.0')
523 self
._text
.insert(tkinter
.END
, "\n" + " " * COLUMNS
)
524 self
._text
.config(state
=tkinter
.DISABLED
)
526 def _erase_all(self
):
527 """Completely clear the terminal's screen."""
529 self
._screen
= [[TermChar() for x
in range(COLUMNS
)]
530 for y
in range(ROWS
)]
533 self
._text
.config(state
=tkinter
.NORMAL
)
534 self
._text
.delete('1.0', tkinter
.END
)
535 self
._text
.insert('1.0', "\n".join([" " * COLUMNS
] * ROWS
))
536 self
._text
.config(state
=tkinter
.DISABLED
)
538 def _insert_blanks(self
, w
):
540 Replace the specified number of characters on the current screen line
544 del self
._screen
[self
._cur
_y
][-w
:]
545 pre
= self
._screen
[self
._cur
_y
][:self
._cur
_x
]
546 post
= self
._screen
[self
._cur
_y
][self
._cur
_x
:]
547 self
._screen
[self
._cur
_y
] = pre
+ [TermChar() for x
in range(w
)] + post
550 self
._text
.config(state
=tkinter
.NORMAL
)
551 self
._text
.delete('{}.end-{}c'.format(self
._cur
_y
+ 1, w
),
552 '{}.end'.format(self
._cur
_y
+ 1))
553 self
._text
.insert('{}.{}'.format(self
._cur
_y
+ 1, self
._cur
_x
),
555 self
._text
.config(state
=tkinter
.DISABLED
)
557 def _print_char_at(self
, y
, x
, char
):
558 """Output one character on the screen at the specified coordinates."""
560 # Record the character in the internal screen representation.
561 self
._screen
[y
][x
] = char
564 # Add the character to the terminal text widget.
565 self
._text
.config(state
=tkinter
.NORMAL
)
566 pos
= '{}.{}'.format(y
+ 1, x
)
567 self
._text
.delete(pos
)
569 tag
= 'tag_{}-{}'.format(char
.get_tag_foreground(),
570 char
.get_tag_background())
571 self
._text
.insert(pos
, char
.char
, tag
)
572 self
._text
.config(state
=tkinter
.DISABLED
)
574 def _print_char(self
, char
):
575 """Output one character on the screen at the cursor position."""
577 self
._print
_char
_at
(self
._cur
_y
, self
._cur
_x
, char
)
579 # Advance the cursor.
581 if self
._cur
_x
== COLUMNS
:
585 def _tk_key(self
, event
):
586 """Process a key pressed by the user."""
588 if len(event
.char
) != 0:
589 if event
.char
== CODE_ENTER
:
590 self
._send
_key
(event
.char
, 'Enter')
592 self
._send
_key
(event
.char
, event
.char
)
595 # A special key was pressed.
596 if event
.keysym
== 'F12':
597 self
._record
_expected
_screen
()
599 if event
.keysym
== 'Prior':
600 self
._send
_key
(CODE_PAGE_UP
, 'PageUp')
602 if event
.keysym
== 'Next':
603 self
._send
_key
(CODE_PAGE_DOWN
, 'PageDown')
605 match
= re
.fullmatch('F([0-9]+)', event
.keysym
)
608 fnum
= int(match
.group(1))
609 if fnum
>= 1 and fnum
<= len(CODE_FN
):
610 self
._send
_key
(CODE_FN
[fnum
- 1], event
.keysym
)
613 print("Unrecognized key {}.".format(event
.keysym
), file=sys
.stderr
)
615 def _get_screen_xml(self
, screen
):
617 Return an ElementTree.Element that represents the given screen.
620 expect_e
= ElementTree
.Element('expect')
621 data_e
= ElementTree
.SubElement(expect_e
, 'data')
626 # Print content of the screen.
627 for y
in range(ROWS
):
628 line_e
= ElementTree
.SubElement(data_e
, 'line')
633 for x
in range(COLUMNS
):
634 term_char
= screen
[y
][x
]
636 line_e
.text
+= term_char
.char
638 color
= (term_char
.attr
, term_char
.fgcolor
, term_char
.bgcolor
)
639 if color
== (ATTR_NORMAL
, COLOR_DEFAULT
, COLOR_DEFAULT
):
641 elif color
in colors
:
646 assert new_key
!= 'z'
647 new_key
= chr(ord(new_key
) + 1)
650 # Record any non-default attributes/colors.
651 if attr
!= ' ' * COLUMNS
:
652 attr_e
= ElementTree
.SubElement(data_e
, 'attr')
655 # Record used color schemes.
657 scheme_e
= ElementTree
.SubElement(expect_e
, 'scheme')
658 for color
, key
in sorted(colors
.items(), key
=lambda x
: x
[1]):
659 attr
, fgcolor
, bgcolor
= color
660 color_e
= ElementTree
.SubElement(scheme_e
, 'color')
661 color_e
.set('key', key
)
663 attr_str
= attr_to_string(attr
)
665 color_e
.set('attributes', attr_str
)
667 fgcolor_str
= color_to_string(fgcolor
)
669 color_e
.set('foreground', fgcolor_str
)
671 bgcolor_str
= color_to_string(bgcolor
)
673 color_e
.set('background', bgcolor_str
)
677 def _record_expected_screen(self
):
679 Record the current screen content as an expected screen in the test
680 playbook that is being created.
683 assert self
._mode
== self
.MODE_RUN
or self
._mode
== self
.MODE_RECORD
685 if self
._mode
!= self
.MODE_RECORD
:
686 print("Recording is not enabled.", file=sys
.stderr
)
689 expect_e
= self
._get
_screen
_xml
(self
._screen
)
690 self
._test
_e
.append(expect_e
)
691 print("Recorded expected screen.")
693 # Method _indent_xml() is based on a code from
694 # http://effbot.org/zone/element-lib.htm#prettyprint.
695 def _indent_xml(self
, elem
, level
=0):
697 Indent elements of a given ElementTree so it can be pretty-printed.
700 i
= '\n' + '\t' * level
702 if not elem
.text
or not elem
.text
.strip():
705 self
._indent
_xml
(e
, level
+1)
706 if not e
.tail
or not e
.tail
.strip():
708 if not elem
.tail
or not elem
.tail
.strip():
711 def output_test(self
, filename
):
713 Output a recorded playbook to a file with the given name. Returns True
714 when the writing of the test data succeeded, False otherwise.
717 assert self
._mode
== self
.MODE_RECORD
719 # Pretty-format the XML tree.
720 self
._indent
_xml
(self
._test
_e
)
723 tree
= ElementTree
.ElementTree(self
._test
_e
)
725 tree
.write(filename
, 'unicode', True)
726 except Exception as e
:
727 print("Failed to write playbook file '{}': {}.".format(
728 filename
, e
), file=sys
.stderr
)
733 class _TestFailure(Exception):
734 """Exception reported when a test failed."""
737 def _playbook_key(self
, cmd_e
):
739 Parse a description of one key action and send the key to the terminal.
740 Exception _TestFailure is raised if the description is malformed or
741 incomplete, exception _TerminalConnectionException can be thrown when
742 communication with the pseudo-terminal fails.
745 assert self
._mode
== self
.MODE_TEST
748 key
= cmd_e
.attrib
['key']
750 raise self
._TestFailure
("Element 'action' is missing required "
753 # Handle simple characters.
755 self
._send
_key
(key
, key
)
758 # Handle special keys.
760 self
._send
_key
(CODE_ENTER
, key
)
763 self
._send
_key
(CODE_PAGE_UP
, key
)
765 if key
== 'PageDown':
766 self
._send
_key
(CODE_PAGE_DOWN
, key
)
767 match
= re
.fullmatch('F([0-9]+)', key
)
770 fnum
= int(match
.group(1))
771 if fnum
>= 1 and fnum
<= len(CODE_FN
):
772 self
._send
_key
(CODE_FN
[fnum
- 1], key
)
775 raise self
._TestFailure
(
776 "Element 'action' specifies unrecognized key '{}'".format(key
))
778 def _parse_color_scheme(self
, scheme_e
):
780 Parse color scheme of one expected screen. Dictionary with
781 {'key': (attr, fgcolor, bgcolor), ...} is returned on success,
782 exception _TestFailure is raised if the description is malformed or
786 assert self
._mode
== self
.MODE_TEST
789 for color_e
in scheme_e
:
791 key
= color_e
.attrib
['key']
793 raise self
._TestFailure
(
794 "Element 'color' is missing required attribute 'key'")
797 if 'attributes' in color_e
.attrib
:
799 attr
= string_to_attr(color_e
.attrib
['attributes'])
800 except ValueError as e
:
801 raise self
._TestFailure
(
802 "Value of attribute 'attributes' is invalid: "
805 fgcolor
= COLOR_DEFAULT
806 if 'foreground' in color_e
.attrib
:
808 fgcolor
= string_to_color(color_e
.attrib
['foreground'])
809 except ValueError as e
:
810 raise self
._TestFailure
(
811 "Value of attribute 'foreground' is invalid: "
814 bgcolor
= COLOR_DEFAULT
815 if 'background' in color_e
.attrib
:
817 bgcolor
= string_to_color(color_e
.attrib
['background'])
818 except ValueError as e
:
819 raise self
._TestFailure
(
820 "Value of attribute 'background' is invalid: "
823 colors
[key
] = (attr
, fgcolor
, bgcolor
)
827 def _parse_screen_data(self
, data_e
, colors
):
829 Parse screen lines of one expected screen. Internal screen
830 representation is returned on success, exception _TestFailure is raised
831 if the description is malformed or incomplete.
834 assert self
._mode
== self
.MODE_TEST
842 for data_sub_e
in data_e
:
843 # Do common processing for both states.
844 if data_sub_e
.tag
== 'line':
845 # Append the previous line.
847 expected_screen
.append(line
)
848 # Parse the new line.
849 line
= [TermChar(char
) for char
in data_sub_e
.text
]
850 state
= NEW_LINE_OR_ATTR
852 if state
== NEW_LINE
and data_sub_e
.tag
!= 'line':
853 raise self
._TestFailure
("Element '{}' is invalid, expected "
854 "'line'".format(data_sub_e
.tag
))
856 elif state
== NEW_LINE_OR_ATTR
:
857 if data_sub_e
.tag
== 'attr':
858 if len(data_sub_e
.text
) != len(line
):
859 raise self
._TestFailure
(
860 "Element 'attr' does not match the previous line, "
861 "expected '{}' attribute characters but got "
862 "'{}'".format(len(line
), len(data_sub_e
.text
)))
864 for i
, key
in enumerate(data_sub_e
.text
):
869 attr
, fgcolor
, bgcolor
= colors
[key
]
871 raise self
._TestFailure
("Color attribute '{}' is "
872 "not defined".format(key
))
874 line
[i
].fgcolor
= fgcolor
875 line
[i
].bgcolor
= bgcolor
879 elif data_sub_e
.tag
!= 'line':
880 raise self
._TestFailure
(
881 "Element '{}' is invalid, expected 'line' or "
882 "'attr'".format(data_sub_e
.tag
))
884 # Append the final line.
886 expected_screen
.append(line
)
888 return expected_screen
890 def _parse_expected_screen(self
, expect_e
):
892 Parse a description of one expected screen. Internal screen
893 representation is returned on success, exception _TestFailure is raised
894 if the description is malformed or incomplete.
897 assert self
._mode
== self
.MODE_TEST
901 for sub_e
in expect_e
:
902 if sub_e
.tag
== 'data':
904 raise self
._TestFailure
("Element 'expect' contains "
905 "multiple 'data' sub-elements")
907 elif sub_e
.tag
== 'scheme':
909 raise self
._TestFailure
("Element 'expect' contains "
910 "multiple 'scheme' sub-elements")
914 raise self
._TestFailure
(
915 "Element 'expect' is missing required sub-element 'data'")
917 # Parse the color scheme.
919 colors
= self
._parse
_color
_scheme
(scheme_e
)
923 # Parse the screen data.
924 return self
._parse
_screen
_data
(data_e
, colors
)
926 def _report_failed_expectation(self
, expected_screen
):
928 Report that the expected screen state has not been reached. The output
929 consists of the expected screen, the current screen content, followed
930 by differences between the two screens.
933 assert self
._mode
== self
.MODE_TEST
935 # Print the expected screen. The output is not verbatim as it was
936 # specified in the input file, but instead the screen is printed in the
937 # same way the current screen gets output. This allows to properly show
938 # differences between the two screens.
940 expected_screen_e
= self
._get
_screen
_xml
(expected_screen
)
941 self
._indent
_xml
(expected_screen_e
)
942 expected_screen_str
= ElementTree
.tostring(
943 expected_screen_e
, 'unicode')
944 print("Expected (normalized) screen:", file=sys
.stderr
)
945 print(expected_screen_str
, file=sys
.stderr
)
947 # Print the current screen.
948 current_screen_e
= self
._get
_screen
_xml
(self
._screen
)
949 self
._indent
_xml
(current_screen_e
)
950 current_screen_str
= ElementTree
.tostring(current_screen_e
, 'unicode')
951 print("Current screen:", file=sys
.stderr
)
952 print(current_screen_str
, file=sys
.stderr
)
955 print("Differences:", file=sys
.stderr
)
956 sys
.stderr
.writelines(difflib
.unified_diff(
957 expected_screen_str
.splitlines(keepends
=True),
958 current_screen_str
.splitlines(keepends
=True),
959 fromfile
="Expected screen", tofile
="Current screen"))
961 def _execute_playbook(self
, test_e
):
963 Run the main loop and execute the given test playbook. Normal return
964 from the method indicates that the test succeeded. Exception
965 _TestFailure is raised when the test fails and exception
966 _TerminalConnectionException can be thrown when communication with the
967 pseudo-terminal fails.
970 assert self
._mode
== self
.MODE_TEST
972 cmd_iter
= iter(test_e
)
974 # Start the main loop.
975 with selectors
.DefaultSelector() as sel
:
976 sel
.register(self
._fd
, selectors
.EVENT_READ
)
978 expected_screen
= None
981 # Process any actions and find an expected screen.
982 while not expected_screen
and more_commands
:
984 cmd_e
= next(cmd_iter
)
985 if cmd_e
.tag
== 'action':
986 self
._playbook
_key
(cmd_e
)
987 elif cmd_e
.tag
== 'expect':
988 expected_screen
= self
._parse
_expected
_screen
(
990 # Stop processing more commands for now and wait
991 # for the expected screen to appear.
994 raise self
._TestFailure
(
995 "Element '{}' is invalid, expected 'action' "
996 "or 'expect'".format(cmd_e
.tag
))
997 except StopIteration:
999 more_commands
= False
1001 # Wait for the expected screen.
1002 events
= sel
.select(CHILD_TIMEOUT
)
1005 self
._report
_failed
_expectation
(expected_screen
)
1006 raise self
._TestFailure
(
1007 "Timeout reached. No event received in the last {} "
1008 "second(s)".format(CHILD_TIMEOUT
))
1010 # Expect only an event on self._fd.
1011 assert len(events
) == 1
1014 assert key
.fd
== self
._fd
1016 closed
= self
._pty
_callback
()
1019 raise self
._TestFailure
(
1020 "Connection to the terminal was closed but the "
1021 "playbook contains more commands")
1024 # Check if the expected screen is present.
1025 if self
._screen
== expected_screen
:
1026 expected_screen
= None
1028 def execute_test(self
, filename
):
1030 Load test data from a given file, start the program under the test and
1031 execute the test playbook. Returns True when the test succeeded, False
1035 assert self
._mode
== self
.MODE_TEST
1037 # Read the test data.
1039 tree
= ElementTree
.ElementTree(file=filename
)
1040 except Exception as e
:
1041 print("Failed to read playbook file '{}': {}.".format(filename
, e
),
1045 # Read what program to execute.
1046 test_e
= tree
.getroot()
1047 if test_e
.tag
!= 'test':
1048 print("Root element '{}' is invalid, expected 'test'.".format(
1049 test_e
.tag
), file=sys
.stderr
)
1052 self
._program
= test_e
.attrib
['program']
1054 print("Element 'test' is missing required attribute 'program'.",
1058 # Start the specified program.
1059 if not self
._start
_program
():
1062 # Execute the test playbook.
1065 self
._execute
_playbook
(tree
.getroot())
1066 except (self
._TerminalConnectionException
, self
._TestFailure
) as e
:
1067 print("{}.".format(e
), file=sys
.stderr
)
1070 # Finalize the run of the child program.
1071 if not self
._finalize
_program
():
1074 # Return whether the test passed.
1080 Parse command line arguments and execute the operation that the user
1081 selected. Returns 0 if the operation was successful and a non-zero value
1085 # Parse command line arguments.
1086 parser
= argparse
.ArgumentParser()
1087 parser
.add_argument(
1088 '-t', '--terminfo', metavar
='PATH', help="path to terminfo directory")
1090 subparsers
= parser
.add_subparsers(dest
='program')
1091 subparsers
.required
= True
1093 program_parser
= argparse
.ArgumentParser(add_help
=False)
1094 program_parser
.add_argument('program', help="executable to run")
1096 # Create the parser for the 'run' command.
1097 parser_run
= subparsers
.add_parser(
1098 'run', parents
=[program_parser
], help="run a program")
1099 parser_run
.set_defaults(mode
=Term
.MODE_RUN
)
1101 # Create the parser for the 'record' command.
1102 parser_record
= subparsers
.add_parser(
1103 'record', parents
=[program_parser
], help="record a test")
1104 parser_record
.set_defaults(mode
=Term
.MODE_RECORD
)
1105 parser_record
.add_argument(
1106 '-o', '--playbook', metavar
='FILE', required
=True,
1107 help="output playbook file")
1109 # Create the parser for the 'test' command.
1110 parser_test
= subparsers
.add_parser('test', help="perform a test")
1111 parser_test
.set_defaults(program
=None)
1112 parser_test
.set_defaults(mode
=Term
.MODE_TEST
)
1113 parser_test
.add_argument('playbook', help="input playbook file")
1115 args
= parser
.parse_args()
1118 if args
.mode
in (Term
.MODE_RUN
, Term
.MODE_RECORD
):
1119 # Start the terminal GUI.
1121 tk_root
= tkinter
.Tk()
1122 except tkinter
.TclError
as e
:
1123 print("Failed to initialize GUI: {}.".format(e
), file=sys
.stderr
)
1126 term
= Term(tk_root
, args
.program
, args
.mode
, args
.terminfo
)
1128 # Start the GUI main loop.
1129 term
.run_gui_mainloop()
1131 # Execute and check the playbook, without running GUI.
1132 ok
= term
.execute_test(args
.playbook
)
1140 print("Checking of playbook '{}' {}.".format(args
.playbook
, msg
))
1143 if args
.mode
== Term
.MODE_RECORD
:
1144 # Get the recorded test data and write them to a file.
1145 if not term
.output_test(args
.playbook
):
1151 if __name__
== '__main__':
1154 # vim: set tabstop=4 shiftwidth=4 textwidth=79 expandtab :