1# coding=utf-8
2# flake8: noqa E302
3"""
4Cmd2 functional testing based on transcript
5"""
6import os
7import random
8import re
9import sys
10import tempfile
11from unittest import (
12    mock,
13)
14
15import pytest
16
17import cmd2
18from cmd2 import (
19    transcript,
20)
21from cmd2.utils import (
22    Settable,
23    StdSim,
24)
25
26from .conftest import (
27    run_cmd,
28    verify_help_text,
29)
30
31
32class CmdLineApp(cmd2.Cmd):
33
34    MUMBLES = ['like', '...', 'um', 'er', 'hmmm', 'ahh']
35    MUMBLE_FIRST = ['so', 'like', 'well']
36    MUMBLE_LAST = ['right?']
37
38    def __init__(self, *args, **kwargs):
39        self.maxrepeats = 3
40
41        super().__init__(*args, multiline_commands=['orate'], **kwargs)
42
43        # Make maxrepeats settable at runtime
44        self.add_settable(Settable('maxrepeats', int, 'Max number of `--repeat`s allowed', self))
45
46        self.intro = 'This is an intro banner ...'
47
48    speak_parser = cmd2.Cmd2ArgumentParser()
49    speak_parser.add_argument('-p', '--piglatin', action="store_true", help="atinLay")
50    speak_parser.add_argument('-s', '--shout', action="store_true", help="N00B EMULATION MODE")
51    speak_parser.add_argument('-r', '--repeat', type=int, help="output [n] times")
52
53    @cmd2.with_argparser(speak_parser, with_unknown_args=True)
54    def do_speak(self, opts, arg):
55        """Repeats what you tell me to."""
56        arg = ' '.join(arg)
57        if opts.piglatin:
58            arg = '%s%say' % (arg[1:], arg[0])
59        if opts.shout:
60            arg = arg.upper()
61        repetitions = opts.repeat or 1
62        for _ in range(min(repetitions, self.maxrepeats)):
63            self.poutput(arg)
64            # recommend using the poutput function instead of
65            # self.stdout.write or "print", because Cmd allows the user
66            # to redirect output
67
68    do_say = do_speak  # now "say" is a synonym for "speak"
69    do_orate = do_speak  # another synonym, but this one takes multi-line input
70
71    mumble_parser = cmd2.Cmd2ArgumentParser()
72    mumble_parser.add_argument('-r', '--repeat', type=int, help="output [n] times")
73
74    @cmd2.with_argparser(mumble_parser, with_unknown_args=True)
75    def do_mumble(self, opts, arg):
76        """Mumbles what you tell me to."""
77        repetitions = opts.repeat or 1
78        # arg = arg.split()
79        for _ in range(min(repetitions, self.maxrepeats)):
80            output = []
81            if random.random() < 0.33:
82                output.append(random.choice(self.MUMBLE_FIRST))
83            for word in arg:
84                if random.random() < 0.40:
85                    output.append(random.choice(self.MUMBLES))
86                output.append(word)
87            if random.random() < 0.25:
88                output.append(random.choice(self.MUMBLE_LAST))
89            self.poutput(' '.join(output))
90
91    def do_nothing(self, statement):
92        """Do nothing and output nothing"""
93        pass
94
95    def do_keyboard_interrupt(self, _):
96        raise KeyboardInterrupt('Interrupting this command')
97
98
99def test_commands_at_invocation():
100    testargs = ["prog", "say hello", "say Gracie", "quit"]
101    expected = "This is an intro banner ...\nhello\nGracie\n"
102    with mock.patch.object(sys, 'argv', testargs):
103        app = CmdLineApp()
104
105    app.stdout = StdSim(app.stdout)
106    app.cmdloop()
107    out = app.stdout.getvalue()
108    assert out == expected
109
110
111@pytest.mark.parametrize(
112    'filename,feedback_to_output',
113    [
114        ('bol_eol.txt', False),
115        ('characterclass.txt', False),
116        ('dotstar.txt', False),
117        ('extension_notation.txt', False),
118        ('from_cmdloop.txt', True),
119        ('multiline_no_regex.txt', False),
120        ('multiline_regex.txt', False),
121        ('no_output.txt', False),
122        ('no_output_last.txt', False),
123        ('regex_set.txt', False),
124        ('singleslash.txt', False),
125        ('slashes_escaped.txt', False),
126        ('slashslash.txt', False),
127        ('spaces.txt', False),
128        ('word_boundaries.txt', False),
129    ],
130)
131def test_transcript(request, capsys, filename, feedback_to_output):
132    # Get location of the transcript
133    test_dir = os.path.dirname(request.module.__file__)
134    transcript_file = os.path.join(test_dir, 'transcripts', filename)
135
136    # Need to patch sys.argv so cmd2 doesn't think it was called with
137    # arguments equal to the py.test args
138    testargs = ['prog', '-t', transcript_file]
139    with mock.patch.object(sys, 'argv', testargs):
140        # Create a cmd2.Cmd() instance and make sure basic settings are
141        # like we want for test
142        app = CmdLineApp()
143
144    app.feedback_to_output = feedback_to_output
145
146    # Run the command loop
147    sys_exit_code = app.cmdloop()
148    assert sys_exit_code == 0
149
150    # Check for the unittest "OK" condition for the 1 test which ran
151    expected_start = ".\n----------------------------------------------------------------------\nRan 1 test in"
152    expected_end = "s\n\nOK\n"
153    _, err = capsys.readouterr()
154    assert err.startswith(expected_start)
155    assert err.endswith(expected_end)
156
157
158def test_history_transcript():
159    app = CmdLineApp()
160    app.stdout = StdSim(app.stdout)
161    run_cmd(app, 'orate this is\na /multiline/\ncommand;\n')
162    run_cmd(app, 'speak /tmp/file.txt is not a regex')
163
164    expected = r"""(Cmd) orate this is
165> a /multiline/
166> command;
167this is a \/multiline\/ command
168(Cmd) speak /tmp/file.txt is not a regex
169\/tmp\/file.txt is not a regex
170"""
171
172    # make a tmp file
173    fd, history_fname = tempfile.mkstemp(prefix='', suffix='.txt')
174    os.close(fd)
175
176    # tell the history command to create a transcript
177    run_cmd(app, 'history -t "{}"'.format(history_fname))
178
179    # read in the transcript created by the history command
180    with open(history_fname) as f:
181        xscript = f.read()
182
183    assert xscript == expected
184
185
186def test_history_transcript_bad_path(mocker):
187    app = CmdLineApp()
188    app.stdout = StdSim(app.stdout)
189    run_cmd(app, 'orate this is\na /multiline/\ncommand;\n')
190    run_cmd(app, 'speak /tmp/file.txt is not a regex')
191
192    # Bad directory
193    history_fname = '~/fakedir/this_does_not_exist.txt'
194    out, err = run_cmd(app, 'history -t "{}"'.format(history_fname))
195    assert "is not a directory" in err[0]
196
197    # Cause os.open to fail and make sure error gets printed
198    mock_remove = mocker.patch('builtins.open')
199    mock_remove.side_effect = OSError
200
201    history_fname = 'outfile.txt'
202    out, err = run_cmd(app, 'history -t "{}"'.format(history_fname))
203    assert "Error saving transcript file" in err[0]
204
205
206def test_run_script_record_transcript(base_app, request):
207    test_dir = os.path.dirname(request.module.__file__)
208    filename = os.path.join(test_dir, 'scripts', 'help.txt')
209
210    assert base_app._script_dir == []
211    assert base_app._current_script_dir is None
212
213    # make a tmp file to use as a transcript
214    fd, transcript_fname = tempfile.mkstemp(prefix='', suffix='.trn')
215    os.close(fd)
216
217    # Execute the run_script command with the -t option to generate a transcript
218    run_cmd(base_app, 'run_script {} -t {}'.format(filename, transcript_fname))
219
220    assert base_app._script_dir == []
221    assert base_app._current_script_dir is None
222
223    # read in the transcript created by the history command
224    with open(transcript_fname) as f:
225        xscript = f.read()
226
227    assert xscript.startswith('(Cmd) help -v\n')
228    verify_help_text(base_app, xscript)
229
230
231def test_generate_transcript_stop(capsys):
232    # Verify transcript generation stops when a command returns True for stop
233    app = CmdLineApp()
234
235    # Make a tmp file to use as a transcript
236    fd, transcript_fname = tempfile.mkstemp(prefix='', suffix='.trn')
237    os.close(fd)
238
239    # This should run all commands
240    commands = ['help', 'set']
241    app._generate_transcript(commands, transcript_fname)
242    _, err = capsys.readouterr()
243    assert err.startswith("2 commands")
244
245    # Since quit returns True for stop, only the first 2 commands will run
246    commands = ['help', 'quit', 'set']
247    app._generate_transcript(commands, transcript_fname)
248    _, err = capsys.readouterr()
249    assert err.startswith("Command 2 triggered a stop")
250
251    # keyboard_interrupt command should stop the loop and not run the third command
252    commands = ['help', 'keyboard_interrupt', 'set']
253    app._generate_transcript(commands, transcript_fname)
254    _, err = capsys.readouterr()
255    assert err.startswith("Interrupting this command\nCommand 2 triggered a stop")
256
257
258@pytest.mark.parametrize(
259    'expected, transformed',
260    [
261        # strings with zero or one slash or with escaped slashes means no regular
262        # expression present, so the result should just be what re.escape returns.
263        # we don't use static strings in these tests because re.escape behaves
264        # differently in python 3.7 than in prior versions
265        ('text with no slashes', re.escape('text with no slashes')),
266        ('specials .*', re.escape('specials .*')),
267        ('use 2/3 cup', re.escape('use 2/3 cup')),
268        ('/tmp is nice', re.escape('/tmp is nice')),
269        ('slash at end/', re.escape('slash at end/')),
270        # escaped slashes
271        (r'not this slash\/ or this one\/', re.escape('not this slash/ or this one/')),
272        # regexes
273        ('/.*/', '.*'),
274        ('specials ^ and + /[0-9]+/', re.escape('specials ^ and + ') + '[0-9]+'),
275        (r'/a{6}/ but not \/a{6} with /.*?/ more', 'a{6}' + re.escape(' but not /a{6} with ') + '.*?' + re.escape(' more')),
276        (r'not \/, use /\|?/, not \/', re.escape('not /, use ') + r'\|?' + re.escape(', not /')),
277        # inception: slashes in our regex. backslashed on input, bare on output
278        (r'not \/, use /\/?/, not \/', re.escape('not /, use ') + '/?' + re.escape(', not /')),
279        (r'lots /\/?/ more /.*/ stuff', re.escape('lots ') + '/?' + re.escape(' more ') + '.*' + re.escape(' stuff')),
280    ],
281)
282def test_parse_transcript_expected(expected, transformed):
283    app = CmdLineApp()
284
285    class TestMyAppCase(transcript.Cmd2TestCase):
286        cmdapp = app
287
288    testcase = TestMyAppCase()
289    assert testcase._transform_transcript_expected(expected) == transformed
290
291
292def test_transcript_failure(request, capsys):
293    # Get location of the transcript
294    test_dir = os.path.dirname(request.module.__file__)
295    transcript_file = os.path.join(test_dir, 'transcripts', 'failure.txt')
296
297    # Need to patch sys.argv so cmd2 doesn't think it was called with
298    # arguments equal to the py.test args
299    testargs = ['prog', '-t', transcript_file]
300    with mock.patch.object(sys, 'argv', testargs):
301        # Create a cmd2.Cmd() instance and make sure basic settings are
302        # like we want for test
303        app = CmdLineApp()
304
305    app.feedback_to_output = False
306
307    # Run the command loop
308    sys_exit_code = app.cmdloop()
309    assert sys_exit_code != 0
310
311    expected_start = "File "
312    expected_end = "s\n\nFAILED (failures=1)\n\n"
313    _, err = capsys.readouterr()
314    assert err.startswith(expected_start)
315    assert err.endswith(expected_end)
316
317
318def test_transcript_no_file(request, capsys):
319    # Need to patch sys.argv so cmd2 doesn't think it was called with
320    # arguments equal to the py.test args
321    testargs = ['prog', '-t']
322    with mock.patch.object(sys, 'argv', testargs):
323        app = CmdLineApp()
324
325    app.feedback_to_output = False
326
327    # Run the command loop
328    sys_exit_code = app.cmdloop()
329    assert sys_exit_code != 0
330
331    # Check for the unittest "OK" condition for the 1 test which ran
332    expected = 'No test files found - nothing to test\n'
333    _, err = capsys.readouterr()
334    assert err == expected
335