diff options
Diffstat (limited to 'tests')
-rw-r--r-- | tests/conftest.py | 16 | ||||
-rw-r--r-- | tests/test_cmd2.py | 314 | ||||
-rw-r--r-- | tests/test_history.py | 410 | ||||
-rw-r--r-- | tests/transcripts/from_cmdloop.txt | 18 |
4 files changed, 428 insertions, 330 deletions
diff --git a/tests/conftest.py b/tests/conftest.py index 223389b9..f41afb64 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,7 +28,7 @@ except ImportError: # Help text for base cmd2.Cmd application BASE_HELP = """Documented commands (type help <topic>): ======================================== -alias help load py quit shell +alias help load py quit shell edit history macro pyscript set shortcuts """ # noqa: W291 @@ -50,7 +50,8 @@ shortcuts List available shortcuts """ # Help text for the history command -HELP_HISTORY = """Usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c] [arg] +HELP_HISTORY = """Usage: history [-h] [-r | -e | -o FILE | -t TRANSCRIPT | -c] [-s] [-x] [-v] + [arg] View, run, edit, save, or clear previously entered commands @@ -65,12 +66,17 @@ optional arguments: -h, --help show this help message and exit -r, --run run selected history items -e, --edit edit and then run selected history items - -s, --script output commands in script format -o, --output-file FILE - output commands to a script file + output commands to a script file, implies -s -t, --transcript TRANSCRIPT - output commands and results to a transcript file + output commands and results to a transcript file, implies -s -c, --clear clear all history + +formatting: + -s, --script output commands in script format, i.e. without command numbers + -x, --expanded output expanded commands instead of entered command + -v, --verbose display history and include expanded commands if they differ from the typed command + """ # Output from the shortcuts command with default built-in shortcuts diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index faef21f9..8d0d56c6 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -56,10 +56,6 @@ def test_base_help_verbose(base_app): out = run_cmd(base_app, 'help --verbose') assert out == expected -def test_base_help_history(base_app): - out = run_cmd(base_app, 'help history') - assert out == normalize(HELP_HISTORY) - def test_base_argparse_help(base_app, capsys): # Verify that "set -h" gives the same output as "help set" and that it starts in a way that makes sense run_cmd(base_app, 'set -h') @@ -302,206 +298,6 @@ def test_base_error(base_app): assert "is not a recognized command" in out[0] -@pytest.fixture -def hist(): - from cmd2.cmd2 import History, HistoryItem - h = History([HistoryItem('first'), HistoryItem('second'), HistoryItem('third'), HistoryItem('fourth')]) - return h - -def test_history_span(hist): - h = hist - assert h == ['first', 'second', 'third', 'fourth'] - assert h.span('-2..') == ['third', 'fourth'] - assert h.span('2..3') == ['second', 'third'] # Inclusive of end - assert h.span('3') == ['third'] - assert h.span(':') == h - assert h.span('2..') == ['second', 'third', 'fourth'] - assert h.span('-1') == ['fourth'] - assert h.span('-2..-3') == ['third', 'second'] - assert h.span('*') == h - -def test_history_get(hist): - h = hist - assert h == ['first', 'second', 'third', 'fourth'] - assert h.get('') == h - assert h.get('-2') == h[:-2] - assert h.get('5') == [] - assert h.get('2-3') == ['second'] # Exclusive of end - assert h.get('ir') == ['first', 'third'] # Normal string search for all elements containing "ir" - assert h.get('/i.*d/') == ['third'] # Regex string search "i", then anything, then "d" - -def test_base_history(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - out = run_cmd(base_app, 'history') - expected = normalize(""" --------------------------[1] -help --------------------------[2] -shortcuts -""") - assert out == expected - - out = run_cmd(base_app, 'history he') - expected = normalize(""" --------------------------[1] -help -""") - assert out == expected - - out = run_cmd(base_app, 'history sh') - expected = normalize(""" --------------------------[2] -shortcuts -""") - assert out == expected - -def test_history_script_format(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - out = run_cmd(base_app, 'history -s') - expected = normalize(""" -help -shortcuts -""") - assert out == expected - -def test_history_with_string_argument(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - run_cmd(base_app, 'help history') - out = run_cmd(base_app, 'history help') - expected = normalize(""" --------------------------[1] -help --------------------------[3] -help history -""") - assert out == expected - - -def test_history_with_integer_argument(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - out = run_cmd(base_app, 'history 1') - expected = normalize(""" --------------------------[1] -help -""") - assert out == expected - - -def test_history_with_integer_span(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - run_cmd(base_app, 'help history') - out = run_cmd(base_app, 'history 1..2') - expected = normalize(""" --------------------------[1] -help --------------------------[2] -shortcuts -""") - assert out == expected - -def test_history_with_span_start(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - run_cmd(base_app, 'help history') - out = run_cmd(base_app, 'history 2:') - expected = normalize(""" --------------------------[2] -shortcuts --------------------------[3] -help history -""") - assert out == expected - -def test_history_with_span_end(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - run_cmd(base_app, 'help history') - out = run_cmd(base_app, 'history :2') - expected = normalize(""" --------------------------[1] -help --------------------------[2] -shortcuts -""") - assert out == expected - -def test_history_with_span_index_error(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'help history') - run_cmd(base_app, '!ls -hal :') - out = run_cmd(base_app, 'history "hal :"') - expected = normalize(""" --------------------------[3] -!ls -hal : -""") - assert out == expected - -def test_history_output_file(base_app): - run_cmd(base_app, 'help') - run_cmd(base_app, 'shortcuts') - run_cmd(base_app, 'help history') - - fd, fname = tempfile.mkstemp(prefix='', suffix='.txt') - os.close(fd) - run_cmd(base_app, 'history -o "{}"'.format(fname)) - expected = normalize('\n'.join(['help', 'shortcuts', 'help history'])) - with open(fname) as f: - content = normalize(f.read()) - assert content == expected - -def test_history_edit(base_app, monkeypatch): - # Set a fake editor just to make sure we have one. We aren't really - # going to call it due to the mock - base_app.editor = 'fooedit' - - # Mock out the subprocess.Popen call so we don't actually open an editor - m = mock.MagicMock(name='Popen') - monkeypatch.setattr("subprocess.Popen", m) - - # Run help command just so we have a command in history - run_cmd(base_app, 'help') - run_cmd(base_app, 'history -e 1') - - # We have an editor, so should expect a Popen call - m.assert_called_once() - -def test_history_run_all_commands(base_app): - # make sure we refuse to run all commands as a default - run_cmd(base_app, 'shortcuts') - out = run_cmd(base_app, 'history -r') - # this should generate an error, but we don't currently have a way to - # capture stderr in these tests. So we assume that if we got nothing on - # standard out, that the error occurred because if the command executed - # then we should have a list of shortcuts in our output - assert out == [] - -def test_history_run_one_command(base_app): - expected = run_cmd(base_app, 'help') - output = run_cmd(base_app, 'history -r 1') - assert output == expected - -def test_history_clear(base_app): - # Add commands to history - run_cmd(base_app, 'help') - run_cmd(base_app, 'alias') - - # Make sure history has items - out = run_cmd(base_app, 'history') - assert out - - # Clear the history - run_cmd(base_app, 'history --clear') - - # Make sure history is empty - out = run_cmd(base_app, 'history') - assert out == [] - - def test_base_load(base_app, request): test_dir = os.path.dirname(request.module.__file__) filename = os.path.join(test_dir, 'script.txt') @@ -935,34 +731,6 @@ def test_base_py_interactive(base_app): m.assert_called_once() -def test_exclude_from_history(base_app, monkeypatch): - # Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock - base_app.editor = 'fooedit' - - # Mock out the subprocess.Popen call so we don't actually open an editor - m = mock.MagicMock(name='Popen') - monkeypatch.setattr("subprocess.Popen", m) - - # Run edit command - run_cmd(base_app, 'edit') - - # Run history command - run_cmd(base_app, 'history') - - # Verify that the history is empty - out = run_cmd(base_app, 'history') - assert out == [] - - # Now run a command which isn't excluded from the history - run_cmd(base_app, 'help') - - # And verify we have a history now ... - out = run_cmd(base_app, 'history') - expected = normalize("""-------------------------[1] -help""") - assert out == expected - - def test_base_cmdloop_with_queue(): # Create a cmd2.Cmd() instance and make sure basic settings are like we want for test app = cmd2.Cmd() @@ -2152,20 +1920,12 @@ def test_parseline(base_app): assert line == statement.strip() -def test_readline_remove_history_item(base_app): - from cmd2.rl_utils import readline - assert readline.get_current_history_length() == 0 - readline.add_history('this is a test') - assert readline.get_current_history_length() == 1 - readline.remove_history_item(0) - assert readline.get_current_history_length() == 0 - def test_onecmd_raw_str_continue(base_app): line = "help" stop = base_app.onecmd(line) out = base_app.stdout.getvalue() assert not stop - assert out.strip() == BASE_HELP.strip() + assert normalize(out) == normalize(BASE_HELP) def test_onecmd_raw_str_quit(base_app): line = "quit" @@ -2175,78 +1935,6 @@ def test_onecmd_raw_str_quit(base_app): assert out == '' -@pytest.fixture(scope="session") -def hist_file(): - fd, filename = tempfile.mkstemp(prefix='hist_file', suffix='.txt') - os.close(fd) - yield filename - # teardown code - try: - os.remove(filename) - except FileNotFoundError: - pass - -def test_existing_history_file(hist_file, capsys): - import atexit - import readline - - # Create the history file before making cmd2 app - with open(hist_file, 'w'): - pass - - # Create a new cmd2 app - app = cmd2.Cmd(persistent_history_file=hist_file) - out, err = capsys.readouterr() - - # Make sure there were no errors - assert err == '' - - # Unregister the call to write_history_file that cmd2 did - atexit.unregister(readline.write_history_file) - - # Remove created history file - os.remove(hist_file) - - -def test_new_history_file(hist_file, capsys): - import atexit - import readline - - # Remove any existing history file - try: - os.remove(hist_file) - except OSError: - pass - - # Create a new cmd2 app - app = cmd2.Cmd(persistent_history_file=hist_file) - out, err = capsys.readouterr() - - # Make sure there were no errors - assert err == '' - - # Unregister the call to write_history_file that cmd2 did - atexit.unregister(readline.write_history_file) - - # Remove created history file - os.remove(hist_file) - -def test_bad_history_file_path(capsys, request): - # Use a directory path as the history file - test_dir = os.path.dirname(request.module.__file__) - - # Create a new cmd2 app - app = cmd2.Cmd(persistent_history_file=test_dir) - out, err = capsys.readouterr() - - if sys.platform == 'win32': - # pyreadline masks the read exception. Therefore the bad path error occurs when trying to write the file. - assert 'readline cannot write' in err - else: - # GNU readline raises an exception upon trying to read the directory as a file - assert 'readline cannot read' in err - - def test_get_all_commands(base_app): # Verify that the base app has the expected commands commands = base_app.get_all_commands() diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 00000000..06c57b4c --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,410 @@ +# coding=utf-8 +# flake8: noqa E302 +""" +Test history functions of cmd2 +""" +import tempfile +import os +import sys + +import pytest + +# Python 3.5 had some regressions in the unitest.mock module, so use 3rd party mock if available +try: + import mock +except ImportError: + from unittest import mock + +import cmd2 +from cmd2 import clipboard +from cmd2 import utils +from .conftest import run_cmd, normalize, HELP_HISTORY + + +def test_base_help_history(base_app): + out = run_cmd(base_app, 'help history') + assert out == normalize(HELP_HISTORY) + +def test_exclude_from_history(base_app, monkeypatch): + # Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock + base_app.editor = 'fooedit' + + # Mock out the subprocess.Popen call so we don't actually open an editor + m = mock.MagicMock(name='Popen') + monkeypatch.setattr("subprocess.Popen", m) + + # Run edit command + run_cmd(base_app, 'edit') + + # Run history command + run_cmd(base_app, 'history') + + # Verify that the history is empty + out = run_cmd(base_app, 'history') + assert out == [] + + # Now run a command which isn't excluded from the history + run_cmd(base_app, 'help') + + # And verify we have a history now ... + out = run_cmd(base_app, 'history') + expected = normalize(""" 1 help""") + assert out == expected + + +@pytest.fixture +def hist(): + from cmd2.parsing import Statement + from cmd2.cmd2 import History, HistoryItem + h = History([HistoryItem(Statement('', raw='first')), + HistoryItem(Statement('', raw='second')), + HistoryItem(Statement('', raw='third')), + HistoryItem(Statement('', raw='fourth'))]) + return h + +def test_history_class_span(hist): + h = hist + assert h == ['first', 'second', 'third', 'fourth'] + assert h.span('-2..') == ['third', 'fourth'] + assert h.span('2..3') == ['second', 'third'] # Inclusive of end + assert h.span('3') == ['third'] + assert h.span(':') == h + assert h.span('2..') == ['second', 'third', 'fourth'] + assert h.span('-1') == ['fourth'] + assert h.span('-2..-3') == ['third', 'second'] + assert h.span('*') == h + +def test_history_class_get(hist): + h = hist + assert h == ['first', 'second', 'third', 'fourth'] + assert h.get('') == h + assert h.get('-2') == h[:-2] + assert h.get('5') == [] + assert h.get('2-3') == ['second'] # Exclusive of end + assert h.get('ir') == ['first', 'third'] # Normal string search for all elements containing "ir" + assert h.get('/i.*d/') == ['third'] # Regex string search "i", then anything, then "d" + +def test_base_history(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + out = run_cmd(base_app, 'history') + expected = normalize(""" + 1 help + 2 shortcuts +""") + assert out == expected + + out = run_cmd(base_app, 'history he') + expected = normalize(""" + 1 help +""") + assert out == expected + + out = run_cmd(base_app, 'history sh') + expected = normalize(""" + 2 shortcuts +""") + assert out == expected + +def test_history_script_format(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + out = run_cmd(base_app, 'history -s') + expected = normalize(""" +help +shortcuts +""") + assert out == expected + +def test_history_with_string_argument(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + run_cmd(base_app, 'help history') + out = run_cmd(base_app, 'history help') + expected = normalize(""" + 1 help + 3 help history +""") + assert out == expected + +def test_history_expanded_with_string_argument(base_app): + run_cmd(base_app, 'alias create sc shortcuts') + run_cmd(base_app, 'help') + run_cmd(base_app, 'help history') + run_cmd(base_app, 'sc') + out = run_cmd(base_app, 'history -v shortcuts') + expected = normalize(""" + 1 alias create sc shortcuts + 4 sc + 4x shortcuts +""") + assert out == expected + +def test_history_expanded_with_regex_argument(base_app): + run_cmd(base_app, 'alias create sc shortcuts') + run_cmd(base_app, 'help') + run_cmd(base_app, 'help history') + run_cmd(base_app, 'sc') + out = run_cmd(base_app, 'history -v /sh.*cuts/') + expected = normalize(""" + 1 alias create sc shortcuts + 4 sc + 4x shortcuts +""") + assert out == expected + +def test_history_with_integer_argument(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + out = run_cmd(base_app, 'history 1') + expected = normalize(""" + 1 help +""") + assert out == expected + + +def test_history_with_integer_span(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + run_cmd(base_app, 'help history') + out = run_cmd(base_app, 'history 1..2') + expected = normalize(""" + 1 help + 2 shortcuts +""") + assert out == expected + +def test_history_with_span_start(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + run_cmd(base_app, 'help history') + out = run_cmd(base_app, 'history 2:') + expected = normalize(""" + 2 shortcuts + 3 help history +""") + assert out == expected + +def test_history_with_span_end(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + run_cmd(base_app, 'help history') + out = run_cmd(base_app, 'history :2') + expected = normalize(""" + 1 help + 2 shortcuts +""") + assert out == expected + +def test_history_with_span_index_error(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'help history') + run_cmd(base_app, '!ls -hal :') + out = run_cmd(base_app, 'history "hal :"') + expected = normalize(""" + 3 !ls -hal : +""") + assert out == expected + +def test_history_output_file(base_app): + run_cmd(base_app, 'help') + run_cmd(base_app, 'shortcuts') + run_cmd(base_app, 'help history') + + fd, fname = tempfile.mkstemp(prefix='', suffix='.txt') + os.close(fd) + run_cmd(base_app, 'history -o "{}"'.format(fname)) + expected = normalize('\n'.join(['help', 'shortcuts', 'help history'])) + with open(fname) as f: + content = normalize(f.read()) + assert content == expected + +def test_history_edit(base_app, monkeypatch): + # Set a fake editor just to make sure we have one. We aren't really + # going to call it due to the mock + base_app.editor = 'fooedit' + + # Mock out the Popen call so we don't actually open an editor + m = mock.MagicMock(name='Popen') + monkeypatch.setattr("subprocess.Popen", m) + + # Run help command just so we have a command in history + run_cmd(base_app, 'help') + run_cmd(base_app, 'history -e 1') + + # We have an editor, so should expect a Popen call + m.assert_called_once() + +def test_history_run_all_commands(base_app): + # make sure we refuse to run all commands as a default + run_cmd(base_app, 'shortcuts') + out = run_cmd(base_app, 'history -r') + # this should generate an error, but we don't currently have a way to + # capture stderr in these tests. So we assume that if we got nothing on + # standard out, that the error occurred because if the command executed + # then we should have a list of shortcuts in our output + assert out == [] + +def test_history_run_one_command(base_app): + expected = run_cmd(base_app, 'help') + output = run_cmd(base_app, 'history -r 1') + assert output == expected + +def test_history_clear(base_app): + # Add commands to history + run_cmd(base_app, 'help') + run_cmd(base_app, 'alias') + + # Make sure history has items + out = run_cmd(base_app, 'history') + assert out + + # Clear the history + run_cmd(base_app, 'history --clear') + + # Make sure history is empty + out = run_cmd(base_app, 'history') + assert out == [] + +def test_history_verbose_with_other_options(base_app): + # make sure -v shows a usage error if any other options are present + options_to_test = ['-r', '-e', '-o file', '-t file', '-c', '-x'] + for opt in options_to_test: + output = run_cmd(base_app, 'history -v ' + opt) + assert len(output) == 3 + assert output[1].startswith('Usage:') + +def test_history_verbose(base_app): + # validate function of -v option + run_cmd(base_app, 'alias create s shortcuts') + run_cmd(base_app, 's') + output = run_cmd(base_app, 'history -v') + assert len(output) == 3 + # TODO test for basic formatting once we figure it out + +def test_history_script_with_invalid_options(base_app): + # make sure -s shows a usage error if -c, -r, -e, -o, or -t are present + options_to_test = ['-r', '-e', '-o file', '-t file', '-c'] + for opt in options_to_test: + output = run_cmd(base_app, 'history -s ' + opt) + assert len(output) == 3 + assert output[1].startswith('Usage:') + +def test_history_script(base_app): + cmds = ['alias create s shortcuts', 's'] + for cmd in cmds: + run_cmd(base_app, cmd) + output = run_cmd(base_app, 'history -s') + assert output == cmds + +def test_history_expanded_with_invalid_options(base_app): + # make sure -x shows a usage error if -c, -r, -e, -o, or -t are present + options_to_test = ['-r', '-e', '-o file', '-t file', '-c'] + for opt in options_to_test: + output = run_cmd(base_app, 'history -x ' + opt) + assert len(output) == 3 + assert output[1].startswith('Usage:') + +def test_history_expanded(base_app): + # validate function of -x option + cmds = ['alias create s shortcuts', 's'] + for cmd in cmds: + run_cmd(base_app, cmd) + output = run_cmd(base_app, 'history -x') + expected = [' 1 alias create s shortcuts', ' 2 shortcuts'] + assert output == expected + +def test_history_script_expanded(base_app): + # validate function of -s -x options together + cmds = ['alias create s shortcuts', 's'] + for cmd in cmds: + run_cmd(base_app, cmd) + output = run_cmd(base_app, 'history -sx') + expected = ['alias create s shortcuts', 'shortcuts'] + assert output == expected + + +##### +# +# readline tests +# +##### +def test_readline_remove_history_item(base_app): + from cmd2.rl_utils import readline + assert readline.get_current_history_length() == 0 + readline.add_history('this is a test') + assert readline.get_current_history_length() == 1 + readline.remove_history_item(0) + assert readline.get_current_history_length() == 0 + + +@pytest.fixture(scope="session") +def hist_file(): + fd, filename = tempfile.mkstemp(prefix='hist_file', suffix='.txt') + os.close(fd) + yield filename + # teardown code + try: + os.remove(filename) + except FileNotFoundError: + pass + +def test_existing_history_file(hist_file, capsys): + import atexit + import readline + + # Create the history file before making cmd2 app + with open(hist_file, 'w'): + pass + + # Create a new cmd2 app + app = cmd2.Cmd(persistent_history_file=hist_file) + out, err = capsys.readouterr() + + # Make sure there were no errors + assert err == '' + + # Unregister the call to write_history_file that cmd2 did + atexit.unregister(readline.write_history_file) + + # Remove created history file + os.remove(hist_file) + +def test_new_history_file(hist_file, capsys): + import atexit + import readline + + # Remove any existing history file + try: + os.remove(hist_file) + except OSError: + pass + + # Create a new cmd2 app + app = cmd2.Cmd(persistent_history_file=hist_file) + out, err = capsys.readouterr() + + # Make sure there were no errors + assert err == '' + + # Unregister the call to write_history_file that cmd2 did + atexit.unregister(readline.write_history_file) + + # Remove created history file + os.remove(hist_file) + +def test_bad_history_file_path(capsys, request): + # Use a directory path as the history file + test_dir = os.path.dirname(request.module.__file__) + + # Create a new cmd2 app + app = cmd2.Cmd(persistent_history_file=test_dir) + out, err = capsys.readouterr() + + if sys.platform == 'win32': + # pyreadline masks the read exception. Therefore the bad path error occurs when trying to write the file. + assert 'readline cannot write' in err + else: + # GNU readline raises an exception upon trying to read the directory as a file + assert 'readline cannot read' in err + diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt index 8c0dd007..871b71f1 100644 --- a/tests/transcripts/from_cmdloop.txt +++ b/tests/transcripts/from_cmdloop.txt @@ -35,18 +35,12 @@ OODNIGHT, GRACIEGAY OODNIGHT, GRACIEGAY OODNIGHT, GRACIEGAY (Cmd) history --------------------------[1] -help --------------------------[2] -help say --------------------------[3] -say goodnight, Gracie --------------------------[4] -say -ps --repeat=5 goodnight, Gracie --------------------------[5] -set maxrepeats 5 --------------------------[6] -say -ps --repeat=5 goodnight, Gracie + 1 help + 2 help say + 3 say goodnight, Gracie + 4 say -ps --repeat=5 goodnight, Gracie + 5 set maxrepeats 5 + 6 say -ps --repeat=5 goodnight, Gracie (Cmd) history -r 4 say -ps --repeat=5 goodnight, Gracie OODNIGHT, GRACIEGAY |