From a9d960076df627a00beef78f93c64ccb807236f3 Mon Sep 17 00:00:00 2001
From: Alexander Turenko <alexander.turenko@tarantool.org>
Date: Wed, 28 Sep 2022 15:04:28 +0300
Subject: [PATCH] test: add a helper for testing interactive mode

A basic (and pretty useless) example:

```lua
local it = require('test.luatest_helpers.interactive_tarantool')

local child = it.new()

child:execute_command('6 * 7')
local res = child:read_response()
t.assert_equals(res, 42)

child:close()
```

The module also contains `:read_line()`, `:assert_line()` helpers for
testing output directly: for example, when we need to catch a print()
from a background fiber. It provides has constants related to terminal's
control sequences.

A real usage can be seen in a next commit.

Part of #7169

NO_DOC=no user visible changes
NO_TEST=not applicable, it is a testing helper
NO_CHANGELOG=no user visible changes
---
 .../luatest_helpers/interactive_tarantool.lua | 300 ++++++++++++++++++
 1 file changed, 300 insertions(+)
 create mode 100644 test/luatest_helpers/interactive_tarantool.lua

diff --git a/test/luatest_helpers/interactive_tarantool.lua b/test/luatest_helpers/interactive_tarantool.lua
new file mode 100644
index 0000000000..5218607a68
--- /dev/null
+++ b/test/luatest_helpers/interactive_tarantool.lua
@@ -0,0 +1,300 @@
+-- A set of helpers to ease testing of tarantool's interactive
+-- mode.
+
+local fun = require('fun')
+local fiber = require('fiber')
+local log = require('log')
+local yaml = require('yaml')
+local popen = require('popen')
+
+-- Default timeout for expecting an input on child's stdout.
+--
+-- Testing code shouldn't just hang, it rather should raise a
+-- meaningful error for debugging.
+local TIMEOUT = 5
+
+local M = {}
+
+local mt = {}
+mt.__index = mt
+
+-- {{{ Instance methods
+
+function mt._start_stderr_logger(self)
+    local f = fiber.create(function()
+        local fiber_name = "child's stderr logger"
+        fiber.name(fiber_name, {truncate = true})
+
+        while true do
+            local chunk, err = self.ph:read({stderr = true})
+            if chunk == nil then
+                log.warn(('%s: got error, exitting: %s'):format(
+                    fiber_name, tostring(err)))
+                break
+            end
+            if chunk == '' then
+                log.info(('%s: got EOF, exitting'):format(fiber_name))
+                break
+            end
+            for _, line in ipairs(chunk:rstrip('\n'):split('\n')) do
+                log.warn(('%s: %s'):format(fiber_name, line))
+            end
+        end
+    end)
+    self._stderr_logger = f
+end
+
+function mt._stop_stderr_logger(self)
+    self._stderr_logger:cancel()
+    self._stderr_logger = nil
+end
+
+function mt.read_chunk(self, opts)
+    local opts = opts or {}
+    local deadline = opts.deadline or (fiber.clock() + TIMEOUT)
+
+    local chunk, err = self.ph:read({timeout = TIMEOUT})
+    if chunk == nil then
+        error(err)
+    end
+    if chunk == '' then
+        error('Unexpected EOF')
+    end
+    if fiber.clock() > deadline then
+        error('Timed out')
+    end
+    self._readahead_buffer = self._readahead_buffer .. chunk
+    log.info(("child's stdout logger: %s"):format(M.escape_control(chunk)))
+end
+
+function mt.read_line(self, opts)
+    local opts = opts or {}
+    local deadline = opts.deadline or (fiber.clock() + TIMEOUT)
+
+    while not self._readahead_buffer:find('\n') do
+        self:read_chunk({deadline = deadline})
+    end
+    local line, new_buffer = unpack(self._readahead_buffer:split('\n', 1))
+    self._readahead_buffer = new_buffer
+    return line
+end
+
+-- Returns all readed lines (including expected one).
+function mt.read_until_line(self, exp_line, opts)
+    local opts = opts or {}
+    local deadline = opts.deadline or (fiber.clock() + TIMEOUT)
+
+    local res = {}
+
+    repeat
+        local line = self:read_line({deadline = deadline})
+        table.insert(res, line)
+    until line == exp_line
+
+    return res
+end
+
+function mt.assert_line(self, exp_line, opts)
+    local opts = opts or {}
+    local deadline = opts.deadline or (fiber.clock() + TIMEOUT)
+
+    local line = self:read_line({deadline = deadline})
+    if line ~= exp_line then
+        error(('Unexpected line %q, expected %q'):format(line, exp_line))
+    end
+end
+
+function mt.assert_data(self, exp_data, opts)
+    local opts = opts or {}
+    local deadline = opts.deadline or (fiber.clock() + TIMEOUT)
+
+    while #self._readahead_buffer < #exp_data do
+        self:read_chunk({deadline = deadline})
+    end
+    local data = self._readahead_buffer:sub(1, #exp_data)
+    local new_buffer = self._readahead_buffer:sub(#exp_data + 1)
+    self._readahead_buffer = new_buffer
+    if data ~= exp_data then
+        error(('Unexpected data %q, expected %q'):format(data, exp_data))
+    end
+end
+
+-- ReadLine echoes commands to stdout. It is easier to match the
+-- echo against the original command, when the command is a
+-- one-liner. Let's replace newlines with spaces.
+--
+-- Add a line feed at the end to send the command for execution.
+local function _prepare_command_for_write(command)
+    return command:rstrip('\n'):gsub('\n', ' ') .. '\n'
+end
+
+-- ReadLine determines terminal's ability to wrap long lines and
+-- may perform the wrapping on its own. It adds carriage return,
+-- spaces and may duplicate some characters.
+--
+-- Observed behavior is different on different readline version:
+--
+-- * ReadLine 8: lean on terminal's wrapping.
+-- * ReadLine 7: x<CR><duplicate x>, extra spaces.
+-- * ReadLine 6: <space><CR>.
+--
+-- This function drop duplicates that goes in row, strips <CR> and
+-- spaces. Applying of this function to a source command and
+-- readline's echoed command allows to compare them for equality.
+local function _prepare_command_for_compare(x)
+    x = x:gsub(' ', ''):gsub(M.CR, '')
+    local acc = fun.iter(x):reduce(function(acc, c)
+        if acc[#acc] ~= c then
+            table.insert(acc, c)
+        end
+        return acc
+    end, {})
+    return table.concat(acc)
+end
+
+function mt._assert_command_echo(self, prepared_command, opts)
+    local opts = opts or {}
+    local deadline = opts.deadline or (fiber.clock() + TIMEOUT)
+
+    local prompt = 'tarantool> '
+    local exp_echo = prompt .. prepared_command:rstrip('\n')
+    local echo = self:read_line({deadline = deadline})
+
+    -- If readline wraps the line, prepare the commands for
+    -- compare.
+    local comment = ''
+    if echo:find(M.CR) ~= nil then
+        exp_echo = _prepare_command_for_compare(exp_echo)
+        echo = _prepare_command_for_compare(echo)
+        comment = ' (the commands are mangled for the comparison)'
+    end
+
+    if echo ~= exp_echo then
+        error(('Unexpected command echo %q, expected %q%s'):format(
+            echo, exp_echo, comment))
+    end
+end
+
+function mt.execute_command(self, command, opts)
+    local opts = opts or {}
+    local deadline = opts.deadline or (fiber.clock() + TIMEOUT)
+
+    local prepared_command = _prepare_command_for_write(command)
+    self.ph:write(prepared_command)
+    self:_assert_command_echo(prepared_command, {deadline = deadline})
+end
+
+-- Ignores output before yaml start document marks, because
+-- print() output may appear before it.
+function mt.read_response(self, opts)
+    local opts = opts or {}
+    local deadline = opts.deadline or (fiber.clock() + TIMEOUT)
+
+    self:read_until_line('---', {deadline = deadline})
+    local lines = self:read_until_line('...', {deadline = deadline})
+    self:assert_line('', {deadline = deadline})
+
+    -- Handle empty response.
+    if #lines == 1 then
+        return
+    end
+
+    local raw_reply = '---\n' .. table.concat(lines, '\n') .. '\n'
+    local reply = yaml.decode(raw_reply)
+    return unpack(reply, 1, table.maxn(reply))
+end
+
+function mt.assert_empty_response(self, opts)
+    local reply = {self:read_response(opts)}
+    if table.maxn(reply) ~= 0 then
+        error(('Unexpected non-empty response:\n%s'):format(yaml.encode(reply)))
+    end
+end
+
+function mt.close(self)
+    self:_stop_stderr_logger()
+    self.ph:close()
+end
+
+-- }}} Instance methods
+
+-- {{{ Module functions
+
+function M.escape_control(str)
+    return str
+        :gsub(M.TAB, '<TAB>')
+        :gsub(M.LF, '<LF>')
+        :gsub(M.CR, '<CR>')
+        :gsub(M.ESC, '<ESC>')
+end
+
+function M.new(opts)
+    local opts = opts or {}
+    local args = opts.args or {}
+
+    local tarantool_exe = arg[-1]
+    local ph = popen.new(fun.chain({tarantool_exe, '-i'}, args):totable(), {
+        stdin = popen.opts.PIPE,
+        stdout = popen.opts.PIPE,
+        stderr = popen.opts.PIPE,
+        env = {
+            -- Don't know why, but without defined TERM environment
+            -- variable readline doesn't accept INPUTRC environment
+            -- variable.
+            TERM = 'xterm',
+            -- Prevent system/user inputrc configuration file from
+            -- influence testing code. In particular, if
+            -- horizontal-scroll-mode is enabled,
+            -- _assert_command_echo() on a long command will fail
+            -- (because the command in the echo output will be
+            -- trimmed).
+            INPUTRC = '/dev/null',
+        },
+    })
+
+    local res = setmetatable({
+        ph = ph,
+        _readahead_buffer = '',
+    }, mt)
+
+    -- Log child's stderr.
+    res:_start_stderr_logger()
+
+    -- Write a command and ignore the echoed output.
+    --
+    -- ReadLine 6 writes <ESC>[?1034h at beginning, it may hit
+    -- assertions on the child's output.
+    --
+    -- This sequence of charcters is smm ('set meta mode')
+    -- terminal capacity value. It has relation to writing
+    -- characters out of the ASCII range -- ones with 8th bit set,
+    -- but its description is vague. See terminfo(5).
+    res.ph:write("'Hello!'\n")
+    res:read_line()
+    assert(res:read_response() == 'Hello!')
+
+    -- Disable stdout line buffering in the child.
+    res:execute_command("io.stdout:setvbuf('no')")
+    assert(res:read_response(), true)
+
+    return res
+end
+
+-- }}} Module functions
+
+-- {{{ Module constants
+
+-- It may be different for remote console, but the module supports
+-- only local console for now.
+M.PROMPT = 'tarantool> '
+
+M.TAB = '\x09'
+M.LF = '\x0a'
+M.CR = '\x0d'
+M.ESC = '\x1b'
+
+M.ERASE_IN_LINE = M.ESC .. '[K'
+
+-- }}} Module constants
+
+return M
-- 
GitLab