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."""
31 import xml
.etree
.ElementTree
as ElementTree
33 # The module relies on selectors.select() to automatically retry the operation
34 # with a recomputed timeout when it gets interrupted by a signal. This
35 # behaviour was introduced in Python 3.5.
36 if sys
.hexversion
< 0x03050000:
37 print("This program requires at least Python 3.5.", file=sys
.stderr
)
56 COLOR_REGISTER
= ((COLOR_BLACK
, 'black'), (COLOR_RED
, 'red'),
57 (COLOR_GREEN
, 'green'), (COLOR_YELLOW
, 'yellow'),
58 (COLOR_BLUE
, 'blue'), (COLOR_MAGENTA
, 'magenta'),
59 (COLOR_CYAN
, 'cyan'), (COLOR_WHITE
, 'white'),
60 (COLOR_DEFAULT
, 'default'))
61 COLOR_TO_STRING_MAP
= {id_
: name
for id_
, name
in COLOR_REGISTER
}
62 STRING_TO_COLOR_MAP
= {name
: id_
for id_
, name
in COLOR_REGISTER
}
63 COLORS
= {id_
for id_
, _
in COLOR_REGISTER
}
64 REAL_COLOR_NAMES
= tuple(
65 name
for id_
, name
in COLOR_REGISTER
if id_
!= COLOR_DEFAULT
)
67 COLOR_DEFAULT_FOREGROUND
= COLOR_BLACK
68 COLOR_DEFAULT_BACKGROUND
= COLOR_WHITE
71 CODE_FN
= ('\x1bOP', '\x1bOQ', '\x1bOR', '\x1bOS', '\x1b[15~', '\x1b[17~',
72 '\x1b[18~', '\x1b[19~', '\x1b[20~', '\x1b[21~', '\x1b[23~')
73 CODE_PAGE_UP
= '\x1b[5~'
74 CODE_PAGE_DOWN
= '\x1b[6~'
77 def attr_to_string(attr
):
78 """Get string representation of given attributes."""
81 if (attr
& ATTR_REVERSE
) != 0:
86 def string_to_attr(string
):
88 Convert a string to attributes. Exception ValueError is raised if some
93 for attr
in string
.split('|'):
96 elif attr
== 'reverse':
99 raise ValueError("Unrecognized attribute '{}'".format(attr
))
103 def color_to_string(color
):
104 """Get string representation of a given color."""
106 return COLOR_TO_STRING_MAP
[color
]
109 def string_to_color(string
):
111 Convert a string to a color. Exception ValueError is raised if the color
112 name is not recognized.
116 return STRING_TO_COLOR_MAP
[string
]
118 raise ValueError("Unrecognized color '{}'".format(string
))
122 """On-screen character."""
124 def __init__(self
, char
=" ", attr
=ATTR_NORMAL
, fgcolor
=COLOR_DEFAULT
,
125 bgcolor
=COLOR_DEFAULT
):
128 self
.fgcolor
= fgcolor
129 self
.bgcolor
= bgcolor
131 def __eq__(self
, other
):
132 return self
.__dict
__ == other
.__dict
__
134 def _get_translated_fgcolor(self
):
136 Return the foreground color. If the current color is COLOR_DEFAULT then
137 COLOR_DEFAULT_FOREGROUND is returned.
140 if self
.fgcolor
== COLOR_DEFAULT
:
141 return COLOR_DEFAULT_FOREGROUND
144 def _get_translated_bgcolor(self
):
146 Return the background color. If the current color is COLOR_DEFAULT then
147 COLOR_DEFAULT_BACKGROUND is returned.
150 if self
.bgcolor
== COLOR_DEFAULT
:
151 return COLOR_DEFAULT_BACKGROUND
154 def get_tag_foreground(self
):
156 Return a name of the final foreground color that should be used to
157 display the character on the screen.
160 if self
.attr
& ATTR_REVERSE
:
161 color
= self
._get
_translated
_bgcolor
()
163 color
= self
._get
_translated
_fgcolor
()
164 return color_to_string(color
)
166 def get_tag_background(self
):
168 Return a name of the final background color that should be used to
169 display the character on the screen.
172 if self
.attr
& ATTR_REVERSE
:
173 color
= self
._get
_translated
_fgcolor
()
175 color
= self
._get
_translated
_bgcolor
()
176 return color_to_string(color
)
180 """Termex terminal emulator."""
186 class _TerminalConnectionException(Exception):
188 Exception reported when communication with the pseudo-terminal fails.
192 def __init__(self
, root
, program
, mode
, terminfo
=None):
194 self
._program
= program
196 self
._terminfo
= terminfo
198 # Test mode obtains the program name from the playbook.
199 if self
._mode
== self
.MODE_TEST
:
200 assert self
._program
is None
202 self
._child
_pid
= None
208 self
._attr
= ATTR_NORMAL
209 self
._fgcolor
= COLOR_DEFAULT
210 self
._bgcolor
= COLOR_DEFAULT
213 # Initialize the GUI if requested.
215 self
._root
.title("Termex")
216 self
._frame
= tkinter
.Frame(self
._root
)
218 self
._text
= tkinter
.Text(self
._root
, height
=ROWS
, width
=COLUMNS
)
220 foreground
=color_to_string(COLOR_DEFAULT_FOREGROUND
),
221 background
=color_to_string(COLOR_DEFAULT_BACKGROUND
))
224 # Configure tag values.
225 for fgcolor_str
in REAL_COLOR_NAMES
:
226 for bgcolor_str
in REAL_COLOR_NAMES
:
227 tag
= 'tag_{}-{}'.format(fgcolor_str
, bgcolor_str
)
228 self
._text
.tag_config(tag
, foreground
=fgcolor_str
,
229 background
=bgcolor_str
)
233 if self
._mode
== self
.MODE_RECORD
:
234 self
._test
_e
= ElementTree
.Element('test')
235 self
._test
_e
.set('program', self
._program
)
237 def _start_program(self
):
239 Fork, connect the child's controlling terminal to a pseudo-terminal and
240 start the selected child program.
242 Parent behaviour: Returns True when the fork was successful, False
243 otherwise. Note that the returned value does not provide information
244 whether the exec call in the child process was successful or not. That
245 must be determined by attempting communication with the child.
247 Child behaviour: Execs the selected program and does not return if the
248 call was successful, returns False otherwise.
251 # Fork and connect the child's controlling terminal to a
254 self
._child
_pid
, self
._fd
= pty
.fork()
256 print("Fork to run '{}' failed: {}".format(self
._program
, e
),
259 if self
._child
_pid
== 0:
261 env
= {'PATH': '/bin:/usr/bin', 'TERM': 'termex',
262 'LC_ALL': 'en_US.UTF-8'}
264 env
['TERMINFO'] = self
._terminfo
265 elif 'TERMINFO' in os
.environ
:
266 env
['TERMINFO'] = os
.environ
['TERMINFO']
267 os
.execle(self
._program
, self
._program
, env
)
269 print("Failed to execute '{}': {}".format(self
._program
, e
),
275 def _finalize_program(self
):
277 Close the connection to the pseudo-terminal and wait for the child
278 program to complete. Returns True when the connection was successfully
279 closed and the child completed in the timeout limit, False otherwise.
284 # Close the file descriptor that is connected to the child's
285 # controlling terminal.
289 print("Failed to close file descriptor '{}' that is connected to "
290 "the child's controlling terminal: {}.".format(self
._fd
, e
),
294 # Wait for the child to finish. It should terminate now that its input
296 for _
in range(CHILD_TIMEOUT
):
298 pid
, _status
= os
.waitpid(self
._child
_pid
, os
.WNOHANG
)
300 print("Failed to wait on child '{}' to complete: "
301 "{}.".format(pid
, e
), file=sys
.stderr
)
308 print("Child '{}' has not completed.".format(self
._child
_pid
),
314 def run_gui_mainloop(self
):
315 """Start the selected child program and run the tkinter's main loop."""
317 assert self
._mode
== self
.MODE_RUN
or self
._mode
== self
.MODE_RECORD
319 # Start the specified program.
320 if not self
._start
_program
():
324 # Prepare for running the main loop.
325 self
._root
.createfilehandler(
326 self
._fd
, tkinter
.READABLE
,
327 lambda fd
, mask
: self
._pty
_callback
())
328 self
._root
.bind('<Key>', self
._tk
_key
)
329 self
._root
.bind('<<Quit>>', lambda e
: self
._quit
_gui
_mainloop
())
330 self
._root
.protocol('WM_DELETE_WINDOW', self
._quit
_gui
_mainloop
)
334 self
._root
.mainloop()
335 except self
._TerminalConnectionException
as e
:
336 print("{}.".format(e
), file=sys
.stderr
)
338 self
._root
.deletefilehandler(self
._fd
)
340 # Finalize the run of the child program.
341 self
._finalize
_program
()
343 def _quit_gui_mainloop(self
):
344 """Exit the tkinter's main loop."""
346 assert self
._mode
== self
.MODE_RUN
or self
._mode
== self
.MODE_RECORD
349 def _pty_callback(self
):
351 Process a data event from the pseudo-terminal. Returns True when the
352 connection to the pseudo-terminal was closed, False otherwise.
353 Exception _TerminalConnectionException is raised if the read of the new
354 data from the pseudo-terminal fails.
359 char
= os
.read(self
._fd
, 1)
361 if e
.errno
== errno
.EIO
:
364 raise self
._TerminalConnectionException
(
365 "Error reading from file descriptor '{}' that is "
366 "connected to the child's controlling terminal: "
367 "{}".format(self
._fd
, e
))
369 # Check whether the descriptor referring to the pseudo-terminal slave
370 # has been closed or end of file was reached.
371 if closed
or len(char
) == 0:
376 self
._charbuf
+= char
378 if self
._handle
_sequence
(self
._charbuf
):
381 # print("Unmatched {}.".format(self._charbuf), file=sys.stderr)
385 def _send_key(self
, chars
, name
):
387 Write the specified characters that represent one key to the
388 pseudo-terminal. If the recording mode is enabled then the specified
389 key name is recorded in the test playbook. Exception
390 _TerminalConnectionException is raised if the write to the
391 pseudo-terminal fails.
394 if self
._mode
== self
.MODE_RECORD
:
396 action_e
= ElementTree
.SubElement(self
._test
_e
, 'action')
397 action_e
.set('key', name
)
398 print("Recorded key '{}'.".format(name
))
400 # Send the key to the terminal.
402 os
.write(self
._fd
, str.encode(chars
))
404 raise self
._TerminalConnectionException
(
405 "Error writing characters '{}' to file descriptor '{}' that "
406 "is connected to the child's controlling terminal: "
407 "{}".format(chars
, self
._fd
, e
))
409 def _handle_sequence(self
, seq
):
411 Process a byte sequence received from the pseudo-terminal. Returns True
412 when the sequence was recognized and successfully handled, False
416 if re
.fullmatch(b
'[^\x01-\x1f]+', seq
):
418 uchar
= seq
.decode('utf-8')
420 TermChar(uchar
, self
._attr
, self
._fgcolor
, self
._bgcolor
))
423 # Continue on the assumption that it is not yet a complete
424 # character. This assumption is wrong if the received text is
425 # actually malformed.
434 # Backspace non-destructively.
438 # Go to beginning of line.
442 # Move cursor down one line.
446 # Controls beginning with ESC.
449 match
= re
.fullmatch(b
'\x1b\\[([0-9]+)@', seq
)
451 # Insert blank characters.
452 self
._insert
_blanks
(int(match
.group(1)))
455 # Set cursor position to the default (top left).
459 match
= re
.fullmatch(b
'\x1b\\[([0-9]+);([0-9]+)H', seq
)
461 # Set cursor position to (y,x).
462 self
._cur
_y
= int(match
.group(1))
463 self
._cur
_x
= int(match
.group(2))
465 if self
._charbuf
== b
'\x1b[K':
466 # Erase in line to right.
467 for x
in range(self
._cur
_x
, COLUMNS
):
468 self
._print
_char
_at
(self
._cur
_y
, x
, TermChar())
470 if seq
== b
'\x1b[2J':
471 # Erase display completely.
475 # Normal character attribute (all attributes off).
476 self
._attr
= ATTR_NORMAL
478 if seq
== b
'\x1b[7m':
479 # Inverse character attribute.
480 self
._attr |
= ATTR_REVERSE
482 match
= re
.fullmatch(b
'\x1b\\[3([0-9]+)m', seq
)
484 # Set foreground color.
485 color
= int(match
.group(1))
487 self
._fgcolor
= color
490 match
= re
.fullmatch(b
'\x1b\\[4([0-9]+)m', seq
)
492 # Set background color.
493 color
= int(match
.group(1))
495 self
._bgcolor
= color
498 if seq
== b
'\x1b[?25l':
504 def _cursor_down(self
):
506 Move the screen cursor one line down. The screen is scrolled if the
507 cursor points to the last line.
510 if self
._cur
_y
< ROWS
- 1:
513 assert self
._cur
_y
== ROWS
- 1
515 # At the last line of the terminal, scroll up the screen.
517 self
._screen
.append([TermChar() for x
in range(COLUMNS
)])
520 self
._text
.config(state
=tkinter
.NORMAL
)
521 self
._text
.delete('1.0', '2.0')
522 self
._text
.insert(tkinter
.END
, "\n" + " " * COLUMNS
)
523 self
._text
.config(state
=tkinter
.DISABLED
)
525 def _erase_all(self
):
526 """Completely clear the terminal's screen."""
528 self
._screen
= [[TermChar() for x
in range(COLUMNS
)]
529 for y
in range(ROWS
)]
532 self
._text
.config(state
=tkinter
.NORMAL
)
533 self
._text
.delete('1.0', tkinter
.END
)
534 self
._text
.insert('1.0', "\n".join([" " * COLUMNS
] * ROWS
))
535 self
._text
.config(state
=tkinter
.DISABLED
)
537 def _insert_blanks(self
, w
):
539 Replace the specified number of characters on the current screen line
543 del self
._screen
[self
._cur
_y
][-w
:]
544 pre
= self
._screen
[self
._cur
_y
][:self
._cur
_x
]
545 post
= self
._screen
[self
._cur
_y
][self
._cur
_x
:]
546 self
._screen
[self
._cur
_y
] = pre
+ [TermChar() for x
in range(w
)] + post
549 self
._text
.config(state
=tkinter
.NORMAL
)
550 self
._text
.delete('{}.end-{}c'.format(self
._cur
_y
+ 1, w
),
551 '{}.end'.format(self
._cur
_y
+ 1))
552 self
._text
.insert('{}.{}'.format(self
._cur
_y
+ 1, self
._cur
_x
),
554 self
._text
.config(state
=tkinter
.DISABLED
)
556 def _print_char_at(self
, y
, x
, char
):
557 """Output one character on the screen at the specified coordinates."""
559 # Record the character in the internal screen representation.
560 self
._screen
[y
][x
] = char
563 # Add the character to the terminal text widget.
564 self
._text
.config(state
=tkinter
.NORMAL
)
565 pos
= '{}.{}'.format(y
+ 1, x
)
566 self
._text
.delete(pos
)
568 tag
= 'tag_{}-{}'.format(char
.get_tag_foreground(),
569 char
.get_tag_background())
570 self
._text
.insert(pos
, char
.char
, tag
)
571 self
._text
.config(state
=tkinter
.DISABLED
)
573 def _print_char(self
, char
):
574 """Output one character on the screen at the cursor position."""
576 self
._print
_char
_at
(self
._cur
_y
, self
._cur
_x
, char
)
578 # Advance the cursor.
580 if self
._cur
_x
== COLUMNS
:
584 def _tk_key(self
, event
):
585 """Process a key pressed by the user."""
587 if len(event
.char
) != 0:
588 if event
.char
== CODE_ENTER
:
589 self
._send
_key
(event
.char
, 'Enter')
591 self
._send
_key
(event
.char
, event
.char
)
594 # A special key was pressed.
595 if event
.keysym
== 'F12':
596 self
._record
_expected
_screen
()
598 if event
.keysym
== 'Prior':
599 self
._send
_key
(CODE_PAGE_UP
, 'PageUp')
601 if event
.keysym
== 'Next':
602 self
._send
_key
(CODE_PAGE_DOWN
, 'PageDown')
604 match
= re
.fullmatch('F([0-9]+)', event
.keysym
)
607 fnum
= int(match
.group(1))
608 if fnum
>= 1 and fnum
<= len(CODE_FN
):
609 self
._send
_key
(CODE_FN
[fnum
- 1], event
.keysym
)
612 print("Unrecognized key {}.".format(event
.keysym
), file=sys
.stderr
)
614 def _get_screen_xml(self
, screen
):
616 Return an ElementTree.Element that represents the given screen.
619 expect_e
= ElementTree
.Element('expect')
620 data_e
= ElementTree
.SubElement(expect_e
, 'data')
625 # Print content of the screen.
626 for y
in range(ROWS
):
627 line_e
= ElementTree
.SubElement(data_e
, 'line')
632 for x
in range(COLUMNS
):
633 term_char
= screen
[y
][x
]
635 line_e
.text
+= term_char
.char
637 color
= (term_char
.attr
, term_char
.fgcolor
, term_char
.bgcolor
)
638 if color
== (ATTR_NORMAL
, COLOR_DEFAULT
, COLOR_DEFAULT
):
640 elif color
in colors
:
645 assert new_key
!= 'z'
646 new_key
= chr(ord(new_key
) + 1)
649 # Record any non-default attributes/colors.
650 if attr
!= ' ' * COLUMNS
:
651 attr_e
= ElementTree
.SubElement(data_e
, 'attr')
654 # Record used color schemes.
656 scheme_e
= ElementTree
.SubElement(expect_e
, 'scheme')
657 for color
, key
in sorted(colors
.items(), key
=lambda x
: x
[1]):
658 attr
, fgcolor
, bgcolor
= color
659 color_e
= ElementTree
.SubElement(scheme_e
, 'color')
660 color_e
.set('key', key
)
662 attr_str
= attr_to_string(attr
)
664 color_e
.set('attributes', attr_str
)
666 fgcolor_str
= color_to_string(fgcolor
)
668 color_e
.set('foreground', fgcolor_str
)
670 bgcolor_str
= color_to_string(bgcolor
)
672 color_e
.set('background', bgcolor_str
)
676 def _record_expected_screen(self
):
678 Record the current screen content as an expected screen in the test
679 playbook that is being created.
682 assert self
._mode
== self
.MODE_RUN
or self
._mode
== self
.MODE_RECORD
684 if self
._mode
!= self
.MODE_RECORD
:
685 print("Recording is not enabled.", file=sys
.stderr
)
688 expect_e
= self
._get
_screen
_xml
(self
._screen
)
689 self
._test
_e
.append(expect_e
)
690 print("Recorded expected screen.")
692 # Method _indent_xml() is based on a code from
693 # http://effbot.org/zone/element-lib.htm#prettyprint.
694 def _indent_xml(self
, elem
, level
=0):
696 Indent elements of a given ElementTree so it can be pretty-printed.
699 i
= '\n' + '\t' * level
701 if not elem
.text
or not elem
.text
.strip():
704 self
._indent
_xml
(e
, level
+1)
705 if not e
.tail
or not e
.tail
.strip():
707 if not elem
.tail
or not elem
.tail
.strip():
710 def output_test(self
, filename
):
712 Output a recorded playbook to a file with the given name. Returns True
713 when the writing of the test data succeeded, False otherwise.
716 assert self
._mode
== self
.MODE_RECORD
718 # Pretty-format the XML tree.
719 self
._indent
_xml
(self
._test
_e
)
722 tree
= ElementTree
.ElementTree(self
._test
_e
)
724 tree
.write(filename
, 'unicode', True)
725 except Exception as e
:
726 print("Failed to write playbook file '{}': {}.".format(
727 filename
, e
), file=sys
.stderr
)
732 class _TestFailure(Exception):
733 """Exception reported when a test failed."""
736 def _playbook_key(self
, cmd_e
):
738 Parse a description of one key action and send the key to the terminal.
739 Exception _TestFailure is raised if the description is malformed or
740 incomplete, exception _TerminalConnectionException can be thrown when
741 communication with the pseudo-terminal fails.
744 assert self
._mode
== self
.MODE_TEST
747 key
= cmd_e
.attrib
['key']
749 raise self
._TestFailure
("Element 'action' is missing required "
752 # Handle simple characters.
754 self
._send
_key
(key
, key
)
757 # Handle special keys.
759 self
._send
_key
(CODE_ENTER
, key
)
762 self
._send
_key
(CODE_PAGE_UP
, key
)
764 if key
== 'PageDown':
765 self
._send
_key
(CODE_PAGE_DOWN
, key
)
766 match
= re
.fullmatch('F([0-9]+)', key
)
769 fnum
= int(match
.group(1))
770 if fnum
>= 1 and fnum
<= len(CODE_FN
):
771 self
._send
_key
(CODE_FN
[fnum
- 1], key
)
774 raise self
._TestFailure
(
775 "Element 'action' specifies unrecognized key '{}'".format(key
))
777 def _parse_color_scheme(self
, scheme_e
):
779 Parse color scheme of one expected screen. Dictionary with
780 {'key': (attr, fgcolor, bgcolor), ...} is returned on success,
781 exception _TestFailure is raised if the description is malformed or
785 assert self
._mode
== self
.MODE_TEST
788 for color_e
in scheme_e
:
790 key
= color_e
.attrib
['key']
792 raise self
._TestFailure
(
793 "Element 'color' is missing required attribute 'key'")
796 if 'attributes' in color_e
.attrib
:
798 attr
= string_to_attr(color_e
.attrib
['attributes'])
799 except ValueError as e
:
800 raise self
._TestFailure
(
801 "Value of attribute 'attributes' is invalid: "
804 fgcolor
= COLOR_DEFAULT
805 if 'foreground' in color_e
.attrib
:
807 fgcolor
= string_to_color(color_e
.attrib
['foreground'])
808 except ValueError as e
:
809 raise self
._TestFailure
(
810 "Value of attribute 'foreground' is invalid: "
813 bgcolor
= COLOR_DEFAULT
814 if 'background' in color_e
.attrib
:
816 bgcolor
= string_to_color(color_e
.attrib
['background'])
817 except ValueError as e
:
818 raise self
._TestFailure
(
819 "Value of attribute 'background' is invalid: "
822 colors
[key
] = (attr
, fgcolor
, bgcolor
)
826 def _parse_screen_data(self
, data_e
, colors
):
828 Parse screen lines of one expected screen. Internal screen
829 representation is returned on success, exception _TestFailure is raised
830 if the description is malformed or incomplete.
833 assert self
._mode
== self
.MODE_TEST
841 for data_sub_e
in data_e
:
842 # Do common processing for both states.
843 if data_sub_e
.tag
== 'line':
844 # Append the previous line.
846 expected_screen
.append(line
)
847 # Parse the new line.
848 line
= [TermChar(char
) for char
in data_sub_e
.text
]
849 state
= NEW_LINE_OR_ATTR
851 if state
== NEW_LINE
and data_sub_e
.tag
!= 'line':
852 raise self
._TestFailure
("Element '{}' is invalid, expected "
853 "'line'".format(data_sub_e
.tag
))
855 elif state
== NEW_LINE_OR_ATTR
:
856 if data_sub_e
.tag
== 'attr':
857 if len(data_sub_e
.text
) != len(line
):
858 raise self
._TestFailure
(
859 "Element 'attr' does not match the previous line, "
860 "expected '{}' attribute characters but got "
861 "'{}'".format(len(line
), len(data_sub_e
.text
)))
863 for i
, key
in enumerate(data_sub_e
.text
):
868 attr
, fgcolor
, bgcolor
= colors
[key
]
870 raise self
._TestFailure
("Color attribute '{}' is "
871 "not defined".format(key
))
873 line
[i
].fgcolor
= fgcolor
874 line
[i
].bgcolor
= bgcolor
878 elif data_sub_e
.tag
!= 'line':
879 raise self
._TestFailure
(
880 "Element '{}' is invalid, expected 'line' or "
881 "'attr'".format(data_sub_e
.tag
))
883 # Append the final line.
885 expected_screen
.append(line
)
887 return expected_screen
889 def _parse_expected_screen(self
, expect_e
):
891 Parse a description of one expected screen. Internal screen
892 representation is returned on success, exception _TestFailure is raised
893 if the description is malformed or incomplete.
896 assert self
._mode
== self
.MODE_TEST
900 for sub_e
in expect_e
:
901 if sub_e
.tag
== 'data':
903 raise self
._TestFailure
("Element 'expect' contains "
904 "multiple 'data' sub-elements")
906 elif sub_e
.tag
== 'scheme':
908 raise self
._TestFailure
("Element 'expect' contains "
909 "multiple 'scheme' sub-elements")
913 raise self
._TestFailure
(
914 "Element 'expect' is missing required sub-element 'data'")
916 # Parse the color scheme.
918 colors
= self
._parse
_color
_scheme
(scheme_e
)
922 # Parse the screen data.
923 return self
._parse
_screen
_data
(data_e
, colors
)
925 def _report_failed_expectation(self
, expected_screen
):
927 Report that the expected screen state has not been reached. The output
928 consists of the expected screen, the current screen content, followed
929 by differences between the two screens.
932 assert self
._mode
== self
.MODE_TEST
934 # Print the expected screen. The output is not verbatim as it was
935 # specified in the input file, but instead the screen is printed in the
936 # same way the current screen gets output. This allows to properly show
937 # differences between the two screens.
939 expected_screen_e
= self
._get
_screen
_xml
(expected_screen
)
940 self
._indent
_xml
(expected_screen_e
)
941 expected_screen_str
= ElementTree
.tostring(
942 expected_screen_e
, 'unicode')
943 print("Expected (normalized) screen:", file=sys
.stderr
)
944 print(expected_screen_str
, file=sys
.stderr
)
946 # Print the current screen.
947 current_screen_e
= self
._get
_screen
_xml
(self
._screen
)
948 self
._indent
_xml
(current_screen_e
)
949 current_screen_str
= ElementTree
.tostring(current_screen_e
, 'unicode')
950 print("Current screen:", file=sys
.stderr
)
951 print(current_screen_str
, file=sys
.stderr
)
954 print("Differences:", file=sys
.stderr
)
955 sys
.stderr
.writelines(difflib
.unified_diff(
956 expected_screen_str
.splitlines(keepends
=True),
957 current_screen_str
.splitlines(keepends
=True),
958 fromfile
="Expected screen", tofile
="Current screen"))
960 def _execute_playbook(self
, test_e
):
962 Run the main loop and execute the given test playbook. Normal return
963 from the method indicates that the test succeeded. Exception
964 _TestFailure is raised when the test fails and exception
965 _TerminalConnectionException can be thrown when communication with the
966 pseudo-terminal fails.
969 assert self
._mode
== self
.MODE_TEST
971 cmd_iter
= iter(test_e
)
973 # Start the main loop.
974 with selectors
.DefaultSelector() as sel
:
975 sel
.register(self
._fd
, selectors
.EVENT_READ
)
977 expected_screen
= None
980 # Process any actions and find an expected screen.
981 while not expected_screen
and more_commands
:
983 cmd_e
= next(cmd_iter
)
984 if cmd_e
.tag
== 'action':
985 self
._playbook
_key
(cmd_e
)
986 elif cmd_e
.tag
== 'expect':
987 expected_screen
= self
._parse
_expected
_screen
(
989 # Stop processing more commands for now and wait
990 # for the expected screen to appear.
993 raise self
._TestFailure
(
994 "Element '{}' is invalid, expected 'action' "
995 "or 'expect'".format(cmd_e
.tag
))
996 except StopIteration:
998 more_commands
= False
1000 # Wait for the expected screen.
1001 events
= sel
.select(CHILD_TIMEOUT
)
1004 self
._report
_failed
_expectation
(expected_screen
)
1005 raise self
._TestFailure
(
1006 "Timeout reached. No event received in the last {} "
1007 "second(s)".format(CHILD_TIMEOUT
))
1009 # Expect only an event on self._fd.
1010 assert len(events
) == 1
1013 assert key
.fd
== self
._fd
1015 closed
= self
._pty
_callback
()
1018 raise self
._TestFailure
(
1019 "Connection to the terminal was closed but the "
1020 "playbook contains more commands")
1023 # Check if the expected screen is present.
1024 if self
._screen
== expected_screen
:
1025 expected_screen
= None
1027 def execute_test(self
, filename
):
1029 Load test data from a given file, start the program under the test and
1030 execute the test playbook. Returns True when the test succeeded, False
1034 assert self
._mode
== self
.MODE_TEST
1036 # Read the test data.
1038 tree
= ElementTree
.ElementTree(file=filename
)
1039 except Exception as e
:
1040 print("Failed to read playbook file '{}': {}.".format(filename
, e
),
1044 # Read what program to execute.
1045 test_e
= tree
.getroot()
1046 if test_e
.tag
!= 'test':
1047 print("Root element '{}' is invalid, expected 'test'.".format(
1048 test_e
.tag
), file=sys
.stderr
)
1051 self
._program
= test_e
.attrib
['program']
1053 print("Element 'test' is missing required attribute 'program'.",
1057 # Start the specified program.
1058 if not self
._start
_program
():
1061 # Execute the test playbook.
1064 self
._execute
_playbook
(tree
.getroot())
1065 except (self
._TerminalConnectionException
, self
._TestFailure
) as e
:
1066 print("{}.".format(e
), file=sys
.stderr
)
1069 # Finalize the run of the child program.
1070 if not self
._finalize
_program
():
1073 # Return whether the test passed.
1079 Parse command line arguments and execute the operation that the user
1080 selected. Returns 0 if the operation was successful and a non-zero value
1084 # Parse command line arguments.
1085 parser
= argparse
.ArgumentParser()
1086 parser
.add_argument(
1087 '-t', '--terminfo', metavar
='PATH', help="path to terminfo directory")
1089 subparsers
= parser
.add_subparsers(dest
='program')
1090 subparsers
.required
= True
1092 program_parser
= argparse
.ArgumentParser(add_help
=False)
1093 program_parser
.add_argument('program', help="executable to run")
1095 # Create the parser for the 'run' command.
1096 parser_run
= subparsers
.add_parser(
1097 'run', parents
=[program_parser
], help="run a program")
1098 parser_run
.set_defaults(mode
=Term
.MODE_RUN
)
1100 # Create the parser for the 'record' command.
1101 parser_record
= subparsers
.add_parser(
1102 'record', parents
=[program_parser
], help="record a test")
1103 parser_record
.set_defaults(mode
=Term
.MODE_RECORD
)
1104 parser_record
.add_argument(
1105 '-o', '--playbook', metavar
='FILE', required
=True,
1106 help="output playbook file")
1108 # Create the parser for the 'test' command.
1109 parser_test
= subparsers
.add_parser('test', help="perform a test")
1110 parser_test
.set_defaults(program
=None)
1111 parser_test
.set_defaults(mode
=Term
.MODE_TEST
)
1112 parser_test
.add_argument('playbook', help="input playbook file")
1114 args
= parser
.parse_args()
1117 if args
.mode
in (Term
.MODE_RUN
, Term
.MODE_RECORD
):
1118 # Start the terminal GUI.
1123 tk_root
= tkinter
.Tk()
1124 except tkinter
.TclError
as e
:
1125 print("Failed to initialize GUI: {}.".format(e
), file=sys
.stderr
)
1128 term
= Term(tk_root
, args
.program
, args
.mode
, args
.terminfo
)
1130 # Start the GUI main loop.
1131 term
.run_gui_mainloop()
1133 # Execute and check the playbook, without running GUI.
1134 ok
= term
.execute_test(args
.playbook
)
1142 print("Checking of playbook '{}' {}.".format(args
.playbook
, msg
))
1145 if args
.mode
== Term
.MODE_RECORD
:
1146 # Get the recorded test data and write them to a file.
1147 if not term
.output_test(args
.playbook
):
1153 if __name__
== '__main__':
1156 # vim: set tabstop=4 shiftwidth=4 textwidth=79 expandtab :