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
212 self
._alt
_charset
_mode
= False
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 # Handle characters from the alternate set.
418 if self
._alt
_charset
_mode
:
420 b
'q': '\N{BOX DRAWINGS LIGHT HORIZONTAL}',
421 b
'x': '\N{BOX DRAWINGS LIGHT VERTICAL}',
422 b
'm': '\N{BOX DRAWINGS LIGHT UP AND RIGHT}',
423 b
'j': '\N{BOX DRAWINGS LIGHT UP AND LEFT}',
424 b
'l': '\N{BOX DRAWINGS LIGHT DOWN AND RIGHT}',
425 b
'k': '\N{BOX DRAWINGS LIGHT DOWN AND LEFT}',
426 b
'v': '\N{BOX DRAWINGS LIGHT UP AND HORIZONTAL}',
427 b
't': '\N{BOX DRAWINGS LIGHT VERTICAL AND RIGHT}',
428 b
'u': '\N{BOX DRAWINGS LIGHT VERTICAL AND LEFT}',
429 b
'w': '\N{BOX DRAWINGS LIGHT DOWN AND HORIZONTAL}',
430 b
'.': '\N{DOWNWARDS ARROW}',
431 b
',': '\N{LEFTWARDS ARROW}',
432 b
'+': '\N{RIGHTWARDS ARROW}',
433 b
'-': '\N{UPWARDS ARROW}',
434 b
'~': '\N{MIDDLE DOT}'}
437 self
._print
_char
(TermChar(uchars
[seq
], self
._attr
,
438 self
._fgcolor
, self
._bgcolor
))
441 # Handle normal characters.
442 if re
.fullmatch(b
'[^\x01-\x1f]+', seq
):
444 uchar
= seq
.decode('utf-8')
445 self
._print
_char
(TermChar(uchar
, self
._attr
, self
._fgcolor
,
449 # Continue on the assumption that it is not yet a complete
450 # character. This assumption is wrong if the received text is
451 # actually malformed.
460 # Backspace non-destructively.
464 # Go to beginning of line.
468 # Move cursor down one line.
472 # Controls beginning with ESC.
474 # Start alternate character set.
475 self
._alt
_charset
_mode
= True
478 # End alternate character set.
479 self
._alt
_charset
_mode
= False
483 match
= re
.fullmatch(b
'\x1b\\[([0-9]+)@', seq
)
485 # Insert blank characters.
486 self
._insert
_blanks
(int(match
.group(1)))
489 # Set cursor position to the default (top left).
493 match
= re
.fullmatch(b
'\x1b\\[([0-9]+);([0-9]+)H', seq
)
495 # Set cursor position to (y,x).
496 self
._cur
_y
= int(match
.group(1))
497 self
._cur
_x
= int(match
.group(2))
499 if self
._charbuf
== b
'\x1b[K':
500 # Erase in line to right.
501 for x
in range(self
._cur
_x
, COLUMNS
):
502 self
._print
_char
_at
(self
._cur
_y
, x
, TermChar())
504 if seq
== b
'\x1b[2J':
505 # Erase display completely.
509 # Normal character attribute (all attributes off).
510 self
._attr
= ATTR_NORMAL
512 if seq
== b
'\x1b[7m':
513 # Inverse character attribute.
514 self
._attr |
= ATTR_REVERSE
516 match
= re
.fullmatch(b
'\x1b\\[3([0-9]+)m', seq
)
518 # Set foreground color.
519 color
= int(match
.group(1))
521 self
._fgcolor
= color
524 match
= re
.fullmatch(b
'\x1b\\[4([0-9]+)m', seq
)
526 # Set background color.
527 color
= int(match
.group(1))
529 self
._bgcolor
= color
532 if seq
== b
'\x1b[?25l':
538 def _cursor_down(self
):
540 Move the screen cursor one line down. The screen is scrolled if the
541 cursor points to the last line.
544 if self
._cur
_y
< ROWS
- 1:
547 assert self
._cur
_y
== ROWS
- 1
549 # At the last line of the terminal, scroll up the screen.
551 self
._screen
.append([TermChar() for x
in range(COLUMNS
)])
554 self
._text
.config(state
=tkinter
.NORMAL
)
555 self
._text
.delete('1.0', '2.0')
556 self
._text
.insert(tkinter
.END
, "\n" + " " * COLUMNS
)
557 self
._text
.config(state
=tkinter
.DISABLED
)
559 def _erase_all(self
):
560 """Completely clear the terminal's screen."""
562 self
._screen
= [[TermChar() for x
in range(COLUMNS
)]
563 for y
in range(ROWS
)]
566 self
._text
.config(state
=tkinter
.NORMAL
)
567 self
._text
.delete('1.0', tkinter
.END
)
568 self
._text
.insert('1.0', "\n".join([" " * COLUMNS
] * ROWS
))
569 self
._text
.config(state
=tkinter
.DISABLED
)
571 def _insert_blanks(self
, w
):
573 Replace the specified number of characters on the current screen line
577 del self
._screen
[self
._cur
_y
][-w
:]
578 pre
= self
._screen
[self
._cur
_y
][:self
._cur
_x
]
579 post
= self
._screen
[self
._cur
_y
][self
._cur
_x
:]
580 self
._screen
[self
._cur
_y
] = pre
+ [TermChar() for x
in range(w
)] + post
583 self
._text
.config(state
=tkinter
.NORMAL
)
584 self
._text
.delete('{}.end-{}c'.format(self
._cur
_y
+ 1, w
),
585 '{}.end'.format(self
._cur
_y
+ 1))
586 self
._text
.insert('{}.{}'.format(self
._cur
_y
+ 1, self
._cur
_x
),
588 self
._text
.config(state
=tkinter
.DISABLED
)
590 def _print_char_at(self
, y
, x
, char
):
591 """Output one character on the screen at the specified coordinates."""
593 # Record the character in the internal screen representation.
594 self
._screen
[y
][x
] = char
597 # Add the character to the terminal text widget.
598 self
._text
.config(state
=tkinter
.NORMAL
)
599 pos
= '{}.{}'.format(y
+ 1, x
)
600 self
._text
.delete(pos
)
602 tag
= 'tag_{}-{}'.format(char
.get_tag_foreground(),
603 char
.get_tag_background())
604 self
._text
.insert(pos
, char
.char
, tag
)
605 self
._text
.config(state
=tkinter
.DISABLED
)
607 def _print_char(self
, char
):
608 """Output one character on the screen at the cursor position."""
610 self
._print
_char
_at
(self
._cur
_y
, self
._cur
_x
, char
)
612 # Advance the cursor.
614 if self
._cur
_x
== COLUMNS
:
618 def _tk_key(self
, event
):
619 """Process a key pressed by the user."""
621 if len(event
.char
) != 0:
622 if event
.char
== CODE_ENTER
:
623 self
._send
_key
(event
.char
, 'Enter')
625 self
._send
_key
(event
.char
, event
.char
)
628 # A special key was pressed.
629 if event
.keysym
== 'F12':
630 self
._record
_expected
_screen
()
632 if event
.keysym
== 'Prior':
633 self
._send
_key
(CODE_PAGE_UP
, 'PageUp')
635 if event
.keysym
== 'Next':
636 self
._send
_key
(CODE_PAGE_DOWN
, 'PageDown')
638 match
= re
.fullmatch('F([0-9]+)', event
.keysym
)
641 fnum
= int(match
.group(1))
642 if fnum
>= 1 and fnum
<= len(CODE_FN
):
643 self
._send
_key
(CODE_FN
[fnum
- 1], event
.keysym
)
646 print("Unrecognized key {}.".format(event
.keysym
), file=sys
.stderr
)
648 def _get_screen_xml(self
, screen
):
650 Return an ElementTree.Element that represents the given screen.
653 expect_e
= ElementTree
.Element('expect')
654 data_e
= ElementTree
.SubElement(expect_e
, 'data')
659 # Print content of the screen.
660 for y
in range(ROWS
):
661 line_e
= ElementTree
.SubElement(data_e
, 'line')
666 for x
in range(COLUMNS
):
667 term_char
= screen
[y
][x
]
669 line_e
.text
+= term_char
.char
671 color
= (term_char
.attr
, term_char
.fgcolor
, term_char
.bgcolor
)
672 if color
== (ATTR_NORMAL
, COLOR_DEFAULT
, COLOR_DEFAULT
):
674 elif color
in colors
:
679 assert new_key
!= 'z'
680 new_key
= chr(ord(new_key
) + 1)
683 # Record any non-default attributes/colors.
684 if attr
!= ' ' * COLUMNS
:
685 attr_e
= ElementTree
.SubElement(data_e
, 'attr')
688 # Record used color schemes.
690 scheme_e
= ElementTree
.SubElement(expect_e
, 'scheme')
691 for color
, key
in sorted(colors
.items(), key
=lambda x
: x
[1]):
692 attr
, fgcolor
, bgcolor
= color
693 color_e
= ElementTree
.SubElement(scheme_e
, 'color')
694 color_e
.set('key', key
)
696 attr_str
= attr_to_string(attr
)
698 color_e
.set('attributes', attr_str
)
700 fgcolor_str
= color_to_string(fgcolor
)
702 color_e
.set('foreground', fgcolor_str
)
704 bgcolor_str
= color_to_string(bgcolor
)
706 color_e
.set('background', bgcolor_str
)
710 def _record_expected_screen(self
):
712 Record the current screen content as an expected screen in the test
713 playbook that is being created.
716 assert self
._mode
== self
.MODE_RUN
or self
._mode
== self
.MODE_RECORD
718 if self
._mode
!= self
.MODE_RECORD
:
719 print("Recording is not enabled.", file=sys
.stderr
)
722 expect_e
= self
._get
_screen
_xml
(self
._screen
)
723 self
._test
_e
.append(expect_e
)
724 print("Recorded expected screen.")
726 # Method _indent_xml() is based on a code from
727 # http://effbot.org/zone/element-lib.htm#prettyprint.
728 def _indent_xml(self
, elem
, level
=0):
730 Indent elements of a given ElementTree so it can be pretty-printed.
733 i
= '\n' + '\t' * level
735 if not elem
.text
or not elem
.text
.strip():
738 self
._indent
_xml
(e
, level
+1)
739 if not e
.tail
or not e
.tail
.strip():
741 if not elem
.tail
or not elem
.tail
.strip():
744 def output_test(self
, filename
):
746 Output a recorded playbook to a file with the given name. Returns True
747 when the writing of the test data succeeded, False otherwise.
750 assert self
._mode
== self
.MODE_RECORD
752 # Pretty-format the XML tree.
753 self
._indent
_xml
(self
._test
_e
)
756 tree
= ElementTree
.ElementTree(self
._test
_e
)
758 tree
.write(filename
, 'unicode', True)
759 except Exception as e
:
760 print("Failed to write playbook file '{}': {}.".format(
761 filename
, e
), file=sys
.stderr
)
766 class _TestFailure(Exception):
767 """Exception reported when a test failed."""
770 def _playbook_key(self
, cmd_e
):
772 Parse a description of one key action and send the key to the terminal.
773 Exception _TestFailure is raised if the description is malformed or
774 incomplete, exception _TerminalConnectionException can be thrown when
775 communication with the pseudo-terminal fails.
778 assert self
._mode
== self
.MODE_TEST
781 key
= cmd_e
.attrib
['key']
783 raise self
._TestFailure
("Element 'action' is missing required "
786 # Handle simple characters.
788 self
._send
_key
(key
, key
)
791 # Handle special keys.
793 self
._send
_key
(CODE_ENTER
, key
)
796 self
._send
_key
(CODE_PAGE_UP
, key
)
798 if key
== 'PageDown':
799 self
._send
_key
(CODE_PAGE_DOWN
, key
)
800 match
= re
.fullmatch('F([0-9]+)', key
)
803 fnum
= int(match
.group(1))
804 if fnum
>= 1 and fnum
<= len(CODE_FN
):
805 self
._send
_key
(CODE_FN
[fnum
- 1], key
)
808 raise self
._TestFailure
(
809 "Element 'action' specifies unrecognized key '{}'".format(key
))
811 def _parse_color_scheme(self
, scheme_e
):
813 Parse color scheme of one expected screen. Dictionary with
814 {'key': (attr, fgcolor, bgcolor), ...} is returned on success,
815 exception _TestFailure is raised if the description is malformed or
819 assert self
._mode
== self
.MODE_TEST
822 for color_e
in scheme_e
:
824 key
= color_e
.attrib
['key']
826 raise self
._TestFailure
(
827 "Element 'color' is missing required attribute 'key'")
830 if 'attributes' in color_e
.attrib
:
832 attr
= string_to_attr(color_e
.attrib
['attributes'])
833 except ValueError as e
:
834 raise self
._TestFailure
(
835 "Value of attribute 'attributes' is invalid: "
838 fgcolor
= COLOR_DEFAULT
839 if 'foreground' in color_e
.attrib
:
841 fgcolor
= string_to_color(color_e
.attrib
['foreground'])
842 except ValueError as e
:
843 raise self
._TestFailure
(
844 "Value of attribute 'foreground' is invalid: "
847 bgcolor
= COLOR_DEFAULT
848 if 'background' in color_e
.attrib
:
850 bgcolor
= string_to_color(color_e
.attrib
['background'])
851 except ValueError as e
:
852 raise self
._TestFailure
(
853 "Value of attribute 'background' is invalid: "
856 colors
[key
] = (attr
, fgcolor
, bgcolor
)
860 def _parse_screen_data(self
, data_e
, colors
):
862 Parse screen lines of one expected screen. Internal screen
863 representation is returned on success, exception _TestFailure is raised
864 if the description is malformed or incomplete.
867 assert self
._mode
== self
.MODE_TEST
875 for data_sub_e
in data_e
:
876 # Do common processing for both states.
877 if data_sub_e
.tag
== 'line':
878 # Append the previous line.
880 expected_screen
.append(line
)
881 # Parse the new line.
882 line
= [TermChar(char
) for char
in data_sub_e
.text
]
883 state
= NEW_LINE_OR_ATTR
885 if state
== NEW_LINE
and data_sub_e
.tag
!= 'line':
886 raise self
._TestFailure
("Element '{}' is invalid, expected "
887 "'line'".format(data_sub_e
.tag
))
889 elif state
== NEW_LINE_OR_ATTR
:
890 if data_sub_e
.tag
== 'attr':
891 if len(data_sub_e
.text
) != len(line
):
892 raise self
._TestFailure
(
893 "Element 'attr' does not match the previous line, "
894 "expected '{}' attribute characters but got "
895 "'{}'".format(len(line
), len(data_sub_e
.text
)))
897 for i
, key
in enumerate(data_sub_e
.text
):
902 attr
, fgcolor
, bgcolor
= colors
[key
]
904 raise self
._TestFailure
("Color attribute '{}' is "
905 "not defined".format(key
))
907 line
[i
].fgcolor
= fgcolor
908 line
[i
].bgcolor
= bgcolor
912 elif data_sub_e
.tag
!= 'line':
913 raise self
._TestFailure
(
914 "Element '{}' is invalid, expected 'line' or "
915 "'attr'".format(data_sub_e
.tag
))
917 # Append the final line.
919 expected_screen
.append(line
)
921 return expected_screen
923 def _parse_expected_screen(self
, expect_e
):
925 Parse a description of one expected screen. Internal screen
926 representation is returned on success, exception _TestFailure is raised
927 if the description is malformed or incomplete.
930 assert self
._mode
== self
.MODE_TEST
934 for sub_e
in expect_e
:
935 if sub_e
.tag
== 'data':
937 raise self
._TestFailure
("Element 'expect' contains "
938 "multiple 'data' sub-elements")
940 elif sub_e
.tag
== 'scheme':
942 raise self
._TestFailure
("Element 'expect' contains "
943 "multiple 'scheme' sub-elements")
947 raise self
._TestFailure
(
948 "Element 'expect' is missing required sub-element 'data'")
950 # Parse the color scheme.
952 colors
= self
._parse
_color
_scheme
(scheme_e
)
956 # Parse the screen data.
957 return self
._parse
_screen
_data
(data_e
, colors
)
959 def _report_failed_expectation(self
, expected_screen
):
961 Report that the expected screen state has not been reached. The output
962 consists of the expected screen, the current screen content, followed
963 by differences between the two screens.
966 assert self
._mode
== self
.MODE_TEST
968 # Print the expected screen. The output is not verbatim as it was
969 # specified in the input file, but instead the screen is printed in the
970 # same way the current screen gets output. This allows to properly show
971 # differences between the two screens.
973 expected_screen_e
= self
._get
_screen
_xml
(expected_screen
)
974 self
._indent
_xml
(expected_screen_e
)
975 expected_screen_str
= ElementTree
.tostring(
976 expected_screen_e
, 'unicode')
977 print("Expected (normalized) screen:", file=sys
.stderr
)
978 print(expected_screen_str
, file=sys
.stderr
)
980 # Print the current screen.
981 current_screen_e
= self
._get
_screen
_xml
(self
._screen
)
982 self
._indent
_xml
(current_screen_e
)
983 current_screen_str
= ElementTree
.tostring(current_screen_e
, 'unicode')
984 print("Current screen:", file=sys
.stderr
)
985 print(current_screen_str
, file=sys
.stderr
)
988 print("Differences:", file=sys
.stderr
)
989 sys
.stderr
.writelines(difflib
.unified_diff(
990 expected_screen_str
.splitlines(keepends
=True),
991 current_screen_str
.splitlines(keepends
=True),
992 fromfile
="Expected screen", tofile
="Current screen"))
994 def _execute_playbook(self
, test_e
):
996 Run the main loop and execute the given test playbook. Normal return
997 from the method indicates that the test succeeded. Exception
998 _TestFailure is raised when the test fails and exception
999 _TerminalConnectionException can be thrown when communication with the
1000 pseudo-terminal fails.
1003 assert self
._mode
== self
.MODE_TEST
1005 cmd_iter
= iter(test_e
)
1007 # Start the main loop.
1008 with selectors
.DefaultSelector() as sel
:
1009 sel
.register(self
._fd
, selectors
.EVENT_READ
)
1011 expected_screen
= None
1012 more_commands
= True
1014 # Process any actions and find an expected screen.
1015 while not expected_screen
and more_commands
:
1017 cmd_e
= next(cmd_iter
)
1018 if cmd_e
.tag
== 'action':
1019 self
._playbook
_key
(cmd_e
)
1020 elif cmd_e
.tag
== 'expect':
1021 expected_screen
= self
._parse
_expected
_screen
(
1023 # Stop processing more commands for now and wait
1024 # for the expected screen to appear.
1027 raise self
._TestFailure
(
1028 "Element '{}' is invalid, expected 'action' "
1029 "or 'expect'".format(cmd_e
.tag
))
1030 except StopIteration:
1032 more_commands
= False
1034 # Wait for the expected screen.
1035 events
= sel
.select(CHILD_TIMEOUT
)
1038 self
._report
_failed
_expectation
(expected_screen
)
1039 raise self
._TestFailure
(
1040 "Timeout reached. No event received in the last {} "
1041 "second(s)".format(CHILD_TIMEOUT
))
1043 # Expect only an event on self._fd.
1044 assert len(events
) == 1
1047 assert key
.fd
== self
._fd
1049 closed
= self
._pty
_callback
()
1052 raise self
._TestFailure
(
1053 "Connection to the terminal was closed but the "
1054 "playbook contains more commands")
1057 # Check if the expected screen is present.
1058 if self
._screen
== expected_screen
:
1059 expected_screen
= None
1061 def execute_test(self
, filename
):
1063 Load test data from a given file, start the program under the test and
1064 execute the test playbook. Returns True when the test succeeded, False
1068 assert self
._mode
== self
.MODE_TEST
1070 # Read the test data.
1072 tree
= ElementTree
.ElementTree(file=filename
)
1073 except Exception as e
:
1074 print("Failed to read playbook file '{}': {}.".format(filename
, e
),
1078 # Read what program to execute.
1079 test_e
= tree
.getroot()
1080 if test_e
.tag
!= 'test':
1081 print("Root element '{}' is invalid, expected 'test'.".format(
1082 test_e
.tag
), file=sys
.stderr
)
1085 self
._program
= test_e
.attrib
['program']
1087 print("Element 'test' is missing required attribute 'program'.",
1091 # Start the specified program.
1092 if not self
._start
_program
():
1095 # Execute the test playbook.
1098 self
._execute
_playbook
(tree
.getroot())
1099 except (self
._TerminalConnectionException
, self
._TestFailure
) as e
:
1100 print("{}.".format(e
), file=sys
.stderr
)
1103 # Finalize the run of the child program.
1104 if not self
._finalize
_program
():
1107 # Return whether the test passed.
1113 Parse command line arguments and execute the operation that the user
1114 selected. Returns 0 if the operation was successful and a non-zero value
1118 # Parse command line arguments.
1119 parser
= argparse
.ArgumentParser()
1120 parser
.add_argument(
1121 '-t', '--terminfo', metavar
='PATH', help="path to terminfo directory")
1123 subparsers
= parser
.add_subparsers(dest
='program')
1124 subparsers
.required
= True
1126 program_parser
= argparse
.ArgumentParser(add_help
=False)
1127 program_parser
.add_argument('program', help="executable to run")
1129 # Create the parser for the 'run' command.
1130 parser_run
= subparsers
.add_parser(
1131 'run', parents
=[program_parser
], help="run a program")
1132 parser_run
.set_defaults(mode
=Term
.MODE_RUN
)
1134 # Create the parser for the 'record' command.
1135 parser_record
= subparsers
.add_parser(
1136 'record', parents
=[program_parser
], help="record a test")
1137 parser_record
.set_defaults(mode
=Term
.MODE_RECORD
)
1138 parser_record
.add_argument(
1139 '-o', '--playbook', metavar
='FILE', required
=True,
1140 help="output playbook file")
1142 # Create the parser for the 'test' command.
1143 parser_test
= subparsers
.add_parser('test', help="perform a test")
1144 parser_test
.set_defaults(program
=None)
1145 parser_test
.set_defaults(mode
=Term
.MODE_TEST
)
1146 parser_test
.add_argument('playbook', help="input playbook file")
1148 args
= parser
.parse_args()
1151 if args
.mode
in (Term
.MODE_RUN
, Term
.MODE_RECORD
):
1152 # Start the terminal GUI.
1157 tk_root
= tkinter
.Tk()
1158 except tkinter
.TclError
as e
:
1159 print("Failed to initialize GUI: {}.".format(e
), file=sys
.stderr
)
1162 term
= Term(tk_root
, args
.program
, args
.mode
, args
.terminfo
)
1164 # Start the GUI main loop.
1165 term
.run_gui_mainloop()
1167 # Execute and check the playbook, without running GUI.
1168 ok
= term
.execute_test(args
.playbook
)
1176 print("Checking of playbook '{}' {}.".format(args
.playbook
, msg
))
1179 if args
.mode
== Term
.MODE_RECORD
:
1180 # Get the recorded test data and write them to a file.
1181 if not term
.output_test(args
.playbook
):
1187 if __name__
== '__main__':
1190 # vim: set tabstop=4 shiftwidth=4 textwidth=79 expandtab :