diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 03:05:31 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 03:05:31 +0000 |
commit | e2e51038f71bb0ee8062603e3247d6660a75644b (patch) | |
tree | 8cef3c436dc2a3c6301c5b61bc5d8f1362ee918e /test/features | |
parent | Initial commit. (diff) | |
download | mycli-8e4bf343cde9134070a5f80a3c8dabd21f3df2a4.tar.xz mycli-8e4bf343cde9134070a5f80a3c8dabd21f3df2a4.zip |
Adding upstream version 1.27.0.upstream/1.27.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'test/features')
26 files changed, 1387 insertions, 0 deletions
diff --git a/test/features/__init__.py b/test/features/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/test/features/__init__.py diff --git a/test/features/auto_vertical.feature b/test/features/auto_vertical.feature new file mode 100644 index 0000000..aa95718 --- /dev/null +++ b/test/features/auto_vertical.feature @@ -0,0 +1,12 @@ +Feature: auto_vertical mode: + on, off + + Scenario: auto_vertical on with small query + When we run dbcli with --auto-vertical-output + and we execute a small query + then we see small results in horizontal format + + Scenario: auto_vertical on with large query + When we run dbcli with --auto-vertical-output + and we execute a large query + then we see large results in vertical format diff --git a/test/features/basic_commands.feature b/test/features/basic_commands.feature new file mode 100644 index 0000000..a12e899 --- /dev/null +++ b/test/features/basic_commands.feature @@ -0,0 +1,19 @@ +Feature: run the cli, + call the help command, + exit the cli + + Scenario: run "\?" command + When we send "\?" command + then we see help output + + Scenario: run source command + When we send source command + then we see help output + + Scenario: check our application_name + When we run query to check application_name + then we see found + + Scenario: run the cli and exit + When we send "ctrl + d" + then dbcli exits diff --git a/test/features/connection.feature b/test/features/connection.feature new file mode 100644 index 0000000..b06935e --- /dev/null +++ b/test/features/connection.feature @@ -0,0 +1,35 @@ +Feature: connect to a database: + + @requires_local_db + Scenario: run mycli on localhost without port + When we run mycli with arguments "host=localhost" without arguments "port" + When we query "status" + Then status contains "via UNIX socket" + + Scenario: run mycli on TCP host without port + When we run mycli without arguments "port" + When we query "status" + Then status contains "via TCP/IP" + + Scenario: run mycli with port but without host + When we run mycli without arguments "host" + When we query "status" + Then status contains "via TCP/IP" + + @requires_local_db + Scenario: run mycli without host and port + When we run mycli without arguments "host port" + When we query "status" + Then status contains "via UNIX socket" + + Scenario: run mycli with my.cnf configuration + When we create my.cnf file + When we run mycli without arguments "host port user pass defaults_file" + Then we are logged in + + Scenario: run mycli with mylogin.cnf configuration + When we create mylogin.cnf file + When we run mycli with arguments "login_path=test_login_path" without arguments "host port user pass defaults_file" + Then we are logged in + + diff --git a/test/features/crud_database.feature b/test/features/crud_database.feature new file mode 100644 index 0000000..f4a7a7f --- /dev/null +++ b/test/features/crud_database.feature @@ -0,0 +1,30 @@ +Feature: manipulate databases: + create, drop, connect, disconnect + + Scenario: create and drop temporary database + When we create database + then we see database created + when we drop database + then we confirm the destructive warning + then we see database dropped + when we connect to dbserver + then we see database connected + + Scenario: connect and disconnect from test database + When we connect to test database + then we see database connected + when we connect to dbserver + then we see database connected + + Scenario: connect and disconnect from quoted test database + When we connect to quoted test database + then we see database connected + + Scenario: create and drop default database + When we create database + then we see database created + when we connect to tmp database + then we see database connected + when we drop database + then we confirm the destructive warning + then we see database dropped and no default database diff --git a/test/features/crud_table.feature b/test/features/crud_table.feature new file mode 100644 index 0000000..3384efd --- /dev/null +++ b/test/features/crud_table.feature @@ -0,0 +1,49 @@ +Feature: manipulate tables: + create, insert, update, select, delete from, drop + + Scenario: create, insert, select from, update, drop table + When we connect to test database + then we see database connected + when we create table + then we see table created + when we insert into table + then we see record inserted + when we update table + then we see record updated + when we select from table + then we see data selected + when we delete from table + then we confirm the destructive warning + then we see record deleted + when we drop table + then we confirm the destructive warning + then we see table dropped + when we connect to dbserver + then we see database connected + + Scenario: select null values + When we connect to test database + then we see database connected + when we select null + then we see null selected + + Scenario: confirm destructive query + When we query "create table foo(x integer);" + and we query "delete from foo;" + and we answer the destructive warning with "y" + then we see text "Your call!" + + Scenario: decline destructive query + When we query "delete from foo;" + and we answer the destructive warning with "n" + then we see text "Wise choice!" + + Scenario: no destructive warning if disabled in config + When we run dbcli with --no-warn + and we query "create table blabla(x integer);" + and we query "delete from blabla;" + Then we see text "Query OK" + + Scenario: confirm destructive query with invalid response + When we query "delete from foo;" + then we answer the destructive warning with invalid "1" and see text "is not a valid boolean" diff --git a/test/features/db_utils.py b/test/features/db_utils.py new file mode 100644 index 0000000..be550e9 --- /dev/null +++ b/test/features/db_utils.py @@ -0,0 +1,93 @@ +import pymysql + + +def create_db(hostname='localhost', port=3306, username=None, + password=None, dbname=None): + """Create test database. + + :param hostname: string + :param port: int + :param username: string + :param password: string + :param dbname: string + :return: + + """ + cn = pymysql.connect( + host=hostname, + port=port, + user=username, + password=password, + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + + with cn.cursor() as cr: + cr.execute('drop database if exists ' + dbname) + cr.execute('create database ' + dbname) + + cn.close() + + cn = create_cn(hostname, port, password, username, dbname) + return cn + + +def create_cn(hostname, port, password, username, dbname): + """Open connection to database. + + :param hostname: + :param port: + :param password: + :param username: + :param dbname: string + :return: psycopg2.connection + + """ + cn = pymysql.connect( + host=hostname, + port=port, + user=username, + password=password, + db=dbname, + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + + return cn + + +def drop_db(hostname='localhost', port=3306, username=None, + password=None, dbname=None): + """Drop database. + + :param hostname: string + :param port: int + :param username: string + :param password: string + :param dbname: string + + """ + cn = pymysql.connect( + host=hostname, + port=port, + user=username, + password=password, + db=dbname, + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + + with cn.cursor() as cr: + cr.execute('drop database if exists ' + dbname) + + close_cn(cn) + + +def close_cn(cn=None): + """Close connection. + + :param connection: pymysql.connection + + """ + if cn: + cn.close() diff --git a/test/features/environment.py b/test/features/environment.py new file mode 100644 index 0000000..1ea0f08 --- /dev/null +++ b/test/features/environment.py @@ -0,0 +1,176 @@ +import os +import shutil +import sys +from tempfile import mkstemp + +import db_utils as dbutils +import fixture_utils as fixutils +import pexpect + +from steps.wrappers import run_cli, wait_prompt + +test_log_file = os.path.join(os.environ['HOME'], '.mycli.test.log') + + +SELF_CONNECTING_FEATURES = ( + 'test/features/connection.feature', +) + + +MY_CNF_PATH = os.path.expanduser('~/.my.cnf') +MY_CNF_BACKUP_PATH = f'{MY_CNF_PATH}.backup' +MYLOGIN_CNF_PATH = os.path.expanduser('~/.mylogin.cnf') +MYLOGIN_CNF_BACKUP_PATH = f'{MYLOGIN_CNF_PATH}.backup' + + +def get_db_name_from_context(context): + return context.config.userdata.get( + 'my_test_db', None + ) or "mycli_behave_tests" + + + +def before_all(context): + """Set env parameters.""" + os.environ['LINES'] = "100" + os.environ['COLUMNS'] = "100" + os.environ['EDITOR'] = 'ex' + os.environ['LC_ALL'] = 'en_US.UTF-8' + os.environ['PROMPT_TOOLKIT_NO_CPR'] = '1' + os.environ['MYCLI_HISTFILE'] = os.devnull + + test_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) + login_path_file = os.path.join(test_dir, 'mylogin.cnf') +# os.environ['MYSQL_TEST_LOGIN_FILE'] = login_path_file + + context.package_root = os.path.abspath( + os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + + os.environ["COVERAGE_PROCESS_START"] = os.path.join(context.package_root, + '.coveragerc') + + context.exit_sent = False + + vi = '_'.join([str(x) for x in sys.version_info[:3]]) + db_name = get_db_name_from_context(context) + db_name_full = '{0}_{1}'.format(db_name, vi) + + # Store get params from config/environment variables + context.conf = { + 'host': context.config.userdata.get( + 'my_test_host', + os.getenv('PYTEST_HOST', 'localhost') + ), + 'port': context.config.userdata.get( + 'my_test_port', + int(os.getenv('PYTEST_PORT', '3306')) + ), + 'user': context.config.userdata.get( + 'my_test_user', + os.getenv('PYTEST_USER', 'root') + ), + 'pass': context.config.userdata.get( + 'my_test_pass', + os.getenv('PYTEST_PASSWORD', None) + ), + 'cli_command': context.config.userdata.get( + 'my_cli_command', None) or + sys.executable + ' -c "import coverage ; coverage.process_startup(); import mycli.main; mycli.main.cli()"', + 'dbname': db_name, + 'dbname_tmp': db_name_full + '_tmp', + 'vi': vi, + 'pager_boundary': '---boundary---', + } + + _, my_cnf = mkstemp() + with open(my_cnf, 'w') as f: + f.write( + '[client]\n' + 'pager={0} {1} {2}\n'.format( + sys.executable, os.path.join(context.package_root, + 'test/features/wrappager.py'), + context.conf['pager_boundary']) + ) + context.conf['defaults-file'] = my_cnf + context.conf['myclirc'] = os.path.join(context.package_root, 'test', + 'myclirc') + + context.cn = dbutils.create_db(context.conf['host'], context.conf['port'], + context.conf['user'], + context.conf['pass'], + context.conf['dbname']) + + context.fixture_data = fixutils.read_fixture_files() + + +def after_all(context): + """Unset env parameters.""" + dbutils.close_cn(context.cn) + dbutils.drop_db(context.conf['host'], context.conf['port'], + context.conf['user'], context.conf['pass'], + context.conf['dbname']) + + # Restore env vars. + #for k, v in context.pgenv.items(): + # if k in os.environ and v is None: + # del os.environ[k] + # elif v: + # os.environ[k] = v + + +def before_step(context, _): + context.atprompt = False + + +def before_scenario(context, arg): + with open(test_log_file, 'w') as f: + f.write('') + if arg.location.filename not in SELF_CONNECTING_FEATURES: + run_cli(context) + wait_prompt(context) + + if os.path.exists(MY_CNF_PATH): + shutil.move(MY_CNF_PATH, MY_CNF_BACKUP_PATH) + + if os.path.exists(MYLOGIN_CNF_PATH): + shutil.move(MYLOGIN_CNF_PATH, MYLOGIN_CNF_BACKUP_PATH) + + +def after_scenario(context, _): + """Cleans up after each test complete.""" + with open(test_log_file) as f: + for line in f: + if 'error' in line.lower(): + raise RuntimeError(f'Error in log file: {line}') + + if hasattr(context, 'cli') and not context.exit_sent: + # Quit nicely. + if not context.atprompt: + user = context.conf['user'] + host = context.conf['host'] + dbname = context.currentdb + context.cli.expect_exact( + '{0}@{1}:{2}>'.format( + user, host, dbname + ), + timeout=5 + ) + context.cli.sendcontrol('c') + context.cli.sendcontrol('d') + context.cli.expect_exact(pexpect.EOF, timeout=5) + + if os.path.exists(MY_CNF_BACKUP_PATH): + shutil.move(MY_CNF_BACKUP_PATH, MY_CNF_PATH) + + if os.path.exists(MYLOGIN_CNF_BACKUP_PATH): + shutil.move(MYLOGIN_CNF_BACKUP_PATH, MYLOGIN_CNF_PATH) + elif os.path.exists(MYLOGIN_CNF_PATH): + # This file was moved in `before_scenario`. + # If it exists now, it has been created during a test + os.remove(MYLOGIN_CNF_PATH) + + +# TODO: uncomment to debug a failure +# def after_step(context, step): +# if step.status == "failed": +# import ipdb; ipdb.set_trace() diff --git a/test/features/fixture_data/help.txt b/test/features/fixture_data/help.txt new file mode 100644 index 0000000..deb499a --- /dev/null +++ b/test/features/fixture_data/help.txt @@ -0,0 +1,24 @@ ++--------------------------+-----------------------------------------------+ +| Command | Description | +|--------------------------+-----------------------------------------------| +| \# | Refresh auto-completions. | +| \? | Show Help. | +| \c[onnect] database_name | Change to a new database. | +| \d [pattern] | List or describe tables, views and sequences. | +| \dT[S+] [pattern] | List data types | +| \df[+] [pattern] | List functions. | +| \di[+] [pattern] | List indexes. | +| \dn[+] [pattern] | List schemas. | +| \ds[+] [pattern] | List sequences. | +| \dt[+] [pattern] | List tables. | +| \du[+] [pattern] | List roles. | +| \dv[+] [pattern] | List views. | +| \e [file] | Edit the query with external editor. | +| \l | List databases. | +| \n[+] [name] | List or execute named queries. | +| \nd [name [query]] | Delete a named query. | +| \ns name query | Save a named query. | +| \refresh | Refresh auto-completions. | +| \timing | Toggle timing of commands. | +| \x | Toggle expanded output. | ++--------------------------+-----------------------------------------------+ diff --git a/test/features/fixture_data/help_commands.txt b/test/features/fixture_data/help_commands.txt new file mode 100644 index 0000000..2c06d5d --- /dev/null +++ b/test/features/fixture_data/help_commands.txt @@ -0,0 +1,31 @@ ++-------------+----------------------------+------------------------------------------------------------+
+| Command | Shortcut | Description |
++-------------+----------------------------+------------------------------------------------------------+
+| \G | \G | Display current query results vertically. |
+| \clip | \clip | Copy query to the system clipboard. |
+| \dt | \dt[+] [table] | List or describe tables. |
+| \e | \e | Edit command with editor (uses $EDITOR). |
+| \f | \f [name [args..]] | List or execute favorite queries. |
+| \fd | \fd [name] | Delete a favorite query. |
+| \fs | \fs name query | Save a favorite query. |
+| \l | \l | List databases. |
+| \once | \o [-o] filename | Append next result to an output file (overwrite using -o). |
+| \pipe_once | \| command | Send next result to a subprocess. |
+| \timing | \t | Toggle timing of commands. |
+| connect | \r | Reconnect to the database. Optional database argument. |
+| exit | \q | Exit. |
+| help | \? | Show this help. |
+| nopager | \n | Disable pager, print to stdout. |
+| notee | notee | Stop writing results to an output file. |
+| pager | \P [command] | Set PAGER. Print the query results via PAGER. |
+| prompt | \R | Change prompt format. |
+| quit | \q | Quit. |
+| rehash | \# | Refresh auto-completions. |
+| source | \. filename | Execute commands from file. |
+| status | \s | Get status information from the server. |
+| system | system [command] | Execute a system shell commmand. |
+| tableformat | \T | Change the table format used to output results. |
+| tee | tee [-o] filename | Append all results to an output file (overwrite using -o). |
+| use | \u | Change to a new database. |
+| watch | watch [seconds] [-c] query | Executes the query every [seconds] seconds (by default 5). |
++-------------+----------------------------+------------------------------------------------------------+
diff --git a/test/features/fixture_utils.py b/test/features/fixture_utils.py new file mode 100644 index 0000000..f85e0f6 --- /dev/null +++ b/test/features/fixture_utils.py @@ -0,0 +1,29 @@ +import os +import io + + +def read_fixture_lines(filename): + """Read lines of text from file. + + :param filename: string name + :return: list of strings + + """ + lines = [] + for line in open(filename): + lines.append(line.strip()) + return lines + + +def read_fixture_files(): + """Read all files inside fixture_data directory.""" + fixture_dict = {} + + current_dir = os.path.dirname(__file__) + fixture_dir = os.path.join(current_dir, 'fixture_data/') + for filename in os.listdir(fixture_dir): + if filename not in ['.', '..']: + fullname = os.path.join(fixture_dir, filename) + fixture_dict[filename] = read_fixture_lines(fullname) + + return fixture_dict diff --git a/test/features/iocommands.feature b/test/features/iocommands.feature new file mode 100644 index 0000000..95366eb --- /dev/null +++ b/test/features/iocommands.feature @@ -0,0 +1,47 @@ +Feature: I/O commands + + Scenario: edit sql in file with external editor + When we start external editor providing a file name + and we type "select * from abc" in the editor + and we exit the editor + then we see dbcli prompt + and we see "select * from abc" in prompt + + Scenario: tee output from query + When we tee output + and we wait for prompt + and we select "select 123456" + and we wait for prompt + and we notee output + and we wait for prompt + then we see 123456 in tee output + + Scenario: set delimiter + When we query "delimiter $" + then delimiter is set to "$" + + Scenario: set delimiter twice + When we query "delimiter $" + and we query "delimiter ]]" + then delimiter is set to "]]" + + Scenario: set delimiter and query on same line + When we query "select 123; delimiter $ select 456 $ delimiter %" + then we see result "123" + and we see result "456" + and delimiter is set to "%" + + Scenario: send output to file + When we query "\o /tmp/output1.sql" + and we query "select 123" + and we query "system cat /tmp/output1.sql" + then we see result "123" + + Scenario: send output to file two times + When we query "\o /tmp/output1.sql" + and we query "select 123" + and we query "\o /tmp/output2.sql" + and we query "select 456" + and we query "system cat /tmp/output2.sql" + then we see result "456" +
\ No newline at end of file diff --git a/test/features/named_queries.feature b/test/features/named_queries.feature new file mode 100644 index 0000000..5e681ec --- /dev/null +++ b/test/features/named_queries.feature @@ -0,0 +1,24 @@ +Feature: named queries: + save, use and delete named queries + + Scenario: save, use and delete named queries + When we connect to test database + then we see database connected + when we save a named query + then we see the named query saved + when we use a named query + then we see the named query executed + when we delete a named query + then we see the named query deleted + + Scenario: save, use and delete named queries with parameters + When we connect to test database + then we see database connected + when we save a named query with parameters + then we see the named query saved + when we use named query with parameters + then we see the named query with parameters executed + when we use named query with too few parameters + then we see the named query with parameters fail with missing parameters + when we use named query with too many parameters + then we see the named query with parameters fail with extra parameters diff --git a/test/features/specials.feature b/test/features/specials.feature new file mode 100644 index 0000000..bb36757 --- /dev/null +++ b/test/features/specials.feature @@ -0,0 +1,7 @@ +Feature: Special commands + + @wip + Scenario: run refresh command + When we refresh completions + and we wait for prompt + then we see completions refresh started diff --git a/test/features/steps/__init__.py b/test/features/steps/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/test/features/steps/__init__.py diff --git a/test/features/steps/auto_vertical.py b/test/features/steps/auto_vertical.py new file mode 100644 index 0000000..e1cb26f --- /dev/null +++ b/test/features/steps/auto_vertical.py @@ -0,0 +1,46 @@ +from textwrap import dedent + +from behave import then, when + +import wrappers +from utils import parse_cli_args_to_dict + + +@when('we run dbcli with {arg}') +def step_run_cli_with_arg(context, arg): + wrappers.run_cli(context, run_args=parse_cli_args_to_dict(arg)) + + +@when('we execute a small query') +def step_execute_small_query(context): + context.cli.sendline('select 1') + + +@when('we execute a large query') +def step_execute_large_query(context): + context.cli.sendline( + 'select {}'.format(','.join([str(n) for n in range(1, 50)]))) + + +@then('we see small results in horizontal format') +def step_see_small_results(context): + wrappers.expect_pager(context, dedent("""\ + +---+\r + | 1 |\r + +---+\r + | 1 |\r + +---+\r + \r + """), timeout=5) + wrappers.expect_exact(context, '1 row in set', timeout=2) + + +@then('we see large results in vertical format') +def step_see_large_results(context): + rows = ['{n:3}| {n}'.format(n=str(n)) for n in range(1, 50)] + expected = ('***************************[ 1. row ]' + '***************************\r\n' + + '{}\r\n'.format('\r\n'.join(rows) + '\r\n')) + + wrappers.expect_pager(context, expected, timeout=10) + wrappers.expect_exact(context, '1 row in set', timeout=2) diff --git a/test/features/steps/basic_commands.py b/test/features/steps/basic_commands.py new file mode 100644 index 0000000..425ef67 --- /dev/null +++ b/test/features/steps/basic_commands.py @@ -0,0 +1,100 @@ +"""Steps for behavioral style tests are defined in this module. + +Each step is defined by the string decorating it. This string is used +to call the step in "*.feature" file. + +""" + +from behave import when +from textwrap import dedent +import tempfile +import wrappers + + +@when('we run dbcli') +def step_run_cli(context): + wrappers.run_cli(context) + + +@when('we wait for prompt') +def step_wait_prompt(context): + wrappers.wait_prompt(context) + + +@when('we send "ctrl + d"') +def step_ctrl_d(context): + """Send Ctrl + D to hopefully exit.""" + context.cli.sendcontrol('d') + context.exit_sent = True + + +@when('we send "\?" command') +def step_send_help(context): + """Send \? + + to see help. + + """ + context.cli.sendline('\\?') + wrappers.expect_exact( + context, context.conf['pager_boundary'] + '\r\n', timeout=5) + + +@when(u'we send source command') +def step_send_source_command(context): + with tempfile.NamedTemporaryFile() as f: + f.write(b'\?') + f.flush() + context.cli.sendline('\. {0}'.format(f.name)) + wrappers.expect_exact( + context, context.conf['pager_boundary'] + '\r\n', timeout=5) + + +@when(u'we run query to check application_name') +def step_check_application_name(context): + context.cli.sendline( + "SELECT 'found' FROM performance_schema.session_connect_attrs WHERE attr_name = 'program_name' AND attr_value = 'mycli'" + ) + + +@then(u'we see found') +def step_see_found(context): + wrappers.expect_exact( + context, + context.conf['pager_boundary'] + '\r' + dedent(''' + +-------+\r + | found |\r + +-------+\r + | found |\r + +-------+\r + \r + ''') + context.conf['pager_boundary'], + timeout=5 + ) + + +@then(u'we confirm the destructive warning') +def step_confirm_destructive_command(context): + """Confirm destructive command.""" + wrappers.expect_exact( + context, 'You\'re about to run a destructive command.\r\nDo you want to proceed? (y/n):', timeout=2) + context.cli.sendline('y') + + +@when(u'we answer the destructive warning with "{confirmation}"') +def step_confirm_destructive_command(context, confirmation): + """Confirm destructive command.""" + wrappers.expect_exact( + context, 'You\'re about to run a destructive command.\r\nDo you want to proceed? (y/n):', timeout=2) + context.cli.sendline(confirmation) + + +@then(u'we answer the destructive warning with invalid "{confirmation}" and see text "{text}"') +def step_confirm_destructive_command(context, confirmation, text): + """Confirm destructive command.""" + wrappers.expect_exact( + context, 'You\'re about to run a destructive command.\r\nDo you want to proceed? (y/n):', timeout=2) + context.cli.sendline(confirmation) + wrappers.expect_exact(context, text, timeout=2) + # we must exit the Click loop, or the feature will hang + context.cli.sendline('n') diff --git a/test/features/steps/connection.py b/test/features/steps/connection.py new file mode 100644 index 0000000..e16dd86 --- /dev/null +++ b/test/features/steps/connection.py @@ -0,0 +1,71 @@ +import io +import os +import shlex + +from behave import when, then +import pexpect + +import wrappers +from test.features.steps.utils import parse_cli_args_to_dict +from test.features.environment import MY_CNF_PATH, MYLOGIN_CNF_PATH, get_db_name_from_context +from test.utils import HOST, PORT, USER, PASSWORD +from mycli.config import encrypt_mylogin_cnf + + +TEST_LOGIN_PATH = 'test_login_path' + + +@when('we run mycli with arguments "{exact_args}" without arguments "{excluded_args}"') +@when('we run mycli without arguments "{excluded_args}"') +def step_run_cli_without_args(context, excluded_args, exact_args=''): + wrappers.run_cli( + context, + run_args=parse_cli_args_to_dict(exact_args), + exclude_args=parse_cli_args_to_dict(excluded_args).keys() + ) + + +@then('status contains "{expression}"') +def status_contains(context, expression): + wrappers.expect_exact(context, f'{expression}', timeout=5) + + # Normally, the shutdown after scenario waits for the prompt. + # But we may have changed the prompt, depending on parameters, + # so let's wait for its last character + context.cli.expect_exact('>') + context.atprompt = True + + +@when('we create my.cnf file') +def step_create_my_cnf_file(context): + my_cnf = ( + '[client]\n' + f'host = {HOST}\n' + f'port = {PORT}\n' + f'user = {USER}\n' + f'password = {PASSWORD}\n' + ) + with open(MY_CNF_PATH, 'w') as f: + f.write(my_cnf) + + +@when('we create mylogin.cnf file') +def step_create_mylogin_cnf_file(context): + os.environ.pop('MYSQL_TEST_LOGIN_FILE', None) + mylogin_cnf = ( + f'[{TEST_LOGIN_PATH}]\n' + f'host = {HOST}\n' + f'port = {PORT}\n' + f'user = {USER}\n' + f'password = {PASSWORD}\n' + ) + with open(MYLOGIN_CNF_PATH, 'wb') as f: + input_file = io.StringIO(mylogin_cnf) + f.write(encrypt_mylogin_cnf(input_file).read()) + + +@then('we are logged in') +def we_are_logged_in(context): + db_name = get_db_name_from_context(context) + context.cli.expect_exact(f'{db_name}>', timeout=5) + context.atprompt = True diff --git a/test/features/steps/crud_database.py b/test/features/steps/crud_database.py new file mode 100644 index 0000000..841f37d --- /dev/null +++ b/test/features/steps/crud_database.py @@ -0,0 +1,115 @@ +"""Steps for behavioral style tests are defined in this module. + +Each step is defined by the string decorating it. This string is used +to call the step in "*.feature" file. + +""" + +import pexpect + +import wrappers +from behave import when, then + + +@when('we create database') +def step_db_create(context): + """Send create database.""" + context.cli.sendline('create database {0};'.format( + context.conf['dbname_tmp'])) + + context.response = { + 'database_name': context.conf['dbname_tmp'] + } + + +@when('we drop database') +def step_db_drop(context): + """Send drop database.""" + context.cli.sendline('drop database {0};'.format( + context.conf['dbname_tmp'])) + + +@when('we connect to test database') +def step_db_connect_test(context): + """Send connect to database.""" + db_name = context.conf['dbname'] + context.currentdb = db_name + context.cli.sendline('use {0};'.format(db_name)) + + +@when('we connect to quoted test database') +def step_db_connect_quoted_tmp(context): + """Send connect to database.""" + db_name = context.conf['dbname'] + context.currentdb = db_name + context.cli.sendline('use `{0}`;'.format(db_name)) + + +@when('we connect to tmp database') +def step_db_connect_tmp(context): + """Send connect to database.""" + db_name = context.conf['dbname_tmp'] + context.currentdb = db_name + context.cli.sendline('use {0}'.format(db_name)) + + +@when('we connect to dbserver') +def step_db_connect_dbserver(context): + """Send connect to database.""" + context.currentdb = 'mysql' + context.cli.sendline('use mysql') + + +@then('dbcli exits') +def step_wait_exit(context): + """Make sure the cli exits.""" + wrappers.expect_exact(context, pexpect.EOF, timeout=5) + + +@then('we see dbcli prompt') +def step_see_prompt(context): + """Wait to see the prompt.""" + user = context.conf['user'] + host = context.conf['host'] + dbname = context.currentdb + wrappers.wait_prompt(context, '{0}@{1}:{2}> '.format(user, host, dbname)) + + +@then('we see help output') +def step_see_help(context): + for expected_line in context.fixture_data['help_commands.txt']: + wrappers.expect_exact(context, expected_line, timeout=1) + + +@then('we see database created') +def step_see_db_created(context): + """Wait to see create database output.""" + wrappers.expect_exact(context, 'Query OK, 1 row affected', timeout=2) + + +@then('we see database dropped') +def step_see_db_dropped(context): + """Wait to see drop database output.""" + wrappers.expect_exact(context, 'Query OK, 0 rows affected', timeout=2) + + +@then('we see database dropped and no default database') +def step_see_db_dropped_no_default(context): + """Wait to see drop database output.""" + user = context.conf['user'] + host = context.conf['host'] + database = '(none)' + context.currentdb = None + + wrappers.expect_exact(context, 'Query OK, 0 rows affected', timeout=2) + wrappers.wait_prompt(context, '{0}@{1}:{2}>'.format(user, host, database)) + + +@then('we see database connected') +def step_see_db_connected(context): + """Wait to see drop database output.""" + wrappers.expect_exact( + context, 'You are now connected to database "', timeout=2) + wrappers.expect_exact(context, '"', timeout=2) + wrappers.expect_exact(context, ' as user "{0}"'.format( + context.conf['user']), timeout=2) diff --git a/test/features/steps/crud_table.py b/test/features/steps/crud_table.py new file mode 100644 index 0000000..f715f0c --- /dev/null +++ b/test/features/steps/crud_table.py @@ -0,0 +1,112 @@ +"""Steps for behavioral style tests are defined in this module. + +Each step is defined by the string decorating it. This string is used +to call the step in "*.feature" file. + +""" + +import wrappers +from behave import when, then +from textwrap import dedent + + +@when('we create table') +def step_create_table(context): + """Send create table.""" + context.cli.sendline('create table a(x text);') + + +@when('we insert into table') +def step_insert_into_table(context): + """Send insert into table.""" + context.cli.sendline('''insert into a(x) values('xxx');''') + + +@when('we update table') +def step_update_table(context): + """Send insert into table.""" + context.cli.sendline('''update a set x = 'yyy' where x = 'xxx';''') + + +@when('we select from table') +def step_select_from_table(context): + """Send select from table.""" + context.cli.sendline('select * from a;') + + +@when('we delete from table') +def step_delete_from_table(context): + """Send deete from table.""" + context.cli.sendline('''delete from a where x = 'yyy';''') + + +@when('we drop table') +def step_drop_table(context): + """Send drop table.""" + context.cli.sendline('drop table a;') + + +@then('we see table created') +def step_see_table_created(context): + """Wait to see create table output.""" + wrappers.expect_exact(context, 'Query OK, 0 rows affected', timeout=2) + + +@then('we see record inserted') +def step_see_record_inserted(context): + """Wait to see insert output.""" + wrappers.expect_exact(context, 'Query OK, 1 row affected', timeout=2) + + +@then('we see record updated') +def step_see_record_updated(context): + """Wait to see update output.""" + wrappers.expect_exact(context, 'Query OK, 1 row affected', timeout=2) + + +@then('we see data selected') +def step_see_data_selected(context): + """Wait to see select output.""" + wrappers.expect_pager( + context, dedent("""\ + +-----+\r + | x |\r + +-----+\r + | yyy |\r + +-----+\r + \r + """), timeout=2) + wrappers.expect_exact(context, '1 row in set', timeout=2) + + +@then('we see record deleted') +def step_see_data_deleted(context): + """Wait to see delete output.""" + wrappers.expect_exact(context, 'Query OK, 1 row affected', timeout=2) + + +@then('we see table dropped') +def step_see_table_dropped(context): + """Wait to see drop output.""" + wrappers.expect_exact(context, 'Query OK, 0 rows affected', timeout=2) + + +@when('we select null') +def step_select_null(context): + """Send select null.""" + context.cli.sendline('select null;') + + +@then('we see null selected') +def step_see_null_selected(context): + """Wait to see null output.""" + wrappers.expect_pager( + context, dedent("""\ + +--------+\r + | NULL |\r + +--------+\r + | <null> |\r + +--------+\r + \r + """), timeout=2) + wrappers.expect_exact(context, '1 row in set', timeout=2) diff --git a/test/features/steps/iocommands.py b/test/features/steps/iocommands.py new file mode 100644 index 0000000..bbabf43 --- /dev/null +++ b/test/features/steps/iocommands.py @@ -0,0 +1,105 @@ +import os +import wrappers + +from behave import when, then +from textwrap import dedent + + +@when('we start external editor providing a file name') +def step_edit_file(context): + """Edit file with external editor.""" + context.editor_file_name = os.path.join( + context.package_root, 'test_file_{0}.sql'.format(context.conf['vi'])) + if os.path.exists(context.editor_file_name): + os.remove(context.editor_file_name) + context.cli.sendline('\e {0}'.format( + os.path.basename(context.editor_file_name))) + wrappers.expect_exact( + context, 'Entering Ex mode. Type "visual" to go to Normal mode.', timeout=2) + wrappers.expect_exact(context, '\r\n:', timeout=2) + + +@when('we type "{query}" in the editor') +def step_edit_type_sql(context, query): + context.cli.sendline('i') + context.cli.sendline(query) + context.cli.sendline('.') + wrappers.expect_exact(context, '\r\n:', timeout=2) + + +@when('we exit the editor') +def step_edit_quit(context): + context.cli.sendline('x') + wrappers.expect_exact(context, "written", timeout=2) + + +@then('we see "{query}" in prompt') +def step_edit_done_sql(context, query): + for match in query.split(' '): + wrappers.expect_exact(context, match, timeout=5) + # Cleanup the command line. + context.cli.sendcontrol('c') + # Cleanup the edited file. + if context.editor_file_name and os.path.exists(context.editor_file_name): + os.remove(context.editor_file_name) + + +@when(u'we tee output') +def step_tee_ouptut(context): + context.tee_file_name = os.path.join( + context.package_root, 'tee_file_{0}.sql'.format(context.conf['vi'])) + if os.path.exists(context.tee_file_name): + os.remove(context.tee_file_name) + context.cli.sendline('tee {0}'.format( + os.path.basename(context.tee_file_name))) + + +@when(u'we select "select {param}"') +def step_query_select_number(context, param): + context.cli.sendline(u'select {}'.format(param)) + wrappers.expect_pager(context, dedent(u"""\ + +{dashes}+\r + | {param} |\r + +{dashes}+\r + | {param} |\r + +{dashes}+\r + \r + """.format(param=param, dashes='-' * (len(param) + 2)) + ), timeout=5) + wrappers.expect_exact(context, '1 row in set', timeout=2) + + +@then(u'we see result "{result}"') +def step_see_result(context, result): + wrappers.expect_exact( + context, + u"| {} |".format(result), + timeout=2 + ) + + +@when(u'we query "{query}"') +def step_query(context, query): + context.cli.sendline(query) + + +@when(u'we notee output') +def step_notee_output(context): + context.cli.sendline('notee') + + +@then(u'we see 123456 in tee output') +def step_see_123456_in_ouput(context): + with open(context.tee_file_name) as f: + assert '123456' in f.read() + if os.path.exists(context.tee_file_name): + os.remove(context.tee_file_name) + + +@then(u'delimiter is set to "{delimiter}"') +def delimiter_is_set(context, delimiter): + wrappers.expect_exact( + context, + u'Changed delimiter to {}'.format(delimiter), + timeout=2 + ) diff --git a/test/features/steps/named_queries.py b/test/features/steps/named_queries.py new file mode 100644 index 0000000..bc1f866 --- /dev/null +++ b/test/features/steps/named_queries.py @@ -0,0 +1,90 @@ +"""Steps for behavioral style tests are defined in this module. + +Each step is defined by the string decorating it. This string is used +to call the step in "*.feature" file. + +""" + +import wrappers +from behave import when, then + + +@when('we save a named query') +def step_save_named_query(context): + """Send \fs command.""" + context.cli.sendline('\\fs foo SELECT 12345') + + +@when('we use a named query') +def step_use_named_query(context): + """Send \f command.""" + context.cli.sendline('\\f foo') + + +@when('we delete a named query') +def step_delete_named_query(context): + """Send \fd command.""" + context.cli.sendline('\\fd foo') + + +@then('we see the named query saved') +def step_see_named_query_saved(context): + """Wait to see query saved.""" + wrappers.expect_exact(context, 'Saved.', timeout=2) + + +@then('we see the named query executed') +def step_see_named_query_executed(context): + """Wait to see select output.""" + wrappers.expect_exact(context, 'SELECT 12345', timeout=2) + + +@then('we see the named query deleted') +def step_see_named_query_deleted(context): + """Wait to see query deleted.""" + wrappers.expect_exact(context, 'foo: Deleted', timeout=2) + + +@when('we save a named query with parameters') +def step_save_named_query_with_parameters(context): + """Send \fs command for query with parameters.""" + context.cli.sendline('\\fs foo_args SELECT $1, "$2", "$3"') + + +@when('we use named query with parameters') +def step_use_named_query_with_parameters(context): + """Send \f command with parameters.""" + context.cli.sendline('\\f foo_args 101 second "third value"') + + +@then('we see the named query with parameters executed') +def step_see_named_query_with_parameters_executed(context): + """Wait to see select output.""" + wrappers.expect_exact( + context, 'SELECT 101, "second", "third value"', timeout=2) + + +@when('we use named query with too few parameters') +def step_use_named_query_with_too_few_parameters(context): + """Send \f command with missing parameters.""" + context.cli.sendline('\\f foo_args 101') + + +@then('we see the named query with parameters fail with missing parameters') +def step_see_named_query_with_parameters_fail_with_missing_parameters(context): + """Wait to see select output.""" + wrappers.expect_exact( + context, 'missing substitution for $2 in query:', timeout=2) + + +@when('we use named query with too many parameters') +def step_use_named_query_with_too_many_parameters(context): + """Send \f command with extra parameters.""" + context.cli.sendline('\\f foo_args 101 102 103 104') + + +@then('we see the named query with parameters fail with extra parameters') +def step_see_named_query_with_parameters_fail_with_extra_parameters(context): + """Wait to see select output.""" + wrappers.expect_exact( + context, 'query does not have substitution parameter $4:', timeout=2) diff --git a/test/features/steps/specials.py b/test/features/steps/specials.py new file mode 100644 index 0000000..e8b99e3 --- /dev/null +++ b/test/features/steps/specials.py @@ -0,0 +1,27 @@ +"""Steps for behavioral style tests are defined in this module. + +Each step is defined by the string decorating it. This string is used +to call the step in "*.feature" file. + +""" + +import wrappers +from behave import when, then + + +@when('we refresh completions') +def step_refresh_completions(context): + """Send refresh command.""" + context.cli.sendline('rehash') + + +@then('we see text "{text}"') +def step_see_text(context, text): + """Wait to see given text message.""" + wrappers.expect_exact(context, text, timeout=2) + +@then('we see completions refresh started') +def step_see_refresh_started(context): + """Wait to see refresh output.""" + wrappers.expect_exact( + context, 'Auto-completion refresh started in the background.', timeout=2) diff --git a/test/features/steps/utils.py b/test/features/steps/utils.py new file mode 100644 index 0000000..1ae63d2 --- /dev/null +++ b/test/features/steps/utils.py @@ -0,0 +1,12 @@ +import shlex + + +def parse_cli_args_to_dict(cli_args: str): + args_dict = {} + for arg in shlex.split(cli_args): + if '=' in arg: + key, value = arg.split('=') + args_dict[key] = value + else: + args_dict[arg] = None + return args_dict diff --git a/test/features/steps/wrappers.py b/test/features/steps/wrappers.py new file mode 100644 index 0000000..6408f23 --- /dev/null +++ b/test/features/steps/wrappers.py @@ -0,0 +1,117 @@ +import re +import pexpect +import sys +import textwrap + + +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + + +def expect_exact(context, expected, timeout): + timedout = False + try: + context.cli.expect_exact(expected, timeout=timeout) + except pexpect.TIMEOUT: + timedout = True + if timedout: + # Strip color codes out of the output. + actual = re.sub(r'\x1b\[([0-9A-Za-z;?])+[m|K]?', + '', context.cli.before) + raise Exception( + textwrap.dedent('''\ + Expected: + --- + {0!r} + --- + Actual: + --- + {1!r} + --- + Full log: + --- + {2!r} + --- + ''').format( + expected, + actual, + context.logfile.getvalue() + ) + ) + + +def expect_pager(context, expected, timeout): + expect_exact(context, "{0}\r\n{1}{0}\r\n".format( + context.conf['pager_boundary'], expected), timeout=timeout) + + +def run_cli(context, run_args=None, exclude_args=None): + """Run the process using pexpect.""" + run_args = run_args or {} + rendered_args = [] + exclude_args = set(exclude_args) if exclude_args else set() + + conf = dict(**context.conf) + conf.update(run_args) + + def add_arg(name, key, value): + if name not in exclude_args: + if value is not None: + rendered_args.extend((key, value)) + else: + rendered_args.append(key) + + if conf.get('host', None): + add_arg('host', '-h', conf['host']) + if conf.get('user', None): + add_arg('user', '-u', conf['user']) + if conf.get('pass', None): + add_arg('pass', '-p', conf['pass']) + if conf.get('port', None): + add_arg('port', '-P', str(conf['port'])) + if conf.get('dbname', None): + add_arg('dbname', '-D', conf['dbname']) + if conf.get('defaults-file', None): + add_arg('defaults_file', '--defaults-file', conf['defaults-file']) + if conf.get('myclirc', None): + add_arg('myclirc', '--myclirc', conf['myclirc']) + if conf.get('login_path'): + add_arg('login_path', '--login-path', conf['login_path']) + + for arg_name, arg_value in conf.items(): + if arg_name.startswith('-'): + add_arg(arg_name, arg_name, arg_value) + + try: + cli_cmd = context.conf['cli_command'] + except KeyError: + cli_cmd = ( + '{0!s} -c "' + 'import coverage ; ' + 'coverage.process_startup(); ' + 'import mycli.main; ' + 'mycli.main.cli()' + '"' + ).format(sys.executable) + + cmd_parts = [cli_cmd] + rendered_args + cmd = ' '.join(cmd_parts) + context.cli = pexpect.spawnu(cmd, cwd=context.package_root) + context.logfile = StringIO() + context.cli.logfile = context.logfile + context.exit_sent = False + context.currentdb = context.conf['dbname'] + + +def wait_prompt(context, prompt=None): + """Make sure prompt is displayed.""" + if prompt is None: + user = context.conf['user'] + host = context.conf['host'] + dbname = context.currentdb + prompt = '{0}@{1}:{2}>'.format( + user, host, dbname), + expect_exact(context, prompt, timeout=5) + context.atprompt = True diff --git a/test/features/wrappager.py b/test/features/wrappager.py new file mode 100755 index 0000000..51d4909 --- /dev/null +++ b/test/features/wrappager.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +import sys + + +def wrappager(boundary): + print(boundary) + while 1: + buf = sys.stdin.read(2048) + if not buf: + break + sys.stdout.write(buf) + print(boundary) + + +if __name__ == "__main__": + wrappager(sys.argv[1]) |