diff --git a/.github/workflows/package_test.yml b/.github/workflows/package_test.yml new file mode 100644 index 00000000..0ee5b12c --- /dev/null +++ b/.github/workflows/package_test.yml @@ -0,0 +1,52 @@ +name: Package test + +on: + push: + pull_request: + +jobs: + package-test: + if: github.repository == 'tarantool/luatest' && ( + github.event_name == 'push' || ( github.event_name == 'pull_request' && + github.repository_owner != 'tarantool' ) ) + + strategy: + fail-fast: false + matrix: + include: + - dist: ubuntu + version: jammy + - dist: fedora + version: 36 + + runs-on: ubuntu-latest + + steps: + - name: Check out repo + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Check out packpack + uses: actions/checkout@v3 + with: + repository: packpack/packpack + path: packpack + + - name: Set package version + run: | + GIT_TAG=$(git tag --points-at HEAD) + GIT_DESCRIBE=$(git describe HEAD) + if [ -n "${GIT_TAG}" ]; then + echo "VERSION=${GIT_TAG}" >> $GITHUB_ENV + else + echo "VERSION=$(echo ${GIT_DESCRIBE} | sed ${SED_REPLACE_VERSION_REGEX}).dev" >> $GITHUB_ENV + fi + env: + SED_REPLACE_VERSION_REGEX: s/-\([0-9]\+\)-g[0-9a-f]\+$/.\1/ + + - name: Run packaging + run: ./packpack/packpack + env: + OS: ${{ matrix.dist }} + DIST: ${{ matrix.version }} diff --git a/.github/workflows/push_rockspec.yaml b/.github/workflows/push_rockspec.yaml index a6f1a67b..a0c977b1 100644 --- a/.github/workflows/push_rockspec.yaml +++ b/.github/workflows/push_rockspec.yaml @@ -28,9 +28,9 @@ jobs: steps: - uses: actions/checkout@master - - uses: tarantool/setup-tarantool@v1 + - uses: tarantool/setup-tarantool@v3 with: - tarantool-version: '2.5' + tarantool-version: '2.11' # https://stackoverflow.com/questions/58177786/get-the-current-pushed-tag-in-github-actions - name: Set env diff --git a/.github/workflows/test_on_push.yaml b/.github/workflows/test_on_push.yaml index 0e8ae158..607b0a0a 100644 --- a/.github/workflows/test_on_push.yaml +++ b/.github/workflows/test_on_push.yaml @@ -11,26 +11,31 @@ jobs: github.event_name == 'pull_request' && github.event.pull_request.head.repo.owner.login != 'tarantool' strategy: matrix: - tarantool: ["1.10", "2.6", "2.7", "2.8"] + tarantool: ["2.11", "3.0", "3.1", "3.2", "3.3", "3.4"] fail-fast: false - runs-on: [ubuntu-20.04] + runs-on: [ubuntu-latest] steps: - uses: actions/checkout@master - - uses: tarantool/setup-tarantool@v1 + - uses: tarantool/setup-tarantool@v3 with: tarantool-version: '${{ matrix.tarantool }}' + - name: Install tt utility + run: | + curl -L https://tarantool.io/release/2/installer.sh | bash + sudo apt-get -y install tt + - name: Install requirements for community run: | - cmake . - make bootstrap + cmake -S . -B build + make -C build bootstrap # This server starts and listen on 8084 port that is used for tests - name: Stop Mono server run: sudo kill -9 $(sudo lsof -t -i tcp:8084) || true - name: Run linter - run: make lint + run: make -C build lint - - name: Run tests - run: bin/luatest -v + - name: Run tests with coverage + run: make -C build selftest-coverage diff --git a/.luacheckrc b/.luacheckrc index a9d0616c..7c9517a8 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -1,4 +1,4 @@ include_files = {"**/*.lua", "*.rockspec", "*.luacheckrc"} -exclude_files = {"lua_modules/", ".luarocks/", ".rocks/", "tmp/"} +exclude_files = {"build.luarocks/", "lua_modules/", "tmp/", ".luarocks/", ".rocks/"} max_line_length = 120 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 382412b3..00000000 --- a/.travis.yml +++ /dev/null @@ -1,109 +0,0 @@ -os: linux -dist: xenial -language: shell -services: - - docker - -env: - global: - - ROCK_NAME=luatest - - PRODUCT=luatest - -stages: - - test - - name: deploy - if: (type = push) AND (branch = master OR tag IS present) - -before_script: - - git describe --long - -_test: &test - before_install: - - curl -L https://tarantool.io/installer.sh | VER=$TARANTOOL_VERSION sudo -E bash - - sudo apt install tarantool-dev - script: - - cmake . - - make lint test_with_coverage_report - -_deploy_to_packagecloud: &deploy_to_packagecloud - provider: packagecloud - username: tarantool - token: $PACKAGECLOUD_TOKEN - dist: $OS/$DIST - package_glob: build/*.{rpm,deb} - skip_cleanup: true - on: - tags: true - -# Pack and deploy packages to PackageCloud -_packpack: &packpack - stage: deploy - # Build packages only at `pr` stage, skip at `push` stage - if: branch = master - script: - - git clone https://github.com/packpack/packpack.git - - packpack/packpack - - ls -l build/ - deploy: - - <<: *deploy_to_packagecloud - repository: '1_10' - - <<: *deploy_to_packagecloud - repository: '2x' - - <<: *deploy_to_packagecloud - repository: '2_2' - - <<: *deploy_to_packagecloud - repository: '2_3' - -jobs: - include: - - <<: *test - env: TARANTOOL_VERSION=1.10 - - <<: *test - env: TARANTOOL_VERSION=2.3 - - stage: deploy - name: Publish rockspecs - script: skip - deploy: - - provider: script - script: curl --fail -X PUT -F rockspec=@$ROCK_NAME-scm-1.rockspec - https://$ROCKS_USERNAME:$ROCKS_PASSWORD@rocks.tarantool.org - - on: - tags: true - all_branches: true - provider: script - script: cat $ROCK_NAME-scm-1.rockspec | - sed -E - -e "s/branch = '.+'/tag = '$TRAVIS_TAG'/g" - -e "s/version = '.+'/version = '$TRAVIS_TAG-1'/g" | - curl --fail -X PUT -F "rockspec=@-;filename=$ROCK_NAME-$TRAVIS_TAG-1.rockspec" - https://$ROCKS_USERNAME:$ROCKS_PASSWORD@rocks.tarantool.org - - - <<: *packpack - env: OS=el DIST=7 - - <<: *packpack - env: OS=el DIST=8 - stage: test # test rpm packaging - - <<: *packpack - env: OS=fedora DIST=30 - - <<: *packpack - env: OS=ubuntu DIST=trusty - - <<: *packpack - env: OS=ubuntu DIST=xenial - - <<: *packpack - env: OS=ubuntu DIST=bionic - stage: test # test deb packaging - - <<: *packpack - env: OS=ubuntu DIST=eoan - - <<: *packpack - env: OS=debian DIST=jessie - - <<: *packpack - env: OS=debian DIST=stretch - - <<: *packpack - env: OS=debian DIST=buster - -notifications: - email: - recipients: - - build@tarantool.org - on_success: change - on_failure: always diff --git a/CHANGELOG.md b/CHANGELOG.md index e0632ffb..f8467435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,65 @@ ## Unreleased -- Add new module `replica_set.lua`. +- Added the ability to keep and adjust cluster declarative configuration + with `cluster:modify_config()` and apply it later via + `cluster:apply_config_changes()` without passing an explicit + configuration (gh-426). +- Added an option to create `Cluster` objects without global hook management, + allowing tests to keep clusters alive between test runs (gh-414). +- Fixed a bug where URI search would terminate prematurely when multiple + replicasets existed (gh-427). + +## 1.2.1 + +- Fixed a bug when `Server:grep_log()` didn't consider the `reset` option. + +## 1.2.0 + +- Fixed a bug when `server:grep_log()` failed to find a string logged in + `server:exec()` called immediately before it (gh-421). +- Fixed a bug when it wasn't possible to reload the cluster config with + `cluster:reload()` after removing an instance with `cluster:sync()`. + Also added an option to `cluster:sync()` to start/stop added/removed + instances (gh-423). + +## 1.1.0 + +- Added logging to unified file (gh-324). +- Added memory leak detection during server process execution (gh-349). +- Added `assert_error_covers`. +- Added more logs (gh-326). +- Added `justrun` helper as a tarantool runner and output catcher (gh-365). +- Changed error message for too long Unix domain socket paths (gh-341). +- Added `cbuilder` helper as a declarative configuration builder (gh-366). +- Made `assert_error_*` additionally check error trace if required. +- Added `--list-test-cases` and `--run-test-case` CLI options. +- Introduced preloaded hooks (gh-380). +- Added `treegen` helper as a tree generator (gh-364). +- Added support for declarative configuration to `server.lua` (gh-367). +- Made `assert_covers` recursive (gh-379). +- Added alias `--no-capture` for the option `-c` (gh-391). +- Fixed reporting of an assertion failure in `Server:exec()` in case verbose + error serialization is enabled in Tarantool (gh-376). +- Added `assert_items_exclude`. +- Stripped useless `...` lines from error trace. +- Fixed error trace reporting for functions executed with `Server:exec()` + (gh-396). +- Removed pretty-printing of `luatest.log` arguments. +- Added `cluster` helper as a tool for managing a Tarantool cluster (gh-368). +- Fixed `Server:grep_log()` to work with a server instance started using + the `cluster` helper (gh-389). +- Fixed `Server:grep_log()` to work with a stopped server instance (gh-397). + +## 1.0.1 + +- Fixed incorrect Unix domain socket path length check (gh-341). +- Now `net_box_uri` can be accepted as a table (gh-342). +- Fixed returning values from `Server:exec()` if some of them are nil (gh-350). +- Introduce `luatest.log` helper (gh-326). + +## 1.0.0 + - Extend `server.lua` API: * Update parameters of the `Server:new()` function: - The `alias` parameter defaults to 'server'. @@ -14,7 +72,6 @@ * Add waiting until the process of the stopped server is terminated. * Add new functions: - `Server.build_listen_uri()` - - `Server:clean()` - `Server:drop()` - `Server:wait_until_ready()` - `Server:get_instance_id()` @@ -36,15 +93,24 @@ - `Server:wait_for_vclock_of()` - `Server:update_box_cfg()` - `Server:get_box_cfg()` -- Check docs generation with LDoc. -- Add `--repeat-group` (`-R`) option to run tests in a circle within the group. -- Forbid negative values for `--repeat` (`-r`) option. -- Change `coverage_report` parameter type to boolean in `Server:new()` function. -- Print Tarantool version used by luatest. - Add new module `replica_proxy.lua`. +- Add new module `replica_set.lua`. - Add new module `tarantool.lua`. -- Auto-require `luatest` module in `Server:exec()` function where it is available - via the corresponding upvalue. +- Check docs generation with LDoc. +- Add the `--repeat-group` (`-R`) option to run tests in a circle within the + group. +- Forbid negative values for the `--repeat` (`-r`) option. +- Change the `coverage_report` parameter type to boolean in the `Server:new()` + function. +- Print Tarantool version used by luatest. +- Auto-require the `luatest` module in the `Server:exec()` function where it is + available via the corresponding upvalue. +- Raise an error when non-array arguments passed to the `Server:exec()` + function. +- Save server artifacts (logs, snapshots, etc.) to the `${VARDIR}/artifacts` + directory if the test fails. +- Fix requiring the internal test helper when running tests. +- Fix collecting coverage if the tarantool binary has a suffix. ## 0.5.7 diff --git a/CMakeLists.txt b/CMakeLists.txt index 736db937..67287688 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 2.8 FATAL_ERROR) +cmake_minimum_required(VERSION 3.5 FATAL_ERROR) project(luatest NONE) @@ -37,38 +37,50 @@ set_property(DIRECTORY APPEND PROPERTY ADDITIONAL_MAKE_CLEAN_FILES build) set_property(DIRECTORY APPEND PROPERTY ADDITIONAL_MAKE_CLEAN_FILES .rocks) add_custom_command( - OUTPUT .rocks + OUTPUT ${PROJECT_SOURCE_DIR}/.rocks DEPENDS ${PROJECT_NAME}-scm-1.rockspec - COMMAND tarantoolctl rocks make ./${PROJECT_NAME}-scm-1.rockspec - COMMAND tarantoolctl rocks install http 1.1.0 - COMMAND tarantoolctl rocks install luacheck 0.25.0 - COMMAND tarantoolctl rocks install luacov 0.13.0 + COMMAND tt rocks make ./${PROJECT_NAME}-scm-1.rockspec + COMMAND tt rocks install http 1.1.0 + COMMAND tt rocks install luacheck 0.25.0 + COMMAND tt rocks install luacov 0.13.0 + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} ) -add_custom_target(bootstrap DEPENDS .rocks) +add_custom_target(bootstrap DEPENDS ${PROJECT_SOURCE_DIR}/.rocks) add_custom_command( - OUTPUT .rocks/bin/ldoc + OUTPUT ${PROJECT_SOURCE_DIR}/.rocks/bin/ldoc DEPENDS bootstrap - COMMAND tarantoolctl rocks install ldoc --server=http://rocks.moonscript.org + COMMAND tt rocks install ldoc --server=http://rocks.moonscript.org + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} ) set_property(DIRECTORY APPEND PROPERTY ADDITIONAL_MAKE_CLEAN_FILES doc) add_custom_target(doc - DEPENDS .rocks/bin/ldoc + DEPENDS ${PROJECT_SOURCE_DIR}/.rocks/bin/ldoc COMMAND .rocks/bin/ldoc -t ${PROJECT_NAME}-scm-1 -p ${PROJECT_NAME} --all . + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} ) -add_custom_target(lint DEPENDS bootstrap COMMAND .rocks/bin/luacheck .) -add_custom_target(selftest DEPENDS bootstrap COMMAND bin/luatest) +add_custom_target(lint + DEPENDS bootstrap + COMMAND .rocks/bin/luacheck . + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} +) +add_custom_target(selftest + DEPENDS bootstrap + COMMAND bin/luatest -v --shuffle group + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} +) -add_custom_target(test_with_coverage_report +add_custom_target(selftest-coverage DEPENDS bootstrap COMMAND rm -f tmp/luacov.*.out* - COMMAND tarantool -l luatest.coverage bin/luatest + COMMAND tarantool -l luatest.coverage bin/luatest -v --shuffle group COMMAND .rocks/bin/luacov . COMMAND echo COMMAND grep -A999 '^Summary' tmp/luacov.report.out + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} ) ## Test ####################################################################### diff --git a/README.rst b/README.rst index f16e526a..35fe5849 100644 --- a/README.rst +++ b/README.rst @@ -25,7 +25,7 @@ Installation .. code-block:: bash - tarantoolctl rocks install luatest + tt rocks install luatest .rocks/bin/luatest --help # list available options --------------------------------- @@ -156,6 +156,48 @@ To change default order use: local t = require('luatest') t.configure({shuffle = 'group'}) +--------------------------------- +Preloaded hooks +--------------------------------- +Preloaded hooks extend base hooks. They behave like the pytest fixture with the ``autouse`` parameter. + +.. code-block:: lua + -- my_helper.lua + local hooks = require('luatest.hooks') + + hooks.before_suite_preloaded(function() print('start foo') end) + hooks.after_suite_preloaded(function() print('stop foo') end) + + hooks.before_all_preloaded(function() print('start bar') end) + hooks.after_all_preloaded(function() print('stop bar') end) + + hooks.before_each_preloaded(function() print('start baz') end) + hooks.after_each_preloaded(function() print('stop baz') end) + +If you run the following test: + +.. code-block:: lua + local t = require('luatest') + local my_helper = require('my_helper') + local g = t.group() + + g.before_all(function() print('prepare') end) + g.after_all(function() print('cleanup') end) + + g.test_print = function() print('everythings is ok') end + +Then the hooks are executed in the following sequence: + +.. code-block:: text + |\ start foo + | \ start bar + | \ prepare + | \ start baz + | test_print (everythings is ok) + | / stop baz + | / cleanup + | / stop bar + |/ stop foo --------------------------------- List of luatest functions @@ -190,10 +232,16 @@ List of luatest functions +--------------------------------------------------------------------+-----------------------------------------------+ | ``assert_error_msg_matches (pattern, fn, ...)`` | | +--------------------------------------------------------------------+-----------------------------------------------+ +| ``assert_error_covers (expected, fn, ...)`` | Checks that actual error map includes expected| +| | one. | ++--------------------------------------------------------------------+-----------------------------------------------+ | ``assert_eval_to_false (value[, message])`` | Alias for assert_not. | +--------------------------------------------------------------------+-----------------------------------------------+ | ``assert_eval_to_true (value[, message])`` | Alias for assert. | +--------------------------------------------------------------------+-----------------------------------------------+ +| ``assert_items_exclude (actual, expected[, message])`` | Checks that one table does not include any | +| | items of another, irrespective of their keys. | ++--------------------------------------------------------------------+-----------------------------------------------+ | ``assert_items_include (actual, expected[, message])`` | Checks that one table includes all items of | | | another, irrespective of their keys. | +--------------------------------------------------------------------+-----------------------------------------------+ @@ -423,6 +471,39 @@ to server process. ``luatest.Process:start(path, args, env)`` provides low-level interface to run any other application. +``luatest.cluster`` runs a declarative configuration as a set of Tarantool instances. +By default clusters are registered inside the current test group and cleaned up with +preloaded hooks (``auto_cleanup = true``). When you need to reuse the same cluster across +multiple tests or keep several clusters running at once, pass ``auto_cleanup = false`` and +manage the lifecycle manually: + +.. code-block:: Lua + + local t = require('luatest') + local cluster = require('luatest.cluster') + local cbuilder = require('luatest.cbuilder') + + local g = t.group('shared') + + g.before_all(function() + local config = cbuilder:new() + :use_group('g-1') + :use_replicaset('rs-1') + :add_instance('instance-1', {}) + :config() + + g.cluster = cluster:new(config, {}, {auto_cleanup = false}) + g.cluster:start() + end) + + g.after_all(function() + g.cluster:drop() + end) + + g.test_reuses_cluster_between_cases = function() + t.assert_not_equals(g.cluster['instance-1'].process, nil) + end + There are several small helpers for common actions: .. code-block:: Lua @@ -439,7 +520,7 @@ There are several small helpers for common actions: luacov integration --------------------------------- -- Install `luacov `_ with ``tarantoolctl rocks install luacov`` +- Install `luacov `_ with ``tt rocks install luacov`` - Configure it with ``.luacov`` file - Clean old reports ``rm -f luacov.*.out*`` - Run luatest with ``--coverage`` option diff --git a/config.ld b/config.ld index 471aa4bf..77e613ad 100644 --- a/config.ld +++ b/config.ld @@ -7,6 +7,11 @@ file = { 'luatest/runner.lua', 'luatest/server.lua', 'luatest/replica_set.lua', + 'luatest/justrun.lua', + 'luatest/cbuilder.lua', + 'luatest/hooks.lua', + 'luatest/treegen.lua', + 'luatest/cluster.lua' } topics = { 'CHANGELOG.md', diff --git a/debian/changelog b/debian/changelog index ac2efe62..9a74f152 100644 --- a/debian/changelog +++ b/debian/changelog @@ -10,7 +10,7 @@ luatest (0.5.7-1) unstable; urgency=medium * Change test run summary report: use verbs in past simple tense (succeeded, failed, xfailed, etc.) instead of nouns (success(es), fail(s), xfail(s), etc.) - -- Nikolay Volynkin Fri, 28 Jan 2022 14:00:00 +0300 + -- Nikolay Volynkin Fri, 28 Jan 2022 14:00:00 +0300 luatest (0.5.6-1) unstable; urgency=medium @@ -51,8 +51,8 @@ luatest (0.5.3-1) unstable; urgency=medium luatest (0.5.2-1) unstable; urgency=medium -- Throw parser error when .json is accessed on response with invalid body. -- Set `Content-Type: application/json` for `:http_request(..., {json = ...})` requests. + * Throw parser error when .json is accessed on response with invalid body. + * Set `Content-Type: application/json` for `:http_request(..., {json = ...})` requests. -- Maxim Melentiev Thu, 25 Jun 2020 13:00:00 +0300 diff --git a/debian/compat b/debian/compat index ec635144..f599e28b 100644 --- a/debian/compat +++ b/debian/compat @@ -1 +1 @@ -9 +10 diff --git a/debian/control b/debian/control index 39dd6d13..27913183 100644 --- a/debian/control +++ b/debian/control @@ -2,11 +2,13 @@ Source: luatest Priority: optional Section: database Maintainer: Konstantin Nazarov -Build-Depends: debhelper (>= 9), cdbs, +Build-Depends: debhelper (>= 10), + cdbs (>= 0.4.100), tarantool (>= 1.9.0), tarantool-dev (>= 1.9.0), - tarantool-checks (>= 3.0.1) -Standards-Version: 3.9.6 + tarantool-checks (>= 3.0.1), + tt (>= 2.2.1) +Standards-Version: 4.5.1 Homepage: https://github.com/tarantool/luatest Vcs-Git: git://github.com/tarantool/luatest.git Vcs-Browser: https://github.com/tarantool/luatest diff --git a/debian/docs b/debian/docs index b43bf86b..a1320b1b 100644 --- a/debian/docs +++ b/debian/docs @@ -1 +1 @@ -README.md +README.rst diff --git a/debian/prebuild.sh b/debian/prebuild.sh index 479bf20a..e4778e40 100755 --- a/debian/prebuild.sh +++ b/debian/prebuild.sh @@ -1 +1 @@ -curl -s https://packagecloud.io/install/repositories/tarantool/1_10/script.deb.sh | sudo bash +curl -L https://tarantool.io/release/${LUATEST_TARANTOOL_SERIES:-2}/installer.sh | bash diff --git a/debian/rules b/debian/rules index 0db7e509..201fa870 100755 --- a/debian/rules +++ b/debian/rules @@ -6,5 +6,7 @@ VERSION := $(shell echo $(DEB_VERSION) | sed 's/-[[:digit:]]\+$$//') DEB_CMAKE_EXTRA_FLAGS := -DCMAKE_INSTALL_LIBDIR=lib/$(DEB_HOST_MULTIARCH) \ -DCMAKE_BUILD_TYPE=RelWithDebInfo -DVERSION=$(VERSION) +DEB_MAKE_CHECK_TARGET := selftest + include /usr/share/cdbs/1/rules/debhelper.mk include /usr/share/cdbs/1/class/cmake.mk diff --git a/luatest/VERSION.lua b/luatest/VERSION.lua index ea6eec62..704eeb86 100644 --- a/luatest/VERSION.lua +++ b/luatest/VERSION.lua @@ -1 +1 @@ -return '0.5.7' +return '1.2.1' diff --git a/luatest/assertions.lua b/luatest/assertions.lua index 1e183936..fb5e73dc 100644 --- a/luatest/assertions.lua +++ b/luatest/assertions.lua @@ -8,7 +8,11 @@ local math = require('math') local comparator = require('luatest.comparator') local mismatch_formatter = require('luatest.mismatch_formatter') local pp = require('luatest.pp') +local log = require('luatest.log') local utils = require('luatest.utils') +local tarantool = require('tarantool') +local fio = require('fio') +local ffi = require('ffi') local prettystr = pp.tostring local prettystr_pairs = pp.tostring_pair @@ -17,6 +21,8 @@ local M = {} local xfail = false +local box_error_type = ffi.typeof(box.error.new(box.error.UNKNOWN)) + -- private exported functions (for testing) M.private = {} @@ -84,12 +90,101 @@ local function error_msg_equality(actual, expected, deep_analysis) end M.private.error_msg_equality = error_msg_equality +-- +-- The wrapper is used when trace check is required. See pcall_check_trace. +-- +-- Without wrapper the trace will point to the pcall implementation. So trace +-- check is not strict enough (the trace can point to any pcall in below in +-- call trace). +-- +local trace_line = debug.getinfo(1, 'l').currentline + 2 +local function wrapped_call(fn, ...) + local res = utils.table_pack(fn(...)) + -- With `return fn(...)` wrapper does not work due to tail call + -- optimization. + return unpack(res, 1, res.n) +end + +-- Expected trace for trace check. See pcall_check_trace. +local wrapped_trace = { + file = debug.getinfo(1, 'S').short_src, + line = trace_line, +} + +-- Used in tests to force check for given module. +M.private.check_trace_module = nil + +-- +-- Return true if error trace check is required for function. Basically it is +-- just a wrapper around Tarantool's utils.proper_trace_required. Additionally +-- old Tarantool versions where this function is not present are handled. +-- +local function trace_check_is_required(fn) + local src = debug.getinfo(fn, 'S').short_src + if M.private.check_trace_module == src then + return true + end + if tarantool._internal ~= nil and + tarantool._internal.trace_check_is_required ~= nil then + local path = debug.getinfo(fn, 'S').short_src + return tarantool._internal.trace_check_is_required(path) + end + return false +end + +-- +-- Substitute for pcall but additionally checks error trace if required. +-- +-- The error should be box.error and trace should point to the place +-- where fn is called. +-- +-- level is used to set proper level in error assertions that use this function. +-- +local function pcall_check_trace(level, fn, ...) + local fn_explicit = fn + if type(fn) ~= 'function' then + fn_explicit = debug.getmetatable(fn).__call + end + if not trace_check_is_required(fn_explicit) then + return pcall(fn, ...) + end + local ok, err = pcall(wrapped_call, fn, ...) + if ok then + return ok, err + end + if type(err) ~= 'cdata' or ffi.typeof(err) ~= box_error_type then + fail_fmt(level + 1, nil, 'Error raised is not a box.error: %s', + prettystr(err)) + end + local unpacked = err:unpack() + if not comparator.equals(unpacked.trace[1], wrapped_trace) then + fail_fmt(level + 1, nil, + 'Unexpected error trace, expected: %s, actual: %s', + prettystr(wrapped_trace), prettystr(unpacked.trace[1])) + end + return ok, err +end + +-- +-- Wrapper around pcall that: +-- * checks the stack trace for box errors; +-- * unwraps the error if it was wrapped by Server:exec(). +-- +local function pcall_wrapper(level, fn, ...) + local ok, err = pcall_check_trace(level + 1, fn, ...) + if not ok and type(err) == 'table' and + err.class == 'LuatestErrorWrapper' then + err = err.error + end + return ok, err +end + --- Check that calling fn raises an error. -- -- @func fn -- @param ... arguments for function function M.assert_error(fn, ...) - local ok, err = pcall(fn, ...) + local ok, err = pcall_wrapper(2, fn, ...) if ok then failure("Expected an error when calling function but no error generated", nil, 2) end @@ -329,15 +424,37 @@ function M.assert_items_include(actual, expected, message) end end -local function table_covers(actual, expected) +--- Checks that one table does not include any items of another, irrespective of their keys. +-- +-- @param actual +-- @param expected +-- @string[opt] message +function M.assert_items_exclude(actual, expected, message) if type(actual) ~= 'table' or type(expected) ~= 'table' then - failure('Argument 1 and 2 must be tables', nil, 3) + failure('Argument 1 and 2 must be tables', nil, 2) + end + if not comparator.are_disjoint(expected, actual) then + expected, actual = prettystr_pairs(expected, actual) + fail_fmt(2, message, 'Expected no item values from: %s\nTo be present in: %s', expected, actual) + end +end + +local function table_slice(actual, expected) + if type(expected) ~= 'table' or type(actual) ~= 'table' then + return actual end local sliced = {} for k, _ in pairs(expected) do - sliced[k] = actual[k] + sliced[k] = table_slice(actual[k], expected[k]) end - return comparator.equals(sliced, expected) + return sliced +end + +local function table_covers(actual, expected) + if type(actual) ~= 'table' or type(expected) ~= 'table' then + failure('Argument 1 and 2 must be tables', nil, 3) + end + return comparator.equals(table_slice(actual, expected), expected) end --- Checks that actual map includes expected one. @@ -459,7 +576,7 @@ function M.assert_str_matches(value, pattern, start, final, message) end local function _assert_error_msg_equals(stripFileAndLine, expectedMsg, func, ...) - local no_error, error_msg = pcall(func, ...) + local no_error, error_msg = pcall_wrapper(3, func, ...) if no_error then local failure_message = string.format( 'Function successfully returned: %s\nExpected error: %s', @@ -472,7 +589,8 @@ local function _assert_error_msg_equals(stripFileAndLine, expectedMsg, func, ... end local differ = false if stripFileAndLine then - if error_msg:gsub("^.+:%d+: ", "") ~= expectedMsg then + error_msg = error_msg:gsub("^.+:%d+: ", "") + if error_msg ~= expectedMsg then differ = true end else @@ -524,7 +642,7 @@ end -- @func fn -- @param ... arguments for function function M.assert_error_msg_contains(expected_partial, fn, ...) - local no_error, error_msg = pcall(fn, ...) + local no_error, error_msg = pcall_wrapper(2, fn, ...) if no_error then local failure_message = string.format( 'Function successfully returned: %s\nExpected error containing: %s', @@ -546,7 +664,7 @@ end -- @func fn -- @param ... arguments for function function M.assert_error_msg_matches(pattern, fn, ...) - local no_error, error_msg = pcall(fn, ...) + local no_error, error_msg = pcall_wrapper(2, fn, ...) if no_error then local failure_message = string.format( 'Function successfully returned: %s\nExpected error matching: %s', @@ -562,6 +680,44 @@ function M.assert_error_msg_matches(pattern, fn, ...) end end +-- If it is box.error that unpack it recursively. If it is not then +-- return argument unchanged. +local function error_unpack(err) + if type(err) ~= 'cdata' or ffi.typeof(err) ~= box_error_type then + return err + end + local unpacked = err:unpack() + local tmp = unpacked + while tmp.prev ~= nil do + tmp.prev = tmp.prev:unpack() + tmp = tmp.prev + end + return unpacked +end + +--- Checks that error raised by function is table that includes expected one. +--- box.error is unpacked to convert to table. Stacked errors are supported. +--- That is if there is prev field in expected then it should cover prev field +--- in actual and so on recursively. +-- +-- @tab expected +-- @func fn +-- @param ... arguments for function +function M.assert_error_covers(expected, fn, ...) + local ok, actual = pcall_wrapper(2, fn, ...) + if ok then + fail_fmt(2, nil, + 'Function successfully returned: %s\nExpected error: %s', + prettystr(actual), prettystr(expected)) + end + local unpacked = error_unpack(actual) + if not comparator.equals(table_slice(unpacked, expected), expected) then + actual, expected = prettystr_pairs(unpacked, expected) + fail_fmt(2, nil, 'Error expected: %s\nError received: %s', + expected, actual) + end +end + --- Alias for @{assert}. -- -- @param value @@ -731,4 +887,16 @@ function M.assert_not_minus_zero(value, message) end end +-- Log all checked assertions for debugging. +for func_name, func in pairs(M) do + if func_name:startswith('assert') and type(func) == 'function' then + M[func_name] = function(...) + local info = debug.getinfo(2, 'Sl') + log.info('Checking assertion at %s:%s', + fio.basename(info.short_src), info.currentline) + return func(...) + end + end +end + return M diff --git a/luatest/cbuilder.lua b/luatest/cbuilder.lua new file mode 100644 index 00000000..df0512b8 --- /dev/null +++ b/luatest/cbuilder.lua @@ -0,0 +1,235 @@ +--- Configuration builder. +-- +-- It allows to construct a declarative configuration for a test case using +-- less boilerplace code/options, especially when a replicaset is to be +-- tested, not a single instance. All the methods support chaining (return +-- the builder object back). +-- +-- @usage +-- +-- local config = Builder:new() +-- :add_instance('instance-001', { +-- database = { +-- mode = 'rw', +-- }, +-- }) +-- :add_instance('instance-002', {}) +-- :add_instance('instance-003', {}) +-- :config() +-- +-- By default, all instances are added to replicaset-001 in group-001, +-- but it's possible to select a different replicaset and/or group: +-- +-- local config = Builder:new() +-- :use_group('group-001') +-- :use_replicaset('replicaset-001') +-- :add_instance(<...>) +-- :add_instance(<...>) +-- :add_instance(<...>) +-- +-- :use_group('group-002') +-- :use_replicaset('replicaset-002') +-- :add_instance(<...>) +-- :add_instance(<...>) +-- :add_instance(<...>) +-- +-- :config() +-- +-- The default credentials and iproto options are added to +-- setup replication and to allow a test to connect to the +-- instances. +-- +-- There is a few other methods: +-- +-- * :set_replicaset_option('foo.bar', value) +-- * :set_instance_option('instance-001', 'foo.bar', value) +-- +-- @classmod luatest.cbuilder + +local checks = require('checks') +local fun = require('fun') + +local Builder = require('luatest.class').new() + +-- Do a post-reqiure of the `internal.config.cluster_config`, +-- since it is available from version 3.0.0+. Otherwise we +-- will get an error when initializing the module in `luatest.init`. +local cluster_config = {} + +local base_config = { + credentials = { + users = { + replicator = { + password = 'secret', + roles = {'replication'}, + }, + client = { + password = 'secret', + roles = {'super'}, + }, + }, + }, + iproto = { + listen = {{ + uri = 'unix/:./{{ instance_name }}.iproto' + }}, + advertise = { + peer = { + login = 'replicator', + } + }, + }, + replication = { + -- The default value is 1 second. It is good for a real + -- usage, but often suboptimal for testing purposes. + -- + -- If an instance can't connect to another instance (say, + -- because it is not started yet), it retries the attempt + -- after so called 'replication interval', which is equal + -- to replication timeout. + -- + -- One second waiting means one more second for a test + -- case and, if there are many test cases with a + -- replicaset construction, it affects the test timing a + -- lot. + -- + -- replication.timeout = 0.1 second reduces the timing + -- by half for my test. + timeout = 0.1, + }, +} + +function Builder:inherit(object) + setmetatable(object, self) + self.__index = self + return object +end + +--- Build a config builder object. +-- +-- @tab[opt] config Table with declarative configuration. +function Builder:new(config) + checks('table', '?table') + cluster_config = require('internal.config.cluster_config') + + config = table.deepcopy(config or base_config) + self._config = config + self._group = 'group-001' + self._replicaset = 'replicaset-001' + return self +end + +--- Select a group for following calls. +-- +-- @string group_name Group of replicas. +function Builder:use_group(group_name) + checks('table', 'string') + self._group = group_name + return self +end + +--- Select a replicaset for following calls. +-- +-- @string replicaset_name Replica set name. +function Builder:use_replicaset(replicaset_name) + checks('table', 'string') + self._replicaset = replicaset_name + return self +end + +--- Set option to the cluster config. +-- +-- @string path Option path. +-- @param value Option value (int, string, table). +function Builder:set_global_option(path, value) + checks('table', 'string', '?') + cluster_config:set(self._config, path, value) + return self +end + +--- Set an option for the selected group. +-- +-- @string path Option path. +-- @param value Option value (int, string, table). +function Builder:set_group_option(path, value) + checks('table', 'string', '?') + path = fun.chain({ + 'groups', self._group, + }, path:split('.')):totable() + + cluster_config:set(self._config, path, value) + return self +end + +--- Set an option for the selected replicaset. +-- +-- @string path Option path. +-- @param value Option value (int, string, table). +function Builder:set_replicaset_option(path, value) + checks('table', 'string', '?') + path = fun.chain({ + 'groups', self._group, + 'replicasets', self._replicaset, + }, path:split('.')):totable() + + -- :set() validation is too tight. Workaround + -- it. Maybe we should reconsider this :set() behavior in a + -- future. + if value == nil then + local cur = self._config + for i = 1, #path - 1 do + -- Create missed fields. + local component = path[i] + if cur[component] == nil then + cur[component] = {} + end + + cur = cur[component] + end + cur[path[#path]] = value + return self + end + + cluster_config:set(self._config, path, value) + return self +end + +-- Set an option of a particular instance in the selected replicaset. +-- +-- @string instance_name Instance where the option will be saved. +-- @string path Option path. +-- @param value Option value (int, string, table). +function Builder:set_instance_option(instance_name, path, value) + checks('table', 'string', 'string', '?') + path = fun.chain({ + 'groups', self._group, + 'replicasets', self._replicaset, + 'instances', instance_name, + }, path:split('.')):totable() + + cluster_config:set(self._config, path, value) + return self +end + +--- Add an instance with the given options to the selected replicaset. +-- +-- @string instance_name Instance where the config will be saved. +-- @tab iconfig Declarative config for the instance. +function Builder:add_instance(instance_name, iconfig) + checks('table', 'string', '?') + local path = { + 'groups', self._group, + 'replicasets', self._replicaset, + 'instances', instance_name, + } + cluster_config:set(self._config, path, iconfig) + return self +end + +--- Return the resulting configuration. +-- +function Builder:config() + return self._config +end + +return Builder diff --git a/luatest/cluster.lua b/luatest/cluster.lua new file mode 100644 index 00000000..b1e22135 --- /dev/null +++ b/luatest/cluster.lua @@ -0,0 +1,494 @@ +--- Tarantool 3.0+ cluster management utils. +-- +-- The helper is used to automatically collect a set of +-- instances from the provided configuration and automatically +-- set up servers per each configured instance. +-- +-- @usage +-- +-- local cluster = Cluster:new(config) +-- cluster:start() +-- cluster['instance-001']:exec(<...>) +-- cluster:each(function(server) +-- server:exec(<...>) +-- end) +-- +-- After setting up a cluster object the following methods could +-- be used to interact with it: +-- +-- * :start() Startup the cluster. +-- * :start_instance() Startup a specific instance. +-- * :stop() Stop the cluster. +-- * :each() Execute a function on each instance. +-- * :size() get an amount of instances +-- * :drop() Drop the cluster. +-- * :sync() Sync the configuration and collect a new set of +-- instances +-- * :reload() Reload the configuration. +-- * :config() Return the last applied configuration. +-- * :modify_config() Initialize a configuration builder based on +-- the current config and store it inside the cluster object. +-- * :apply_config_changes() Apply the configuration built via +-- :modify_config() by passing it to :sync(). +-- +-- The module can also be used for testing failure startup +-- cases: +-- +-- Cluster:startup_error(config, error_message) +-- +-- @module luatest.cluster + +local fun = require('fun') +local yaml = require('yaml') +local assertions = require('luatest.assertions') +local cbuilder = require('luatest.cbuilder') +local helpers = require('luatest.helpers') +local hooks = require('luatest.hooks') +local treegen = require('luatest.treegen') +local justrun = require('luatest.justrun') +local server = require('luatest.server') + +local Cluster = require('luatest.class').new() + +-- Cluster uses custom __index implementation to support +-- getting instances from it using `cluster['i-001']`. +-- +-- The metamethod is set on the instance metatable so that multiple +-- cluster objects can co-exist without clobbering shared state on the +-- class table. +local mt = Cluster.mt +mt.__index = function(self, k) + local method = Cluster[k] + if method ~= nil then + return method + end + + local server_map = rawget(self, '_server_map') + if server_map ~= nil and server_map[k] ~= nil then + return server_map[k] + end + + return rawget(self, k) +end + +local cluster = { + _group = {} +} + +function Cluster:inherit(object) + setmetatable(object, self) + self.__index = self + return object +end + +local function init(g) + cluster._group = g +end + +-- Stop all the managed instances using :drop(). +local function drop(g) + if g._cluster ~= nil then + g._cluster:drop() + end + g._cluster = nil +end + +local function clean(g) + assert(g._cluster == nil) +end + +-- {{{ Helpers + +-- Start all instances in the list. +-- +-- @tab[opt] opts Options. +-- @bool[opt] opts.wait_until_ready Wait until servers are ready +-- (default: true). +-- @bool[opt] opts.wait_until_running Wait until servers are running +-- (default: wait_until_ready). +local function start_instances(servers, opts) + for _, iserver in ipairs(servers) do + iserver:start({wait_until_ready = false}) + end + + -- wait_until_ready is true by default. + local wait_until_ready = true + if opts ~= nil and opts.wait_until_ready ~= nil then + wait_until_ready = opts.wait_until_ready + end + + if wait_until_ready then + for _, iserver in ipairs(servers) do + iserver:wait_until_ready() + end + end + + -- wait_until_running is equal to wait_until_ready by default. + local wait_until_running = wait_until_ready + if opts ~= nil and opts.wait_until_running ~= nil then + wait_until_running = opts.wait_until_running + end + + if wait_until_running then + for _, iserver in ipairs(servers) do + helpers.retrying({timeout = 60}, function() + assertions.assert_equals(iserver:eval('return box.info.status'), + 'running') + end) + end + end +end + +-- Collect names of all the instances defined in the config +-- in the alphabetical order. +local function instance_names_from_config(config) + local instance_names = {} + for _, group in pairs(config.groups or {}) do + for _, replicaset in pairs(group.replicasets or {}) do + for name, _ in pairs(replicaset.instances or {}) do + table.insert(instance_names, name) + end + end + end + table.sort(instance_names) + return instance_names +end + + +local function assert_no_pending_config_builder(self, method_name) + assert(self._config_builder == nil, + (':modify_config() was called; apply configuration changes with ' .. + ':apply_config_changes() before calling :%s'):format(method_name)) +end + +-- }}} Helpers + +-- {{{ Cluster management + +--- Execute for server in the cluster. +-- +-- @func f Function to execute with a server as the first param. +function Cluster:each(f) + fun.iter(self._servers):each(function(iserver) + f(iserver) + end) +end + +--- Get cluster size. +-- +-- @return number. +function Cluster:size() + return #self._servers +end + +--- Start all the instances. +-- +-- @tab[opt] opts Cluster startup options. +-- @bool[opt] opts.wait_until_ready Wait until servers are ready +-- (default: true). +-- @bool[opt] opts.wait_until_running Wait until servers are running +-- (default: wait_until_ready). +function Cluster:start(opts) + start_instances(self._servers, opts) +end + +--- Start the given instance. +-- +-- @string instance_name Instance name. +function Cluster:start_instance(instance_name) + local iserver = self._server_map[instance_name] + assert(iserver ~= nil) + iserver:start() +end + +--- Stop the whole cluster. +function Cluster:stop() + for _, iserver in ipairs(self._servers or {}) do + iserver:stop() + end +end + +--- Drop the cluster's servers. +function Cluster:drop() + for _, iserver in ipairs(self._servers or {}) do + iserver:drop() + end + for _, iserver in ipairs(self._expelled_servers or {}) do + iserver:drop() + end + self._servers = nil + self._server_map = nil + self._expelled_servers = nil +end + +--- Sync the cluster object with the new config. +-- +-- It performs the following actions. +-- +-- * Write the new config into the config file. +-- * Update the internal list of instances. +-- * Optionally starts instances added to the config and stops instances +-- removed from the config. +-- +-- @tab config New config. +-- @tab[opt] opts Options. +-- @bool[opt] opts.start_stop Start/stop added/removed servers +-- (default: false). +-- @bool[opt] opts.wait_until_ready Wait until servers are ready +-- (default: true; used only if start_stop is set). +-- @bool[opt] opts.wait_until_running Wait until servers are running +-- (default: wait_until_ready; used only if start_stop is set). +function Cluster:sync(config, opts) + assert_no_pending_config_builder(self, 'sync()') + assert(type(config) == 'table') + + local instance_names = instance_names_from_config(config) + + treegen.write_file(self._dir, self._config_file_rel, yaml.encode(config)) + + self._config = config + local server_map = self._server_map + self._server_map = {} + self._servers = {} + local new_servers = {} + + for _, name in ipairs(instance_names) do + local iserver = server_map[name] + if iserver == nil then + iserver = server:new(fun.chain(self._server_opts, { + alias = name, + }):tomap()) + table.insert(new_servers, iserver) + else + server_map[name] = nil + end + self._server_map[name] = iserver + table.insert(self._servers, iserver) + end + + local expelled_servers = {} + for _, iserver in pairs(server_map) do + table.insert(expelled_servers, iserver) + end + + -- Sort expelled servers by name for reproducibility. + table.sort(expelled_servers, function(a, b) return a.alias < b.alias end) + + -- Add expelled servers to the list to be dropped with the cluster. + for _, iserver in pairs(expelled_servers) do + table.insert(self._expelled_servers, iserver) + end + + local start_stop = false + if opts ~= nil and opts.start_stop ~= nil then + start_stop = opts.start_stop + end + + if start_stop then + start_instances(new_servers, opts) + for _, iserver in ipairs(expelled_servers) do + iserver:stop() + end + end +end + +--- Apply configuration changes built via :modify_config(). +-- +-- Uses the internal configuration builder created by :modify_config(), +-- converts it to a config table and calls :sync() with it. +-- After the call the stored builder is cleared. +-- +-- @tab[opt] opts Options. +-- @bool[opt] opts.start_stop Start/stop added/removed servers +-- (default: false). +-- @bool[opt] opts.wait_until_ready Wait until servers are ready +-- (default: true; used only if start_stop is set). +-- @bool[opt] opts.wait_until_running Wait until servers are running +-- (default: wait_until_ready; used only if start_stop is set). +function Cluster:apply_config_changes(opts) + assert(self._config_builder ~= nil, + ':modify_config() must be called before :apply_config_changes()') + + local config = self._config_builder:config() + self._config_builder = nil + + return self:sync(config, opts) +end + +--- Reload configuration on all the instances. +-- +-- @tab[opt] config New config. +function Cluster:reload(config) + assert_no_pending_config_builder(self, 'reload()') + assert(config == nil or type(config) == 'table') + + -- Rewrite the configuration file if a new config is provided. + if config ~= nil then + treegen.write_file(self._dir, self._config_file_rel, + yaml.encode(config)) + end + + -- Reload config on all the instances. + self:each(function(iserver) + -- Assume that all the instances are started. + -- + -- This requirement may be relaxed if needed, it is just + -- for simplicity. + assert(iserver.process ~= nil) + + iserver:exec(function() + local cfg = require('config') + + cfg:reload() + end) + end) +end + +--- Create a new Tarantool cluster. +-- +-- @tab config Cluster configuration. +-- @tab[opt] server_opts Extra options passed to server:new(). +-- @tab[opt] opts Cluster options. +-- @string[opt] opts.dir Specific directory for the cluster. +-- @bool[opt] opts.auto_cleanup Register the cluster in a test group and +-- automatically drop it using hooks (default: true). +-- @return table +function Cluster:new(config, server_opts, opts) + assert(type(config) == 'table') + assert(config._config == nil, "Please provide cbuilder:new():config()") + + opts = opts or {} + local auto_cleanup = opts.auto_cleanup + + if auto_cleanup == nil then + auto_cleanup = true + end + + assert(type(auto_cleanup) == 'boolean') + + local g + if auto_cleanup then + g = cluster._group + assert(g._cluster == nil) + end + + self._config = table.deepcopy(config) + self._config_builder = nil + + -- Prepare a temporary directory and write a configuration + -- file. + local dir = opts.dir or treegen.prepare_directory({}, {}) + local config_file_rel = 'config.yaml' + local config_file = treegen.write_file(dir, config_file_rel, + yaml.encode(self._config)) + + -- Collect names of all the instances defined in the config + -- in the alphabetical order. + local instance_names = instance_names_from_config(config) + + assert(next(instance_names) ~= nil, 'No instances in the supplied config') + + -- Generate luatest server options. + server_opts = fun.chain({ + config_file = config_file, + chdir = dir, + net_box_credentials = { + user = 'client', + password = 'secret', + }, + }, server_opts or {}):tomap() + + -- Create luatest server objects. + local servers = {} + local server_map = {} + for _, name in ipairs(instance_names) do + local iserver = server:new(fun.chain(server_opts, { + alias = name, + }):tomap()) + table.insert(servers, iserver) + server_map[name] = iserver + end + + local object = self:from({ + _servers = servers, + _server_map = server_map, + _expelled_servers = {}, + _dir = dir, + _config_file_rel = config_file_rel, + _server_opts = server_opts, + }) + + if auto_cleanup then + g._cluster = object + end + + return object +end + +--- Return the last applied configuration. +function Cluster:config() + assert_no_pending_config_builder(self, 'config()') + + return table.deepcopy(self._config) +end + +--- Initialize a configuration builder based on the current config. +-- +-- The returned builder is stored inside the cluster object and later +-- consumed by :apply_config_changes(), which turns it into a config +-- table and passes it to :sync(). +function Cluster:modify_config() + assert(self._config_builder == nil, + ':modify_config() already called and changes were not applied') + + self._config_builder = cbuilder:new(self:config()) + return self._config_builder +end + +-- }}} Replicaset management + +-- {{{ Replicaset that can't start + +--- Ensure cluster startup error +-- +-- Starts a all instance of a cluster from the given config and +-- ensure that all the instances fails to start and reports the +-- given error message. +-- +-- @tab config Cluster configuration. +-- @string exp_err Expected error message. +function Cluster:startup_error(config, exp_err) + -- Stub for the linter, since self is unused though + -- we need to be consistent with Cluster:new() + assert(self) + assert(type(config) == 'table') + assert(config._config == nil, "Please provide cbuilder:new():config()") + -- Prepare a temporary directory and write a configuration + -- file. + local dir = treegen.prepare_directory({}, {}) + local config_file_rel = 'config.yaml' + local config_file = treegen.write_file(dir, config_file_rel, + yaml.encode(config)) + + -- Collect names of all the instances defined in the config + -- in the alphabetical order. + local instance_names = instance_names_from_config(config) + + for _, name in ipairs(instance_names) do + local env = {} + local args = {'--name', name, '--config', config_file} + local opts = {nojson = true, stderr = true} + local res = justrun.tarantool(dir, env, args, opts) + + assertions.assert_equals(res.exit_code, 1) + assertions.assert_str_contains(res.stderr, exp_err) + end +end + +-- }}} Replicaset that can't start + +hooks.before_all_preloaded(init) +hooks.after_each_preloaded(drop) +hooks.after_all_preloaded(clean) + +return Cluster diff --git a/luatest/comparator.lua b/luatest/comparator.lua index 3377b456..699eee3a 100644 --- a/luatest/comparator.lua +++ b/luatest/comparator.lua @@ -70,6 +70,23 @@ function comparator.is_subset(actual, expected) return #expected_array - found_count end +-- Returns false if 'actual' or 'expected' are not tables or their value sets +-- intersect. Returns true otherwise. +function comparator.are_disjoint(actual, expected) + if (type(actual) ~= 'table') or (type(expected) ~= 'table') then + return false + end + + for _, a in pairs(actual) do + for _, b in pairs(expected) do + if comparator.equals(a, b) then + return false + end + end + end + return true +end + -- This is a specialized metatable to help with the bookkeeping of recursions -- in table_equals(). It provides an __index table that implements utility -- functions for easier management of the table. The "cached" method queries diff --git a/luatest/group.lua b/luatest/group.lua index eaa37635..ec53c8e8 100644 --- a/luatest/group.lua +++ b/luatest/group.lua @@ -61,7 +61,7 @@ function Group.mt:initialize(name) error('Group name must not contain `/`: ' .. name) end self.name = name - hooks.define_group_hooks(self) + hooks._define_group_hooks(self) end return Group diff --git a/luatest/helpers.lua b/luatest/helpers.lua index ea9bfc03..e9d61796 100644 --- a/luatest/helpers.lua +++ b/luatest/helpers.lua @@ -4,7 +4,8 @@ local clock = require('clock') local fiber = require('fiber') -local log = require('log') +local fio = require('fio') +local log = require('luatest.log') local helpers = {} @@ -69,8 +70,10 @@ function helpers.retrying(config, fn, ...) if (clock.time() - started_at) > timeout then return fn(...) end - log.debug('Retrying in ' .. delay .. ' sec. due to error:') - log.debug(result) + local info = debug.getinfo(2, 'Sl') + log.info('Retrying at %s:%s in %0.3f sec due to error:', + fio.basename(info.short_src), info.currentline, delay) + log.info(result) fiber.sleep(delay) end end diff --git a/luatest/hooks.lua b/luatest/hooks.lua index 9e28a455..985bf4e7 100644 --- a/luatest/hooks.lua +++ b/luatest/hooks.lua @@ -1,8 +1,88 @@ +--- Provide extra methods for hooks. +-- +-- Preloaded hooks extend base hooks. +-- They behave like the pytest fixture with the `autouse` parameter. +-- +-- @usage +-- +-- local hooks = require('luatest.hooks') +-- +-- hooks.before_suite_preloaded(...) +-- hooks.after_suite_preloaded(...) +-- +-- hooks.before_all_preloaded(...) +-- hooks.after_all_preloaded(...) +-- +-- hooks.before_each_preloaded(...) +-- hooks.after_each_preloaded(...) +-- +-- @module luatest.hooks + +local log = require('luatest.log') local utils = require('luatest.utils') local comparator = require('luatest.comparator') local export = {} +local preloaded_hooks = { + before_suite = {}, + after_suite = {}, + + before_all = {}, + after_all = {}, + + before_each = {}, + after_each = {} +} + +--- Register preloaded before hook in the `suite` scope. +-- It will be done before the classic before_suite() hook in the tests. +-- +-- @func fn The function where you will be preparing for the test. +function export.before_suite_preloaded(fn) + table.insert(preloaded_hooks.before_suite, {fn, {}}) +end + +--- Register preloaded after hook in the `suite` scope. +-- It will be done after the classic after_suite() hook in the tests. +-- +-- @func fn The function where you will be cleaning up for the test. +function export.after_suite_preloaded(fn) + table.insert(preloaded_hooks.after_suite, {fn, {}}) +end + +--- Register preloaded before hook in the `all` scope. +-- It will be done before the classic before_all() hook in the tests. +-- +-- @func fn The function where you will be preparing for the test. +function export.before_all_preloaded(fn) + table.insert(preloaded_hooks.before_all, {fn, {}}) +end + +--- Register preloaded after hook in the `all` scope. +-- It will be done after the classic after_all() hook in the tests. +-- +-- @func fn The function where you will be cleaning up for the test. +function export.after_all_preloaded(fn) + table.insert(preloaded_hooks.after_all, {fn, {}}) +end + +--- Register preloaded before hook in the `each` scope. +-- It will be done before the classic before_each() hook in the tests. +-- +-- @func fn The function where you will be preparing for the test. +function export.before_each_preloaded(fn) + table.insert(preloaded_hooks.before_each, {fn, {}}) +end + +--- Register preloaded after hook in the `each` scope. +-- It will be done after the classic after_each() hook in the tests. +-- +-- @func fn The function where you will be cleaning up for the test. +function export.after_each_preloaded(fn) + table.insert(preloaded_hooks.after_each, {fn, {}}) +end + local function check_params(required, actual) for param_name, param_val in pairs(required) do if not comparator.equals(param_val, actual[param_name]) then @@ -13,7 +93,7 @@ local function check_params(required, actual) return true end -local function define_hooks(object, hooks_type) +local function define_hooks(object, hooks_type, preloaded_hook) local hooks = {} object[hooks_type .. '_hooks'] = hooks @@ -34,13 +114,45 @@ local function define_hooks(object, hooks_type) end object['_original_' .. hooks_type] = object[hooks_type] -- for leagacy hooks support + local function run_preloaded_hooks() + if preloaded_hook == nil then + return + end + + -- before_* -- direct order + -- after_* -- reverse order + local from = 1 + local to = #preloaded_hook + local step = 1 + if hooks_type:startswith('after_') then + from, to = to, from + step = -step + end + + for i = from, to, step do + local hook = preloaded_hook[i] + if check_params(hook[2], object.params) then + hook[1](object) + end + end + end + object['run_' .. hooks_type] = function() + -- before_* -- run before test hooks + if hooks_type:startswith('before_') then + run_preloaded_hooks() + end + local active_hooks = object[hooks_type .. '_hooks'] for _, hook in ipairs(active_hooks) do if check_params(hook[2], object.params) then hook[1](object) end end + -- after_* -- run after test hooks + if hooks_type:startswith('after_') then + run_preloaded_hooks() + end end end @@ -96,11 +208,11 @@ local function define_named_hooks(object, hooks_type) end -- Define hooks on group. -function export.define_group_hooks(group) - define_hooks(group, 'before_each') - define_hooks(group, 'after_each') - define_hooks(group, 'before_all') - define_hooks(group, 'after_all') +function export._define_group_hooks(group) + define_hooks(group, 'before_each', preloaded_hooks.before_each) + define_hooks(group, 'after_each', preloaded_hooks.after_each) + define_hooks(group, 'before_all', preloaded_hooks.before_all) + define_hooks(group, 'after_all', preloaded_hooks.after_all) define_named_hooks(group, 'before_test') define_named_hooks(group, 'after_test') @@ -108,9 +220,9 @@ function export.define_group_hooks(group) end -- Define suite hooks on luatest. -function export.define_suite_hooks(luatest) - define_hooks(luatest, 'before_suite') - define_hooks(luatest, 'after_suite') +function export._define_suite_hooks(luatest) + define_hooks(luatest, 'before_suite', preloaded_hooks.before_suite) + define_hooks(luatest, 'after_suite', preloaded_hooks.after_suite) end local function run_group_hooks(runner, group, hooks_type) @@ -129,6 +241,7 @@ local function run_group_hooks(runner, group, hooks_type) end local function run_test_hooks(self, test, hooks_type, legacy_name) + log.info('Run hook %s', hooks_type) local group = test.group local hook -- Support for group.setup/teardown methods (legacy API) @@ -143,6 +256,7 @@ local function run_test_hooks(self, test, hooks_type, legacy_name) end local function run_named_test_hooks(self, test, hooks_type) + log.info('Run hook %s', hooks_type) local group = test.group local hook = group['run_' .. hooks_type] if hook then @@ -150,7 +264,7 @@ local function run_named_test_hooks(self, test, hooks_type) end end -function export.patch_runner(Runner) +function export._patch_runner(Runner) -- Last run test to set error for when group.after_all hook fails. local last_test = nil @@ -172,7 +286,9 @@ function export.patch_runner(Runner) run_named_test_hooks(self, test, 'before_test') if test:is('success') then + log.info('Start test %s', test.name) super(self, test, ...) + log.info('End test %s', test.name) end run_named_test_hooks(self, test, 'after_test') diff --git a/luatest/init.lua b/luatest/init.lua index 25b5aae8..e3b2071f 100644 --- a/luatest/init.lua +++ b/luatest/init.lua @@ -34,6 +34,25 @@ local Group = require('luatest.group') local hooks = require('luatest.hooks') local parametrizer = require('luatest.parametrizer') +--- Add syntax sugar for logging. +-- +luatest.log = require('luatest.log') + +--- Simple Tarantool runner and output catcher. +-- +-- @see luatest.justrun +luatest.justrun = require('luatest.justrun') + +--- Declarative configuration builder helper. +-- +-- @see luatest.cbuilder +luatest.cbuilder = require('luatest.cbuilder') + +--- Tarantool cluster management utils. +-- +-- @see luatest.cluster +luatest.cluster = require('luatest.cluster') + --- Add before suite hook. -- -- @function before_suite @@ -43,7 +62,12 @@ local parametrizer = require('luatest.parametrizer') -- -- @function after_suite -- @func fn -hooks.define_suite_hooks(luatest) +hooks._define_suite_hooks(luatest) + +--- Add extra hooks methods. +-- +-- @see luatest.hooks +luatest.hooks = hooks luatest.groups = {} diff --git a/luatest/justrun.lua b/luatest/justrun.lua new file mode 100644 index 00000000..d21ef8d9 --- /dev/null +++ b/luatest/justrun.lua @@ -0,0 +1,172 @@ +--- Simple Tarantool runner and output catcher. +-- +-- Sometimes it is necessary to run tarantool with particular arguments and +-- verify its output. `luatest.server` provides a supervisor like +-- interface: an instance is started, calls box.cfg() and we can +-- communicate with it using net.box. Another helper in tarantool/tarantool, +-- `test.interactive_tarantool`, aims to solve all the problems around +-- readline console and also provides ability to communicate with the +-- instance interactively. +-- +-- However, there is nothing like 'just run tarantool with given args and +-- give me its output'. +-- +-- @module luatest.justrun + +local checks = require('checks') +local fun = require('fun') +local json = require('json') +local fiber = require('fiber') + +local log = require('luatest.log') + +local justrun = {} + +local function collect_stderr(ph) + local f = fiber.new(function() + local fiber_name = "child's stderr collector" + fiber.name(fiber_name, {truncate = true}) + + local chunks = {} + + while true do + local chunk, err = ph:read({stderr = true}) + if chunk == nil then + log.warn('%s: got error, exiting: %s', fiber_name, err) + break + end + if chunk == '' then + log.info('%s: got EOF, exiting', fiber_name) + break + end + table.insert(chunks, chunk) + end + + -- Glue all chunks, strip trailing newline. + return table.concat(chunks):rstrip() + end) + f:set_joinable(true) + return f +end + +local function cancel_stderr_fiber(stderr_fiber) + if stderr_fiber == nil then + return + end + stderr_fiber:cancel() +end + +local function join_stderr_fiber(stderr_fiber) + if stderr_fiber == nil then + return + end + return select(2, assert(stderr_fiber:join())) +end + +--- Run tarantool in given directory with given environment and +-- command line arguments and catch its output. +-- +-- Expects JSON lines as the output and parses it into an array +-- (it can be disabled using `nojson` option). +-- +-- Options: +-- +-- - nojson (boolean, default: false) +-- +-- Don't attempt to decode stdout as a stream of JSON lines, +-- return as is. +-- +-- - stderr (boolean, default: false) +-- +-- Collect stderr and place it into the `stderr` field of the +-- return value +-- +-- - quote_args (boolean, default: false) +-- +-- Quote CLI arguments before concatenating them into a shell +-- command. +-- +-- @string dir Directory where the process will run. +-- @tparam table env Environment variables for the process. +-- @tparam table args Options that will be passed when the process starts. +-- @tparam[opt] table opts Custom options: nojson, stderr and quote_args. +-- @treturn table +function justrun.tarantool(dir, env, args, opts) + checks('string', 'table', 'table', '?table') + opts = opts or {} + + local popen = require('popen') + + -- Prevent system/user inputrc configuration file from + -- influencing testing code. + env['INPUTRC'] = '/dev/null' + + local tarantool_exe = arg[-1] + -- Use popen.shell() instead of popen.new() due to lack of + -- cwd option in popen (gh-5633). + local env_str = table.concat(fun.iter(env):map(function(k, v) + return ('%s=%q'):format(k, v) + end):totable(), ' ') + local args_str = table.concat(fun.iter(args):map(function(v) + return opts.quote_args and ('%q'):format(v) or v + end):totable(), ' ') + local command = ('cd %s && %s %s %s'):format(dir, env_str, tarantool_exe, + args_str) + log.info('Running a command: %s', command) + local mode = opts.stderr and 'rR' or 'r' + local ph = popen.shell(command, mode) + + local stderr_fiber + if opts.stderr then + stderr_fiber = collect_stderr(ph) + end + + -- Read everything until EOF. + local chunks = {} + while true do + local chunk, err = ph:read() + if chunk == nil then + cancel_stderr_fiber(stderr_fiber) + ph:close() + error(err) + end + if chunk == '' then -- EOF + break + end + table.insert(chunks, chunk) + end + + local exit_code = ph:wait().exit_code + local stderr = join_stderr_fiber(stderr_fiber) + ph:close() + + -- If an error occurs, discard the output and return only the + -- exit code. However, return stderr. + if exit_code ~= 0 then + return { + exit_code = exit_code, + stderr = stderr, + } + end + + -- Glue all chunks, strip trailing newline. + local res = table.concat(chunks):rstrip() + log.info('Command output:\n%s', res) + + -- Decode JSON object per line into array of tables (if + -- `nojson` option is not passed). + local decoded + if opts.nojson then + decoded = res + else + decoded = fun.iter(res:split('\n')):map(json.decode):totable() + end + + return { + exit_code = exit_code, + stdout = decoded, + stderr = stderr, + } +end + +return justrun diff --git a/luatest/log.lua b/luatest/log.lua new file mode 100644 index 00000000..90ee7e5f --- /dev/null +++ b/luatest/log.lua @@ -0,0 +1,103 @@ +local checks = require('checks') +local fio = require('fio') +local tarantool_log = require('log') + +local OutputBeautifier = require('luatest.output_beautifier') +local utils = require('luatest.utils') + +-- Utils for logging +local log = {} +local default_level = 'info' +local is_initialized = false + +function log.initialize(options) + checks({ + vardir = 'string', + log_file = '?string', + log_prefix = '?string', + }) + if is_initialized then + return + end + + local vardir = options.vardir + local luatest_log_prefix = options.log_prefix or 'luatest' + local luatest_log_file = fio.pathjoin(vardir, luatest_log_prefix .. '.log') + local unified_log_file = options.log_file + + fio.mktree(vardir) + + if unified_log_file then + -- Save the file descriptor as a global variable to use it in + -- the `output_beautifier` module. + local fh = fio.open(unified_log_file, {'O_CREAT', 'O_WRONLY', 'O_TRUNC'}, + tonumber('640', 8)) + rawset(_G, 'log_file', {fh = fh}) + end + + local output_beautifier = OutputBeautifier:new({ + file = luatest_log_file, + prefix = luatest_log_prefix, + }) + output_beautifier:enable() + + -- Redirect all logs to the pipe created by OutputBeautifier. + -- + -- Write through a pipe to enable non-blocking mode. Required to prevent + -- a deadlock in case a test performs a lot of checks without yielding + -- execution to the fiber processing logs (gh-416). + local log_cfg = string.format('| cat > /dev/fd/%d', + output_beautifier.pipes.stdout[1]) + + -- Logging cannot be initialized without configuring the `box` engine + -- on a version less than 2.5.1 (see more details at [1]). Otherwise, + -- this causes the `attempt to call field 'cfg' (a nil value)` error, + -- so there are the following limitations: + -- 1. There is no `luatest.log` file (but logs are still available + -- in stdout and in the `run.log` file); + -- 2. All logs from luatest are non-formatted and look like: + -- + -- luatest | My log message + -- + -- [1]: https://github.com/tarantool/tarantool/issues/689 + if utils.version_current_ge_than(2, 5, 1) then + -- Initialize logging for luatest runner. + -- The log format will be as follows: + -- YYYY-MM-DD HH:MM:SS.ZZZ [ID] main/.../luatest I> ... + require('log').cfg{ + log = log_cfg, + nonblock = true, + } + end + + is_initialized = true +end + +local function _log(level, msg, ...) + if utils.version_current_ge_than(2, 5, 1) then + return tarantool_log[level](msg, ...) + end +end + +--- Extra wrapper for `__call` function +-- An additional function that takes `table` as +-- the first argument to call table function. +local function _log_default(t, msg, ...) + return t[default_level](msg, ...) +end + +function log.info(msg, ...) + return _log('info', msg, ...) +end + +function log.warn(msg, ...) + return _log('warn', msg, ...) +end + +function log.error(msg, ...) + return _log('error', msg, ...) +end + +setmetatable(log, {__call = _log_default}) + +return log diff --git a/luatest/output/junit.lua b/luatest/output/junit.lua index a121744e..90e26ba5 100644 --- a/luatest/output/junit.lua +++ b/luatest/output/junit.lua @@ -1,3 +1,4 @@ +local utils = require('luatest.utils') local ROCK_VERSION = require('luatest.VERSION') -- See directory junitxml for more information about the junit format @@ -20,16 +21,24 @@ function Output.xml_c_data_escape(str) end function Output.node_status_xml(node) + local artifacts = '' + if utils.table_len(node.servers) > 0 then + for _, server in pairs(node.servers) do + artifacts = ('%s%s -> %s'):format(artifacts, server.alias, server.artifacts) + end + end if node:is('error') then return table.concat( {' \n', - ' \n'}) + ' \n', + ' ', Output.xml_escape(artifacts), '\n', + ' \n'}) elseif node:is('fail') then return table.concat( {' \n', - ' \n'}) + ' \n', + ' ', Output.xml_escape(artifacts), '\n', + ' \n'}) elseif node:is('skip') then return table.concat({' ', Output.xml_escape(node.message or ''),'\n'}) end diff --git a/luatest/output/tap.lua b/luatest/output/tap.lua index 2ee969dd..454db403 100644 --- a/luatest/output/tap.lua +++ b/luatest/output/tap.lua @@ -1,3 +1,4 @@ +local utils = require('luatest.utils') -- For a good reference for TAP format, check: http://testanything.org/tap-specification.html local Output = require('luatest.output.generic'):new_class() @@ -24,10 +25,16 @@ function Output.mt:update_status(node) io.stdout:write("not ok ", node.serial_number, "\t", node.name, "\n") local prefix = '# ' if self.verbosity > self.class.VERBOSITY.QUIET then - print(prefix .. node.message:gsub('\n', '\n' .. prefix)) + print(prefix .. node.message:gsub('\n', '\n' .. prefix)) end if (node:is('fail') or node:is('error')) and self.verbosity >= self.class.VERBOSITY.VERBOSE then - print(prefix .. node.trace:gsub('\n', '\n' .. prefix)) + print(prefix .. node.trace:gsub('\n', '\n' .. prefix)) + if utils.table_len(node.servers) > 0 then + print(prefix .. 'artifacts:') + for _, server in pairs(node.servers) do + print(('%s\t%s -> %s'):format(prefix, server.alias, server.artifacts)) + end + end end end diff --git a/luatest/output/text.lua b/luatest/output/text.lua index bf06dc2a..2783de20 100644 --- a/luatest/output/text.lua +++ b/luatest/output/text.lua @@ -1,3 +1,4 @@ +local utils = require('luatest.utils') local Output = require('luatest.output.generic'):new_class() Output.BOLD_CODE = '\x1B[1m' @@ -60,7 +61,12 @@ function Output.mt:display_one_failed_test(index, fail) -- luacheck: no unused print(index..") " .. fail.name .. self.class.ERROR_COLOR_CODE) print(fail.message .. self.class.RESET_TERM) print(fail.trace) - print() + if utils.table_len(fail.servers) > 0 then + print('artifacts:') + for _, server in pairs(fail.servers) do + print(('\t%s -> %s'):format(server.alias, server.artifacts)) + end + end end function Output.mt:display_errored_tests() diff --git a/luatest/output_beautifier.lua b/luatest/output_beautifier.lua index bf550b7c..9bd8523d 100644 --- a/luatest/output_beautifier.lua +++ b/luatest/output_beautifier.lua @@ -1,5 +1,6 @@ local checks = require('checks') local fiber = require('fiber') +local fio = require('fio') local fun = require('fun') local Class = require('luatest.class') @@ -57,11 +58,13 @@ end --- Build OutputBeautifier object. -- @param object -- @string object.prefix String to prefix each output line with. +-- @string[opt] object.file Path to the file to append all output too. -- @string[opt] object.color Color name for prefix. -- @string[opt] object.color_code Color code for prefix. -- @return input object. function OutputBeautifier:new(object) - checks('table', {prefix = 'string', color = '?string', color_code = '?string'}) + checks('table', {prefix = 'string', file = '?string', + color = '?string', color_code = '?string'}) return self:from(object) end @@ -98,6 +101,10 @@ function OutputBeautifier.mt:enable(options) fiber.sleep(self.class.PID_TRACKER_INTERVAL) end end) + if self.file then + self.fh = fio.open(self.file, {'O_CREAT', 'O_WRONLY', 'O_APPEND'}, + tonumber('640', 8)) + end end -- Stop fibers. @@ -110,6 +117,10 @@ function OutputBeautifier.mt:disable() end end self.fibers = nil + if self.fh then + self.fh:close() + end + self.fh = nil end -- Process all available data from fd using synchronization with monitor. @@ -139,8 +150,10 @@ end -- Every line with log level mark (` X> `) changes the color for all the following -- lines until the next one with the mark. function OutputBeautifier.mt:run(fd) - local prefix = self.color_code .. self.prefix .. ' | ' + local prefix = self.prefix .. ' | ' + local colored_prefix = self.color_code .. prefix local line_color_code = self.class.RESET_TERM + local log_file = rawget(_G, 'log_file') while fiber.testcancel() or true do self:process_fd_output(fd, function(chunks) local lines = table.concat(chunks):split('\n') @@ -148,8 +161,21 @@ function OutputBeautifier.mt:run(fd) table.remove(lines) end for _, line in pairs(lines) do - line_color_code = self:color_for_line(line) or line_color_code - io.stdout:write(table.concat({prefix, line_color_code, line, self.class.RESET_TERM, '\n'})) + if self.fh ~= nil then + self.fh:write(table.concat({prefix, line, '\n'})) + end + if log_file ~= nil then + -- Redirect all output to the log file if unified logging + -- is enabled. + log_file.fh:write(table.concat({prefix, line, '\n'})) + else + line_color_code = self:color_for_line(line) or line_color_code + io.stdout:write( + table.concat( + {colored_prefix, line_color_code, line, self.class.RESET_TERM,'\n'} + ) + ) + end fiber.yield() end end) diff --git a/luatest/process.lua b/luatest/process.lua index 9d447b32..5b9d932c 100644 --- a/luatest/process.lua +++ b/luatest/process.lua @@ -3,8 +3,8 @@ local errno = require('errno') local fun = require('fun') local ffi = require('ffi') local fio = require('fio') -local log = require('log') +local log = require('luatest.log') local Class = require('luatest.class') local OutputBeautifier = require('luatest.output_beautifier') @@ -36,6 +36,7 @@ function Process:start(path, args, env, options) chdir = '?string', ignore_gc = '?boolean', output_prefix = '?string', + output_file = '?string', }) args = args and table.copy(args) or {} env = env or {} @@ -43,9 +44,13 @@ function Process:start(path, args, env, options) table.insert(args, 1, path) - local output_beautifier = options.output_prefix and OutputBeautifier:new({ - prefix = options.output_prefix, - }) + local output_beautifier + if options.output_prefix or options.output_file then + output_beautifier = OutputBeautifier:new({ + prefix = options.output_prefix, + file = options.output_file, + }) + end local env_list = fun.iter(env):map(function(k, v) return k .. '=' .. v end):totable() local pid = ffi.C.fork() @@ -77,7 +82,7 @@ function Process.mt:initialize() self._pid_ull = ffi.cast('void*', 0ULL + self.pid) ffi.gc(self._pid_ull, function(x) local pid = tonumber(ffi.cast(ffi.typeof(0ULL), x)) - log.debug("Killing GC'ed process " .. pid) + log.info("Killing GC'ed process %d", pid) Process.kill_pid(pid, nil, {quiet = true}) end) end diff --git a/luatest/replica_conn.lua b/luatest/replica_conn.lua index 61db93a8..3e8658c9 100644 --- a/luatest/replica_conn.lua +++ b/luatest/replica_conn.lua @@ -37,7 +37,7 @@ function Connection:initialize() self.process_client = { pre = nil, func = self.forward_to_server, - post = self.close_client_socket, + post = self.stop, } end @@ -45,7 +45,7 @@ function Connection:initialize() self.process_server = { pre = nil, func = self.forward_to_client, - post = self.close_server_socket, + post = self.stop, } end @@ -71,7 +71,7 @@ function Connection:process_socket(sock, process) local f = fiber.new(function() if process.pre ~= nil then process.pre(self) end - while sock:peer() do + while sock:peer() or not self.running do if not self.running then fiber.sleep(TIMEOUT) elseif sock:readable(TIMEOUT) then diff --git a/luatest/replica_proxy.lua b/luatest/replica_proxy.lua index db7a0fca..9ca22503 100644 --- a/luatest/replica_proxy.lua +++ b/luatest/replica_proxy.lua @@ -4,9 +4,12 @@ local checks = require('checks') local fiber = require('fiber') -local log = require('log') +local fio = require('fio') local socket = require('socket') +local uri = require('uri') +local log = require('luatest.log') +local utils = require('luatest.utils') local Connection = require('luatest.replica_conn') local TIMEOUT = 0.001 @@ -27,6 +30,14 @@ function Proxy:inherit(object) return object end +local function check_tarantool_version() + if utils.version_current_ge_than(2, 10, 1) then + return + else + error('Proxy requires Tarantool 2.10.1 and newer') + end +end + --- Build a proxy object. -- -- @param object @@ -37,6 +48,7 @@ end -- @return Input object. function Proxy:new(object) checks('table', self.constructor_checks) + check_tarantool_version() self:inherit(object) object:initialize() return object @@ -87,6 +99,8 @@ function Proxy:start(opts) os.remove(self.client_socket_path) end + fio.mktree(fio.dirname(uri.parse(self.client_socket_path).service)) + if not self.client_socket:bind('unix/', self.client_socket_path) then log.error("Failed to bind client socket: %s", self.client_socket:error()) return false diff --git a/luatest/replica_set.lua b/luatest/replica_set.lua index aae30fe1..6b1716f6 100644 --- a/luatest/replica_set.lua +++ b/luatest/replica_set.lua @@ -3,10 +3,12 @@ -- @classmod luatest.replica_set local checks = require('checks') -local log = require('log') +local fio = require('fio') local helpers = require('luatest.helpers') local Server = require('luatest.server') +local log = require('luatest.log') +local utils = require('luatest.utils') local ReplicaSet = {} @@ -56,6 +58,11 @@ end -- Initialize the replica set object. function ReplicaSet:initialize() self._server = Server + + self.alias = 'rs' + self.id = ('%s-%s'):format(self.alias, utils.generate_id()) + self.workdir = fio.pathjoin(self._server.vardir, self.id) + if self.servers then local configs = table.deepcopy(self.servers) self.servers = {} @@ -65,6 +72,7 @@ function ReplicaSet:initialize() else self.servers = {} end + log.info('Replica set %q initialized', self.id) end --- Build a server object for the replica set. @@ -75,14 +83,19 @@ end function ReplicaSet:build_server(config) checks('table', self._server.constructor_checks) if config then config = table.deepcopy(config) end - return self._server:new(config) + return self._server:new(config, {rs_id = self.id, vardir = self.workdir}) end --- Add the server object to the replica set. +-- The added server object should be built via the `ReplicaSet:build_server` +-- function. -- -- @tab server Server object to be added to the replica set. function ReplicaSet:add_server(server) checks('table', 'table') + if not server.rs_id then + error('Server should be built via `ReplicaSet:build_server` function') + end if self:get_server(server.alias) then error(('Server with alias "%s" already exists in replica set') :format(server.alias)) @@ -135,8 +148,7 @@ function ReplicaSet:delete_server(alias) if server_index then table.remove(self.servers, server_index) else - log.warn(('Server with alias "%s" does not exist in replica set') - :format(alias)) + log.warn('Server %q does not exist in replica set %q', alias, self.id) end end @@ -149,6 +161,8 @@ end function ReplicaSet:start(opts) checks('table', {wait_until_ready = '?boolean'}) + fio.mktree(self.workdir) + for _, server in ipairs(self.servers) do if not server.process then server:start({wait_until_ready = false}) @@ -160,6 +174,7 @@ function ReplicaSet:start(opts) server:wait_until_ready() end end + log.info('All servers started in replica set %q', self.id) end --- Stop all servers in the replica set. @@ -169,19 +184,15 @@ function ReplicaSet:stop() end end ---- Clean working directories of all servers in the replica set. --- Should be invoked only for a stopped replica set. -function ReplicaSet:clean() - for _, server in ipairs(self.servers) do - server:clean() - end -end - ---- Stop all servers in the replica set and clean their working directories. +--- Stop all servers in the replica set and save their artifacts if the test fails. +-- This function should be used only at the end of the test (`after_test`, +-- `after_each`, `after_all` hooks) to terminate all server processes in +-- the replica set. Besides process termination, it saves the contents of +-- each server working directory to the `/artifacts` directory +-- for further analysis if the test fails. function ReplicaSet:drop() for _, server in ipairs(self.servers) do - server:stop() - server:clean() + server:drop() end end @@ -225,6 +236,7 @@ function ReplicaSet:wait_for_fullmesh(opts) end end end, self) + log.info('Full mesh is ready in replica set %q', self.id) end return ReplicaSet diff --git a/luatest/runner.lua b/luatest/runner.lua index 98655649..82c50ffe 100644 --- a/luatest/runner.lua +++ b/luatest/runner.lua @@ -10,8 +10,10 @@ local capturing = require('luatest.capturing') local Class = require('luatest.class') local GenericOutput = require('luatest.output.generic') local hooks = require('luatest.hooks') +local log = require('luatest.log') local loader = require('luatest.loader') local pp = require('luatest.pp') +local Server = require('luatest.server') local sorted_pairs = require('luatest.sorted_pairs') local TestInstance = require('luatest.test_instance') local utils = require('luatest.utils') @@ -55,6 +57,12 @@ function Runner.run(args, options) end options = utils.merge(options.luatest.configure(), Runner.parse_cmd_line(args), options) + log.initialize({ + vardir = Server.vardir, + log_file = options.log_file, + log_prefix = options.log_prefix, + }) + if options.help then print(Runner.USAGE) return 0 @@ -84,12 +92,15 @@ Positional arguments: Options: -h, --help: Print this help --version: Print version information - -v, --verbose: Increase verbosity + -v, --verbose: Increase output verbosity for luatest runnner -q, --quiet: Set verbosity to minimum - -c Disable capture + -c, --no-capture Disable capture -b Print full backtrace (don't remove luatest frames) -e, --error: Stop on first error -f, --failure: Stop on first failure or error + -l, --log PATH: Path to the unified log file + --runner-log-prefix NAME: + Log prefix for luatest runner, 'luatest' by default --shuffle VALUE: Set execution order: - group[:seed] - shuffle tests within group - all[:seed] - shuffle all tests @@ -107,6 +118,11 @@ Options: May be repeated to exclude several patterns Make sure you escape magic chars like +? with % --coverage: Use luacov to collect code coverage. + --no-clean: Disable the var directory (default: /tmp/t) deletion before + running tests. + --list-test-cases List all test cases. + --run-test-case CASE Run one specific test case. Unlike --pattern the name + should match verbatim. ]] function Runner.parse_cmd_line(args) @@ -142,6 +158,10 @@ function Runner.parse_cmd_line(args) if seed then result.seed = tonumber(seed) or error('Invalid seed value') end + elseif arg == '-l' or arg == '--log' then + result.log_file = tostring(next_arg()) or error('Invalid log file path') + elseif arg == '--runner-log-prefix' then + result.log_prefix = tostring(next_arg()) elseif arg == '--seed' then result.seed = tonumber(next_arg()) or error('Invalid seed value') elseif arg == '--output' or arg == '-o' then @@ -166,10 +186,16 @@ function Runner.parse_cmd_line(args) table.insert(result.tests_pattern, '!' .. next_arg()) elseif arg == '-b' then result.full_backtrace = true - elseif arg == '-c' then + elseif arg == '--no-capture' or arg == '-c' then result.enable_capture = false elseif arg == '--coverage' then result.coverage_report = true + elseif arg == '--no-clean' then + result.no_clean = true + elseif arg == '--list-test-cases' then + result.list_test_cases = true + elseif arg == '--run-test-case' then + result.run_test_case = next_arg() elseif arg:sub(1,1) == '-' then error('Unknown option: ' .. arg) elseif arg:find('/') then @@ -218,10 +244,16 @@ function Runner.is_test_name(s) return string.sub(s, 1, 4):lower() == 'test' end -function Runner.filter_tests(tests, patterns) +function Runner.filter_tests(tests, patterns, test_case) local result = {[true] = {}, [false] = {}} for _, test in ipairs(tests) do - table.insert(result[utils.pattern_filter(patterns, test.name)], test) + -- Handle --pattern CLI option. + local yesno = utils.pattern_filter(patterns, test.name) + -- Handle --run-test-case CLI option. + if test_case ~= nil then + yesno = yesno and test.name == test_case + end + table.insert(result[yesno], test) end return result end @@ -271,12 +303,31 @@ function Runner.mt:bootstrap() load_tests(path) end self.groups = self.luatest.groups + log.info('Bootstrap finished: %d test(s), %d group(s)', #self.paths, #self.groups) +end + +function Runner.mt:cleanup() + if not self.no_clean then + fio.rmtree(Server.vardir) + log.info('Directory %s removed via cleanup procedure', Server.vardir) + end end function Runner.mt:run() self:bootstrap() - local filtered_list = self.class.filter_tests(self:find_tests(), self.tests_pattern) + local filtered_list = self.class.filter_tests(self:find_tests(), + self.tests_pattern, self.run_test_case) + + -- Handle the --list-test-case CLI option. + if self.list_test_cases then + for _, test_case in ipairs(filtered_list[true]) do + print(test_case.name) + end + return 0 + end + self:start_suite(#filtered_list[true], #filtered_list[false]) + self:cleanup() self:run_tests(filtered_list[true]) self:end_suite() if self.result.aborted then @@ -326,6 +377,11 @@ function Runner.mt:update_status(node, err) elseif err.status == 'fail' or err.status == 'error' or err.status == 'skip' or err.status == 'xfail' or err.status == 'xsuccess' then node:update_status(err.status, err.message, err.trace) + if utils.table_len(node.servers) > 0 then + for _, server in pairs(node.servers) do + server:save_artifacts() + end + end else error('No such status: ' .. pp.tostring(err.status)) end @@ -368,6 +424,16 @@ function Runner.mt:protected_call(instance, method, pretty_name) end, function(e) -- transform error into a table, adding the traceback information local trace = debug.traceback('', 3):sub(2) + if type(e) == 'table' and e.class == 'LuatestErrorWrapper' then + -- This is an error wrapped by Server:exec() to save the trace. + -- Concatenate the current trace with the saved trace and restore + -- the original error. + assert(e.trace ~= nil) + assert(e.error ~= nil) + trace = e.trace .. '\n' .. + trace:sub(string.len('stack traceback:\n') + 1) + e = e.error + end if utils.is_luatest_error(e) then return {status = e.status, message = e.message, trace = trace} else @@ -413,17 +479,21 @@ end function Runner.mt:run_tests(tests_list) -- Make seed for ordering not affect other random numbers. math.randomseed(os.time()) + rawset(_G, 'current_test', {value = nil}) for _ = 1, self.exe_repeat_group or 1 do local last_group for _, test in ipairs(tests_list) do if last_group ~= test.group then if last_group then + rawget(_G, 'current_test').value = nil self:end_group(last_group) end self:start_group(test.group) last_group = test.group end + rawget(_G, 'current_test').value = test self:run_test(test) + log.info('Test %s marked as %s', test.name, test.status) if self.result.aborted then break end @@ -442,6 +512,10 @@ end function Runner.mt:invoke_test_function(test) local err = self:protected_call(test.group, test.method, test.name) + if err.status == 'fail' or err.status == 'error' then + log.error(err.message) + log.error(err.trace) + end self:update_status(test, err) end @@ -492,7 +566,7 @@ function Runner.mt:all_test_names() return result end -hooks.patch_runner(Runner) +hooks._patch_runner(Runner) capturing(Runner) return Runner diff --git a/luatest/server.lua b/luatest/server.lua index 86ed56fd..839afbc9 100644 --- a/luatest/server.lua +++ b/luatest/server.lua @@ -4,21 +4,22 @@ local checks = require('checks') local clock = require('clock') -local digest = require('digest') local errno = require('errno') local fiber = require('fiber') local fio = require('fio') local fun = require('fun') local http_client = require('http.client') local json = require('json') -local log = require('log') local net_box = require('net.box') -local uri = require('uri') +local tarantool = require('tarantool') +local urilib = require('uri') +local yaml = require('yaml') local _, luacov_runner = pcall(require, 'luacov.runner') -- luacov may not be installed local assertions = require('luatest.assertions') local HTTPResponse = require('luatest.http_response') local Process = require('luatest.process') +local log = require('luatest.log') local utils = require('luatest.utils') local DEFAULT_VARDIR = '/tmp/t' @@ -39,9 +40,12 @@ local Server = { args = '?table', box_cfg = '?table', + config_file = '?string', + remote_config = '?table', + http_port = '?number', net_box_port = '?number', - net_box_uri = '?string', + net_box_uri = '?string|table', net_box_credentials = '?table', alias = '?string', @@ -74,10 +78,10 @@ end -- Defaults to 'server'. -- @string[opt] object.workdir Working directory for the new server and the -- value of the `TARANTOOL_WORKDIR` env variable which is passed into the --- server process. +-- server process. The directory path will be created on the server start. -- Defaults to `/-`. -- @string[opt] object.datadir Directory path whose contents will be recursively --- copied into `object.workdir` during initialization. +-- copied into `object.workdir` on the server start. -- @number[opt] object.http_port Port for HTTP connection to the new server and -- the value of the `TARANTOOL_HTTP_PORT` env variable which is passed into -- the server process. @@ -87,69 +91,200 @@ end -- into the server process. -- @string[opt] object.net_box_uri URI for the `net.box` connection to the new -- server and the value of the `TARANTOOL_LISTEN` env variable which is passed --- into the server process. +-- into the server process. If it is a Unix socket, the corresponding socket +-- directory path will be created on the server start. -- @tab[opt] object.net_box_credentials Override the default credentials for the -- `net.box` connection to the new server. -- @tab[opt] object.box_cfg Extra options for `box.cfg()` and the value of the -- `TARANTOOL_BOX_CFG` env variable which is passed into the server process. +-- @string[opt] object.config_file Declarative YAML configuration for a server +-- instance. Used to deduce advertise URI to connect net.box to the instance. +-- The special value '' means running without `--config <...>` CLI option +-- (but still passes `--name `). +-- @tab[opt] object.remote_config If `config_file` is not passed, this config +-- value is used to deduce advertise URI to connect net.box to the instance. +-- @tab[opt] extra Table with extra properties for the server object. -- @return table -function Server:new(object) - checks('table', self.constructor_checks) +function Server:new(object, extra) + checks('table', self.constructor_checks, '?table') if not object then object = {} end + if not extra then extra = {} end + object = utils.merge(object, extra) self:inherit(object) object:initialize() + + -- Each method of the server instance will be overridden by a new function + -- in which the association of the current test and server is performed first + -- and then the method itself. + -- It solves the problem when the server is not used in the test (should not + -- save artifacts) and when used. + for k, v in pairs(self) do + if type(v) == 'function' then + object[k] = function(...) + local t = rawget(_G, 'current_test') + if t and t.value then + t = t.value + if not object.tests[t.name] then + object.tests[t.name] = t + t.servers[object.id] = object + log.info('Server %q used in %s test', object.alias, t.name) + end + end + return v(...) + end + end + end return object end +-- Determine advertise URI for given instance from a cluster +-- configuration. +local function find_advertise_uri(config, instance_name, dir) + if config == nil or next(config) == nil then + return nil + end + + -- Determine listen and advertise options that are in effect + -- for the given instance. + local advertise + local listen + + for _, group in pairs(config.groups or {}) do + for _, replicaset in pairs(group.replicasets or {}) do + local instance = (replicaset.instances or {})[instance_name] + if instance == nil then + goto continue + end + if instance.iproto ~= nil then + if instance.iproto.advertise ~= nil then + advertise = advertise or instance.iproto.advertise.client + end + listen = listen or instance.iproto.listen + end + if replicaset.iproto ~= nil then + if replicaset.iproto.advertise ~= nil then + advertise = advertise or replicaset.iproto.advertise.client + end + listen = listen or replicaset.iproto.listen + end + if group.iproto ~= nil then + if group.iproto.advertise ~= nil then + advertise = advertise or group.iproto.advertise.client + end + listen = listen or group.iproto.listen + end + ::continue:: + end + end + + if config.iproto ~= nil then + if config.iproto.advertise ~= nil then + advertise = advertise or config.iproto.advertise.client + end + listen = listen or config.iproto.listen + end + + local uris + if advertise ~= nil then + uris = {{uri = advertise}} + else + uris = listen + end + + for _, uri in ipairs(uris or {}) do + uri = table.copy(uri) + uri.uri = uri.uri:gsub('{{ *instance_name *}}', instance_name) + uri.uri = uri.uri:gsub('unix/:%./', ('unix/:%s/'):format(dir)) + local u = urilib.parse(uri) + if u.ipv4 ~= '0.0.0.0' and u.ipv6 ~= '::' and u.service ~= '0' then + return uri + end + end + + error('No suitable URI to connect is found') +end + -- Initialize the server object. function Server:initialize() - if self.id == nil then - self.id = digest.base64_encode(digest.urandom(9), {urlsafe = true}) + if self.config_file ~= nil then + self.command = arg[-1] + + self.args = fun.chain(self.args or {}, {'--name', self.alias}):totable() + + if self.config_file ~= '' then + table.insert(self.args, '--config') + table.insert(self.args, self.config_file) + + -- Take into account self.chdir to calculate a config + -- file path. + local config_file_path = utils.pathjoin(self.chdir, self.config_file) + + -- Read the provided config file. + local fh, err = fio.open(config_file_path, {'O_RDONLY'}) + if fh == nil then + error(('Unable to open file %q: %s'):format(config_file_path, err)) + end + self.config = yaml.decode(fh:read()) + fh:close() + end + + if self.net_box_uri == nil then + local config = self.config or self.remote_config + + -- NB: listen and advertise URIs are relative to + -- process.work_dir, which, in turn, is relative to + -- self.chdir. + local work_dir + if config.process ~= nil and config.process.work_dir ~= nil then + work_dir = config.process.work_dir + end + local dir = utils.pathjoin(self.chdir, work_dir) + self.net_box_uri = find_advertise_uri(config, self.alias, dir) + end end if self.alias == nil then self.alias = DEFAULT_ALIAS end + if self.id == nil then + self.id = ('%s-%s'):format(self.alias, utils.generate_id()) + end + if self.command == nil then self.command = DEFAULT_INSTANCE end if self.workdir == nil then - self.workdir = fio.pathjoin(self.vardir, ('%s-%s'):format(self.alias, self.id)) + self.workdir = fio.pathjoin(self.vardir, self.id) fio.rmtree(self.workdir) - fio.mktree(self.workdir) - end - - if self.datadir ~= nil then - local ok, err = fio.copytree(self.datadir, self.workdir) - if not ok then - error(('Failed to copy directory: %s'):format(err)) - end - self.datadir = nil end if self.http_port then self.http_client = http_client.new() end - if self.net_box_uri == nil and self.net_box_port == nil then - self.net_box_uri = self.build_listen_uri(self.alias) - fio.mktree(self.vardir) - end - if self.net_box_uri == nil and self.net_box_port then - self.net_box_uri = 'localhost:' .. self.net_box_port + if self.net_box_uri == nil then + if self.net_box_port == nil then + self.net_box_uri = self.build_listen_uri(self.alias, self.rs_id or self.id) + else + self.net_box_uri = 'localhost:' .. self.net_box_port + end end - if uri.parse(self.net_box_uri).host == 'unix/' then + local parsed_net_box_uri = urilib.parse(self.net_box_uri) + if parsed_net_box_uri.host == 'unix/' then -- Linux uses max 108 bytes for Unix domain socket paths, which means a 107 characters -- string ended by a null terminator. Other systems use 104 bytes and 103 characters strings. local max_unix_socket_path = {linux = 107, other = 103} local system = os.execute('[ $(uname) = Linux ]') == 0 and 'linux' or 'other' - if self.net_box_uri:len() > max_unix_socket_path[system] then - error(('Net box URI must be <= max Unix domain socket path length (%d chars)') - :format(max_unix_socket_path[system])) + if parsed_net_box_uri.unix:len() > max_unix_socket_path[system] then + error(('Unix domain socket path cannot be longer than %d chars. Current path is: %s') + :format(max_unix_socket_path[system], parsed_net_box_uri.unix)) end end + if type(self.net_box_uri) == 'table' then + self.net_box_uri = urilib.format(parsed_net_box_uri, true) + end self.env = utils.merge(self.env or {}, self:build_env()) self.args = self.args or {} @@ -160,14 +295,16 @@ function Server:initialize() self.coverage_report = true end if self.coverage_report then - -- If command is executable lua script, run it with `tarantool -l luatest.coverage script.lua` + -- If the command is an executable lua script, run it with + -- `tarantool -l luatest.coverage script.lua`. + -- If the command is `tarantool`, add `-l luatest.coverage`. if self.command:endswith('.lua') then table.insert(self.args, 1, '-l') table.insert(self.args, 2, 'luatest.coverage') table.insert(self.args, 3, self.command) + self.original_command = self.command self.command = arg[-1] - -- If command is tarantool, add `-l luatest.coverage` - elseif self.command:endswith('/tarantool') then + elseif utils.is_tarantool_binary(self.command) then if not fun.index('luatest.coverage', self.args) then table.insert(self.args, 1, '-l') table.insert(self.args, 2, 'luatest.coverage') @@ -183,6 +320,15 @@ function Server:initialize() -- so set it explicitly: self.env.LUATEST_LUACOV_ROOT = os.getenv('LUATEST_LUACOV_ROOT') end + + if not self.tests then + self.tests = {} + end + + local prefix = fio.pathjoin(Server.vardir, 'artifacts', self.rs_id or '') + self.artifacts = fio.pathjoin(prefix, self.id) + + self.log_file = fio.pathjoin(self.workdir, self.alias .. '.log') end -- Create a table with env variables based on the constructor params. @@ -209,13 +355,43 @@ function Server:build_env() return res end ---- Build a listen URI based on the given server alias. +--- Build a listen URI based on the given server alias and extra path. +-- The resulting URI: `/[/].sock`. +-- Provide a unique alias or extra path to avoid collisions with other sockets. -- For now, only UNIX sockets are supported. -- -- @string server_alias Server alias. +-- @string[opt] extra_path Extra path relative to the `Server.vardir` directory. -- @return string -function Server.build_listen_uri(server_alias) - return fio.pathjoin(Server.vardir, server_alias .. '.sock') +function Server.build_listen_uri(server_alias, extra_path) + return fio.pathjoin(Server.vardir, extra_path or '', server_alias .. '.sock') +end + +--- Make the server's working directory. +-- Invoked on the server's start. +function Server:make_workdir() + fio.mktree(self.workdir) +end + +--- Copy contents of the data directory into the server's working directory. +-- Invoked on the server's start. +function Server:copy_datadir() + if self.datadir ~= nil then + local ok, err = fio.copytree(self.datadir, self.workdir) + if not ok then + error(('Failed to copy %s to %s: %s'):format(self.datadir, self.workdir, err)) + end + self.datadir = nil + end +end + +--- Make directory for the server's Unix socket. +-- Invoked on the server's start. +function Server:make_socketdir() + local parsed_net_box_uri = urilib.parse(self.net_box_uri) + if parsed_net_box_uri.host == 'unix/' then + fio.mktree(fio.dirname(parsed_net_box_uri.service)) + end end --- Start a server. @@ -230,11 +406,15 @@ function Server:start(opts) self:initialize() + self:make_workdir() + self:copy_datadir() + self:make_socketdir() + local command = self.command local args = table.copy(self.args) local env = table.copy(os.environ()) - if not command:endswith('/tarantool') then + if not utils.is_tarantool_binary(command) then -- When luatest is installed as a rock, the internal server_instance.lua -- script won't have execution permissions even though it has them in the -- source tree, and won't be able to be run while a server start. To bypass @@ -253,14 +433,23 @@ function Server:start(opts) for _, v in ipairs(args) do table.insert(log_cmd, string.format('%q', v)) end - log.debug(table.concat(log_cmd, ' ')) self.process = Process:start(command, args, env, { chdir = self.chdir, output_prefix = self.alias, + output_file = self.log_file, }) - local wait_until_ready = self.command == DEFAULT_INSTANCE + local wait_until_ready + if self.config_file then + wait_until_ready = self.net_box_uri ~= nil + else + if self.coverage_report then + wait_until_ready = self.original_command == DEFAULT_INSTANCE + else + wait_until_ready = self.command == DEFAULT_INSTANCE + end + end if opts ~= nil and opts.wait_until_ready ~= nil then wait_until_ready = opts.wait_until_ready end @@ -268,7 +457,7 @@ function Server:start(opts) self:wait_until_ready() end - log.debug('Started server PID: ' .. self.process.pid) + log.info('Server %q (pid: %d) started', self.alias, self.process.pid) end --- Restart the server with the given parameters. @@ -293,7 +482,7 @@ function Server:restart(params, opts) http_port = '?number', net_box_port = '?number', - net_box_uri = '?string', + net_box_uri = '?string|table', net_box_credentials = '?table', alias = '?string', @@ -302,7 +491,7 @@ function Server:restart(params, opts) }, {wait_until_ready = '?boolean'}) if not self.process then - log.warn("Process wasn't started") + log.warn('Cannot restart server %q since its process not started', self.alias) end self:stop() @@ -311,18 +500,46 @@ function Server:restart(params, opts) end self:start(opts) - log.debug('Restarted server PID: ' .. self.process.pid) + log.info('Server %q (pid: %d) restarted', self.alias, self.process.pid) +end + +-- Save server artifacts by copying the working directory. +-- The save logic will only work once to avoid overwriting the artifacts directory. +-- If an error occurred, then the server artifacts path will be replaced by the +-- following string: `Failed to copy artifacts for server (alias: , workdir: )`. +function Server:save_artifacts() + if self.artifacts_saved then + log.info('Artifacts of server %q already saved to %s', self.alias, self.artifacts) + return + end + local ok, err = fio.copytree(self.workdir, self.artifacts) + if not ok then + self.artifacts = ('Failed to copy artifacts for server (alias: %s, workdir: %s)') + :format(self.alias, fio.basename(self.workdir)) + log.error(('%s: %s'):format(self.artifacts, err)) + end + log.info('Artifacts of server %q saved from %s to %s', + self.alias, self.workdir, self.artifacts) + self.artifacts_saved = true end -- Wait until the given condition is `true` (anything except `false` and `nil`). --- Throws an error when timeout exceeds. +-- Throws an error when the server process is terminated or timeout exceeds. local function wait_for_condition(cond_desc, server, func, ...) + log.info('Wait for %q condition for server %q (pid: %d) within %d sec', + cond_desc, server.alias, server.process.pid, WAIT_TIMEOUT) local deadline = clock.time() + WAIT_TIMEOUT while true do + if not server.process:is_alive() then + server:save_artifacts() + error(('Process is terminated when waiting for "%s" condition for server (alias: %s, workdir: %s, pid: %d)') + :format(cond_desc, server.alias, fio.basename(server.workdir), server.process.pid)) + end if func(...) then return end if clock.time() > deadline then + server:save_artifacts() error(('Timed out to wait for "%s" condition for server (alias: %s, workdir: %s, pid: %d) within %ds') :format(cond_desc, server.alias, fio.basename(server.workdir), server.process.pid, WAIT_TIMEOUT)) end @@ -338,40 +555,65 @@ function Server:stop() self:coverage('shutdown') end self.net_box:close() + log.info('Connection to server %q (pid: %d) closed', self.alias, self.process.pid) self.net_box = nil end - if self.process then + if self.process and self.process:is_alive() then self.process:kill() - wait_for_condition('process is terminated', self, function() + local ok, err = pcall(wait_for_condition, 'process is terminated', self, function() return not self.process:is_alive() end) - log.debug('Killed server process PID ' .. self.process.pid) + if not ok and not err:find('Process is terminated when waiting for') then + error(err) + end + local workdir = fio.basename(self.workdir) + local pid = self.process.pid + -- Check the log file for crash and memory leak reports. + if fio.path.exists(self.log_file) then + if self:grep_log('Segmentation fault$', math.huge) then + error(('Segmentation fault during process termination (alias: %s, workdir: %s, pid: %d)') + :format(self.alias, workdir, pid)) + end + if self:grep_log('LeakSanitizer: detected memory leaks$', math.huge) then + error(('Memory leak during process execution (alias: %s, workdir: %s, pid: %s)') + :format(self.alias, workdir, pid)) + end + end + log.info('Process of server %q (pid: %d) killed', self.alias, self.process.pid) self.process = nil end end ---- Clean the server's working directory. --- Should be invoked only for a stopped server. -function Server:clean() - fio.rmtree(self.workdir) - self.instance_id = nil - self.instance_uuid = nil -end - ---- Stop the server and clean its working directory. +--- Stop the server and save its artifacts if the test fails. +-- This function should be used only at the end of the test (`after_test`, +-- `after_each`, `after_all` hooks) to terminate the server process. +-- Besides process termination, it saves the contents of the server +-- working directory to the `/artifacts` directory for further +-- analysis if the test fails. function Server:drop() self:stop() - self:clean() + self:save_artifacts() + + self.instance_id = nil + self.instance_uuid = nil end --- Wait until the server is ready after the start. -- A server is considered ready when its `_G.ready` variable becomes `true`. function Server:wait_until_ready() + local expr + if self.config_file ~= nil then + expr = "return require('config'):info().status == 'ready' or " .. + "require('config'):info().status == 'check_warnings'" + else + expr = 'return _G.ready' + end + wait_for_condition('server is ready', self, function() local ok, is_ready = pcall(function() self:connect_net_box() - return self.net_box:eval('return _G.ready') == true + return self.net_box:eval(expr) == true end) return ok and is_ready end) @@ -504,6 +746,19 @@ local function exec_tail(ok, ...) end end +-- Check that the passed `args` to the `fn` function are an array. +local function are_fn_args_array(fn, args) + local fn_details = debug.getinfo(fn) + if args and #args ~= fn_details.nparams then + for k, _ in pairs(args) do + if type(k) ~= 'number' then + return false + end + end + end + return true +end + --- Run given function on the server. -- -- Much like `Server:eval`, but takes a function instead of a string. @@ -562,6 +817,18 @@ function Server:exec(fn, args, options) error(err, 2) end + if not are_fn_args_array(fn, args) then + error(('bad argument #3 for exec at %s: an array is required'):format(utils.get_fn_location(fn))) + end + + -- Note that we wrap any error raised by the executed function to save + -- the original trace. It will be used by the runner to report the full + -- error trace, see Runner:protected_call(). + -- + -- If the function fails an assertion in an open transaction, Tarantool + -- will raise the "Transaction is active at return from function" error, + -- thus overwriting the original error raised by the assertion. To avoid + -- that, let's rollback the active transaction on failure. return exec_tail(self.net_box:eval([[ local dump, args, passthrough_ups = ... local fn = loadstring(dump) @@ -571,11 +838,23 @@ function Server:exec(fn, args, options) debug.setupvalue(fn, i, require(passthrough_ups[name])) end end - if args == nil then - return pcall(fn) - else - return pcall(fn, unpack(args)) + local result = {xpcall(function() + if args == nil then + return fn() + else + return fn(unpack(args)) + end + end, function(e) + return { + class = 'LuatestErrorWrapper', + error = e, + trace = debug.traceback('', 3):sub(2), + } + end)} + if not result[1] then + box.rollback() end + return unpack(result, 1, table.maxn(result)) ]], {string.dump(fn), args, passthrough_ups}, options)) end @@ -588,7 +867,6 @@ end -- --- Search a string pattern in the server's log file. --- If the server has crashed, `opts.filename` is required. -- -- @string pattern String pattern to search in the server's log file. -- @number[opt] bytes_num Number of bytes to read from the server's log file. @@ -597,14 +875,19 @@ end -- pattern is found, which means that the server was restarted. -- Defaults to `true`. -- @string[opt] opts.filename Path to the server's log file. --- Defaults to `box.cfg.log`. +-- Defaults to `/.log`. -- @return string|nil function Server:grep_log(pattern, bytes_num, opts) local options = opts or {} - local reset = options.reset or true - local filename = options.filename or self:exec(function() return box.cfg.log end) + local reset = options.reset + if reset == nil then + reset = true + end + local filename = options.filename or self.log_file local file = fio.open(filename, {'O_RDONLY', 'O_NONBLOCK'}) + log.info('Trying to grep %q in server\'s log file %s', pattern, filename) + local function fail(msg) local err = errno.strerror() if file ~= nil then @@ -617,8 +900,19 @@ function Server:grep_log(pattern, bytes_num, opts) fail('Failed to open log file') end - io.flush() -- attempt to flush stdout == log fd + -- Logs written by a test server go through a pipe and are processed + -- by a luatest fiber before they make it to the log file. Let's write + -- a unique marker string to the server log via server:exec() and retry + -- until we find the marker. + local marker + if self ~= nil and self.net_box ~= nil and self.net_box:ping() then + marker = ('LUATEST_GREP_LOG_MARKER:%d'):format(math.random(1e9)) + self:exec(function(s) require('log').info(s) end, {marker}) + end + + local retries = 0 + ::retry:: local filesize = file:seek(0, 'SEEK_END') if filesize == nil then fail('Failed to get log file size') @@ -653,16 +947,27 @@ function Server:grep_log(pattern, bytes_num, opts) line = table.concat(buf) buf = nil end - if string.match(line, '> Tarantool %d+.%d+.%d+-.*%d+-g.*$') and reset then + local package = tarantool.package or 'Tarantool' + if string.match(line, '> ' .. package .. ' %d+.%d+.%d+-.*%d+-g.*$') and reset then found = nil -- server was restarted, reset the result else found = string.match(line, pattern) or found end + if marker ~= nil and string.match(line, marker) then + marker = nil + end end pos = endpos and endpos + 2 -- jump to char after \n until pos == nil until s == '' + if found == nil and marker ~= nil and retries < 10 then + log.info('Retrying grep because marker was not found') + retries = retries + 1 + fiber.sleep(0.1) + goto retry + end + file:close() return found @@ -816,12 +1121,13 @@ function Server:wait_for_vclock(vclock) end end ---- Wait until all own data is replicated and confirmed by the given server. +--- Wait for the given server to reach at least the same vclock as the local +-- server. Not including the local component, of course. -- -- @tab server Server's object. function Server:wait_for_downstream_to(server) local id = server:get_instance_id() - local vclock = server:get_vclock() + local vclock = self:get_vclock() vclock[0] = nil -- first component is for local changes while true do if vclock_ge(self:get_downstream_vclock(id), vclock) then diff --git a/luatest/server_instance.lua b/luatest/server_instance.lua index 8de20d55..12c191a7 100644 --- a/luatest/server_instance.lua +++ b/luatest/server_instance.lua @@ -1,4 +1,3 @@ -local fio = require('fio') local fun = require('fun') local json = require('json') @@ -8,10 +7,6 @@ local function default_cfg() return { work_dir = os.getenv('TARANTOOL_WORKDIR'), listen = os.getenv('TARANTOOL_LISTEN'), - log = fio.pathjoin( - os.getenv('TARANTOOL_WORKDIR'), - os.getenv('TARANTOOL_ALIAS') .. '.log' - ), } end diff --git a/luatest/tarantool.lua b/luatest/tarantool.lua index 610a8e34..729e4315 100644 --- a/luatest/tarantool.lua +++ b/luatest/tarantool.lua @@ -37,6 +37,15 @@ function M.skip_if_enterprise(message) ) end +--- Skip a running test if Tarantool package is NOT Enterprise. +-- +-- @string[opt] message Message to describe the reason. +function M.skip_if_not_enterprise(message) + assertions.skip_if( + not M.is_enterprise_package(), message or 'package is not Enterprise' + ) +end + --- Search for a fiber with the specified name and return the fiber object. -- -- @string name Fiber name. diff --git a/luatest/test_instance.lua b/luatest/test_instance.lua index 79e1e3e3..a4db250e 100644 --- a/luatest/test_instance.lua +++ b/luatest/test_instance.lua @@ -16,6 +16,7 @@ end -- default constructor, test are PASS by default function TestInstance.mt:initialize() self.status = 'success' + self.servers = {} end function TestInstance.mt:update_status(status, message, trace) diff --git a/luatest/treegen.lua b/luatest/treegen.lua new file mode 100644 index 00000000..f44162c1 --- /dev/null +++ b/luatest/treegen.lua @@ -0,0 +1,212 @@ +--- Working tree generator. +-- +-- Generates a tree of Lua files using provided templates and +-- filenames. +-- +-- @usage +-- +-- local t = require('luatest') +-- local treegen = require('luatest.treegen') +-- +-- local g = t.group() +-- +-- g.test_foo = function(g) +-- treegen.add_template('^.*$', 'test_script') +-- local dir = treegen.prepare_directory({'foo/bar.lua', 'main.lua'}) +-- ... +-- end +-- +-- @module luatest.treegen + +local hooks = require('luatest.hooks') + +local fio = require('fio') +local fun = require('fun') +local checks = require('checks') + +local log = require('luatest.log') + +local treegen = { + _group = {} +} + +local function find_template(group, script) + for position, template_def in ipairs(group._treegen.templates) do + if script:match(template_def.pattern) then + return position, template_def.template + end + end + error(("treegen: can't find a template for script %q"):format(script)) +end + +--- Write provided content into the given directory. +-- +-- @string directory Directory where the content will be created. +-- @string filename File to write (possible nested path: /foo/bar/main.lua). +-- @string content The body to write. +-- @return string +function treegen.write_file(directory, filename, content) + checks('string', 'string', 'string') + local content_abspath = fio.pathjoin(directory, filename) + local flags = {'O_CREAT', 'O_WRONLY', 'O_TRUNC'} + local mode = tonumber('644', 8) + + local contentdir_abspath = fio.dirname(content_abspath) + log.info('Creating a directory: %s', contentdir_abspath) + fio.mktree(contentdir_abspath) + + log.info('Writing a content: %s', content_abspath) + local fh = fio.open(content_abspath, flags, mode) + fh:write(content) + fh:close() + return content_abspath +end + +-- Generate a content that follows a template and write it at the +-- given path in the given directory. +-- +-- @table group Group of tests. +-- @string directory Directory where the content will be created. +-- @string filename File to write (possible nested path: /foo/bar/main.lua). +-- @table replacements List of replacement templates. +-- @return string +local function gen_content(group, directory, filename, replacements) + checks('table', 'string', 'string', 'table') + local _, template = find_template(group, filename) + replacements = fun.chain({filename = filename}, replacements):tomap() + local body = template:gsub('<(.-)>', replacements) + return treegen.write_file(directory, filename, body) +end + +--- Initialize treegen module in the given group of tests. +-- +-- @tab group Group of tests. +local function init(group) + checks('table') + group._treegen = { + tempdirs = {}, + templates = {} + } + treegen._group = group +end + +--- Remove all temporary directories created by the test +-- unless KEEP_DATA environment variable is set to a +-- non-empty value. +local function clean() + if treegen._group._treegen == nil then + return + end + + local dirs = table.copy(treegen._group._treegen.tempdirs) or {} + treegen._group._treegen.tempdirs = nil + + local keep_data = (os.getenv('KEEP_DATA') or '') ~= '' + + for _, dir in ipairs(dirs) do + if keep_data then + log.info('Left intact due to KEEP_DATA env var: %s', dir) + else + log.info('Recursively removing: %s', dir) + fio.rmtree(dir) + end + end + + treegen._group._treegen.templates = nil +end + +--- Save the template with the given pattern. +-- +-- @string pattern File name template +-- @string template A content template for creating a file. +function treegen.add_template(pattern, template) + checks('string', 'string') + table.insert(treegen._group._treegen.templates, { + pattern = pattern, + template = template, + }) +end + +--- Remove the template by pattern. +-- +-- @string pattern File name template +function treegen.remove_template(pattern) + checks('string') + local is_found, position, _ = pcall(find_template, treegen._group, pattern) + if is_found then + table.remove(treegen._group._treegen.templates, position) + end +end + +--- Create a temporary directory with given contents. +-- +-- The contents are generated using templates added by +-- treegen.add_template(). +-- +-- @usage +-- +-- Example for {'foo/bar.lua', 'baz.lua'}: +-- +-- / +-- + tmp/ +-- + rfbWOJ/ +-- + foo/ +-- | + bar.lua +-- + baz.lua +-- +-- The return value is '/tmp/rfbWOJ' for this example. +-- +-- @tab contents List of bodies of the content to write. +-- @tab[opt] replacements List of replacement templates. +-- @return string +function treegen.prepare_directory(contents, replacements) + checks('?table', '?table') + replacements = replacements or {} + + local dir = fio.tempdir() + + -- fio.tempdir() follows the TMPDIR environment variable. + -- If it ends with a slash, the return value contains a double + -- slash in the middle: for example, if TMPDIR=/tmp/, the + -- result is like `/tmp//rfbWOJ`. + -- + -- It looks harmless on the first glance, but this directory + -- path may be used later to form an URI for a Unix domain + -- socket. As result the URI looks like + -- `unix/:/tmp//rfbWOJ/instance-001.iproto`. + -- + -- It confuses net_box.connect(): it reports EAI_NONAME error + -- from getaddrinfo(). + -- + -- It seems, the reason is a peculiar of the URI parsing: + -- + -- tarantool> uri.parse('unix/:/foo/bar.iproto') + -- --- + -- - host: unix/ + -- service: /foo/bar.iproto + -- unix: /foo/bar.iproto + -- ... + -- + -- tarantool> uri.parse('unix/:/foo//bar.iproto') + -- --- + -- - host: unix + -- path: /foo//bar.iproto + -- ... + -- + -- Let's normalize the path using fio.abspath(), which + -- eliminates the double slashes. + dir = fio.abspath(dir) + + table.insert(treegen._group._treegen.tempdirs, dir) + + for _, content in ipairs(contents) do + gen_content(treegen._group, dir, content, replacements) + end + + return dir +end + +hooks.before_all_preloaded(init) +hooks.after_all_preloaded(clean) + +return treegen diff --git a/luatest/utils.lua b/luatest/utils.lua index a097492b..014179e7 100644 --- a/luatest/utils.lua +++ b/luatest/utils.lua @@ -1,3 +1,5 @@ +local digest = require('digest') +local fio = require('fio') local fun = require('fun') local yaml = require('yaml') @@ -59,23 +61,47 @@ end -- Check if line of stack trace comes from inside luatest. local function is_luatest_internal_line(s) - return s:find('[/\\]luatest[/\\]') or s:find('bin[/\\]luatest') + if s:find('bin[/\\]luatest') then + return true + end + if s:find('[/\\]luatest[/\\]') then + -- Do not strip lines originating from luatest test files because + -- we want to see stack traces when we test luatest. + return not s:find('[/\\]luatest[/\\]test[/\\]') + end + return false end function utils.strip_luatest_trace(trace) local lines = trace:split('\n') - local result = {lines[1]} -- always keep 1st line + + -- Scan the stack trace backwards (from caller to callee) and strip all + -- frames related to luatest, as well as `[C]:`, `...`, `eval:` frames + -- called by them because they don't change context. + local result = {} local keep = true - for i = 2, table.maxn(lines) do + for i = table.maxn(lines), 2, -1 do local line = lines[i] - -- `[C]:` lines don't change context - if not line:find('^%s+%[C%]:') then + if not (line:find('^%s+%[C%]:') or + line:find('^%s+%.%.%.$') or + line:find('^%s+eval:')) then keep = not is_luatest_internal_line(line) end if keep then table.insert(result, line) end end + + -- Always keep the 1st line because it's the header ('stack traceback:'). + table.insert(result, lines[1]) + + -- Since we scanned the stack trace backwards, we need to reverse + -- the result. + for i = 1, math.floor(#result / 2) do + local v = result[#result - i + 1] + result[#result - i + 1] = result[i] + result[i] = v + end return table.concat(result, '\n') end @@ -142,5 +168,78 @@ function utils.upvalues(fn) return ret end +function utils.get_fn_location(fn) + local fn_details = debug.getinfo(fn) + local fn_source = fn_details.source:split('/') + return ('%s:%s'):format(fn_source[#fn_source], fn_details.linedefined) +end + +function utils.generate_id(length, urlsafe) + if not length then length = 9 end + if urlsafe == nil then urlsafe = true end + return digest.base64_encode(digest.urandom(length), {urlsafe = urlsafe}) +end + +function utils.version(major, minor, patch) + return { + major = major or 0, + minor = minor or 0, + patch = patch or 0, + } +end + +function utils.get_tarantool_version() + local version = require('tarantool').version + version = version:split('.') + local major = tonumber(version[1]:match('%d+')) + local minor = tonumber(version[2]:match('%d+')) + local patch = tonumber(version[3]:match('%d+')) + return utils.version(major, minor, patch) +end + +function utils.version_ge(version1, version2) + if version1.major ~= version2.major then + return version1.major > version2.major + elseif version1.minor ~= version2.minor then + return version1.minor > version2.minor + else + return version1.patch >= version2.patch + end +end + +function utils.version_current_ge_than(major, minor, patch) + return utils.version_ge(utils.get_tarantool_version(), + utils.version(major, minor, patch)) +end + +function utils.is_tarantool_binary(path) + return path:find('^.*/tarantool[^/]*$') ~= nil +end + +-- Return args as table with 'n' set to args number. +function utils.table_pack(...) + return {n = select('#', ...), ...} +end + +-- Join paths in an intuitive way. +-- If a component is nil, it is skipped. +-- If a component is an absolute path, it skips all the previous +-- components. +-- The wrapper is written for two components for simplicity. +function utils.pathjoin(a, b) + -- No first path -- skip it. + if a == nil then + return b + end + -- No second path -- skip it. + if b == nil then + return a + end + -- The absolute path is checked explicitly due to gh-8816. + if b:startswith('/') then + return b + end + return fio.pathjoin(a, b) +end return utils diff --git a/rpm/tarantool-luatest.spec b/rpm/luatest.spec similarity index 93% rename from rpm/tarantool-luatest.spec rename to rpm/luatest.spec index 03f7a03a..66f54966 100644 --- a/rpm/tarantool-luatest.spec +++ b/rpm/luatest.spec @@ -7,10 +7,12 @@ License: MIT URL: https://github.com/tarantool/luatest Source0: https://github.com/tarantool/luatest/archive/%{version}/luatest-%{version}.tar.gz BuildArch: noarch +BuildRequires: tarantool >= 1.9.0 BuildRequires: tarantool-devel >= 1.9.0 -BuildRequires: tarantool-checks +BuildRequires: tarantool-checks >= 3.0.1 +BuildRequires: tt >= 2.2.1 Requires: tarantool >= 1.9.0 -Requires: tarantool-checks +Requires: tarantool-checks >= 3.0.1 %description Simple Tarantool test framework for both unit and integration testing. @@ -18,20 +20,19 @@ Simple Tarantool test framework for both unit and integration testing. %setup -q -n %{name}-%{version} %build -%cmake . -DCMAKE_BUILD_TYPE=RelWithDebInfo -DVERSION=%{version} +%cmake -B . -DCMAKE_BUILD_TYPE=RelWithDebInfo -DVERSION=%{version} make %{?_smp_mflags} %check -ctest -VV +make selftest %install %make_install %files -#%{_libdir}/tarantool/*/ %{_datarootdir}/tarantool/*/ %{_bindir}/luatest -%doc README.md +%doc README.rst %{!?_licensedir:%global license %doc} %license LICENSE @@ -52,7 +53,7 @@ ctest -VV - Add `xfail` status. - Add new `Server:exec()` function which runs a Lua function remotely. -* Thu Sept 16 2021 Aleksandr Shemenev 0.5.5-1 +* Thu Sep 16 2021 Aleksandr Shemenev 0.5.5-1 - Repeat `_each` and `_test` hooks when `--repeat` is specified. - Add group parametrization. diff --git a/rpm/prebuild.sh b/rpm/prebuild.sh index cbf3b8cc..e4778e40 100755 --- a/rpm/prebuild.sh +++ b/rpm/prebuild.sh @@ -1 +1 @@ -curl -s https://packagecloud.io/install/repositories/tarantool/1_10/script.rpm.sh | sudo bash +curl -L https://tarantool.io/release/${LUATEST_TARANTOOL_SERIES:-2}/installer.sh | bash diff --git a/test/artifacts/common_test.lua b/test/artifacts/common_test.lua new file mode 100644 index 00000000..2442c38a --- /dev/null +++ b/test/artifacts/common_test.lua @@ -0,0 +1,67 @@ +local t = require('luatest') +local utils = require('luatest.utils') + +local g = t.group() +local Server = t.Server + +g.public = Server:new({alias = 'public'}) +g.public:start() + +g.test_servers_not_added_if_they_are_not_used = function() +end + +g.after_test('test_servers_not_added_if_they_are_not_used', function() + t.fail_if( + utils.table_len(rawget(_G, 'current_test').value.servers) ~= 0, + 'Test instance should not contain a servers') +end) + +g.test_only_public_server_has_been_added = function() + g.public:get_vclock() +end + +g.after_test('test_only_public_server_has_been_added', function() + t.fail_if( + rawget(_G, 'current_test').value.servers[g.public.id] == nil, + 'Test should contain only public server') +end) + +g.test_only_private_server_has_been_added = function() + g.private = Server:new({alias = 'private'}) + g.private:start() +end + +g.after_test('test_only_private_server_has_been_added', function() + t.fail_if( + rawget(_G, 'current_test').value.servers[g.private.id] == nil, + 'Test should contain only private server') +end) + +g.before_test('test_add_server_from_test_hooks', function() + g.before = Server:new({alias = 'before'}) + g.before:start() +end) + +g.test_add_server_from_test_hooks = function() +end + +g.after_test('test_add_server_from_test_hooks', function() + g.after = Server:new({alias = 'after'}) + g.after:start() + + local test_servers = rawget(_G, 'current_test').value.servers + + t.fail_if( + utils.table_len(test_servers) ~= 2, + 'Test should contain two servers (from before/after hooks)') + t.fail_if( + test_servers[g.before.id] == nil or test_servers[g.after.id] == nil, + 'Test should contain only `before` and `after` servers') +end) + +g.after_all(function() + g.public:drop() + g.private:drop() + g.before:drop() + g.after:drop() +end) diff --git a/test/artifacts/end_group_test.lua b/test/artifacts/end_group_test.lua new file mode 100644 index 00000000..959c7113 --- /dev/null +++ b/test/artifacts/end_group_test.lua @@ -0,0 +1,28 @@ +local t = require('luatest') +local utils = require('luatest.utils') + +local Server = t.Server + +local g = t.group() + +g.test_foo = function() + g.foo_test = rawget(_G, 'current_test').value +end + +g.test_bar = function() + g.bar_test = rawget(_G, 'current_test').value +end + +g.after_all(function() + g.s = Server:new() + g.s:start() + + t.fail_if( + utils.table_len(g.foo_test.servers) ~= 0, + 'Test instance `foo` should not contain servers') + + t.fail_if( + utils.table_len(g.bar_test.servers) ~= 0, + 'Test instance `bar` should not contain servers') + g.s:drop() +end) diff --git a/test/artifacts/hooks_test.lua b/test/artifacts/hooks_test.lua new file mode 100644 index 00000000..9b805b2a --- /dev/null +++ b/test/artifacts/hooks_test.lua @@ -0,0 +1,72 @@ +local t = require('luatest') +local utils = require('luatest.utils') +local fio = require('fio') + +local g = t.group() +local Server = t.Server + +local function is_server_in_test(server, test) + for _, s in pairs(test.servers) do + if server.id == s.id then + return true + end + end + return false +end + +g.public = Server:new({alias = 'public'}) +g.public:start() + +g.before_all(function() + g.all = Server:new({alias = 'all9'}) + g.all:start() +end) + +g.before_each(function() + g.each = Server:new({alias = 'each'}) + g.each:start() +end) + +g.before_test('test_association_between_test_and_servers', function() + g.test = Server:new({alias = 'test'}) + g.test:start() +end) + +g.test_association_between_test_and_servers = function() + g.internal = Server:new({alias = 'internal'}) + g.internal:start() + + local test = rawget(_G, 'current_test').value + + -- test static association + t.assert(is_server_in_test(g.internal, test)) + t.assert(is_server_in_test(g.each, test)) + t.assert(is_server_in_test(g.test, test)) + t.assert_not(is_server_in_test(g.public, test)) + + g.public:exec(function() return 1 + 1 end) + g.all:exec(function() return 1 + 1 end) + + -- test dynamic association + t.assert(is_server_in_test(g.public, test)) + t.assert(is_server_in_test(g.all, test)) + + t.assert(utils.table_len(test.servers) == 5) +end + +g.after_test('test_association_between_test_and_servers', function() + g.internal:drop() + g.test:drop() + t.assert(fio.path.exists(g.test.artifacts)) +end) + +g.after_each(function() + g.each:drop() + t.assert(fio.path.exists(g.each.artifacts)) +end) + +g.after_all(function() + g.all:drop() + t.assert(fio.path.exists(g.all.artifacts)) + g.public:drop() +end) diff --git a/test/artifacts/replica_set_test.lua b/test/artifacts/replica_set_test.lua new file mode 100644 index 00000000..f94b9ba3 --- /dev/null +++ b/test/artifacts/replica_set_test.lua @@ -0,0 +1,40 @@ +local t = require('luatest') +local utils = require('luatest.utils') +local ReplicaSet = require('luatest.replica_set') + +local g = t.group() + +g.box_cfg = { + replication_timeout = 0.1, + replication_connect_timeout = 3, + replication_sync_lag = 0.01, + replication_connect_quorum = 3 +} + +g.rs = ReplicaSet:new() +g.rs:build_and_add_server({alias = 'replica1', box_cfg = g.box_cfg}) +g.rs:build_and_add_server({alias = 'replica2', box_cfg = g.box_cfg}) + +g.test_foo = function() + g.rs:start() + g.foo_test = rawget(_G, 'current_test').value +end + +g.test_bar = function() + g.bar_test = rawget(_G, 'current_test').value +end + +g.after_test('test_foo', function() + t.fail_if( + utils.table_len(g.foo_test.servers) ~= 2, + 'Test instance should contain all servers from replica set' + ) + g.rs:drop() +end) + +g.after_test('test_bar', function() + t.fail_if( + utils.table_len(g.bar_test.servers) ~= 0, + 'Test instance should not contain any servers' + ) +end) diff --git a/test/artifacts/sequence_test.lua b/test/artifacts/sequence_test.lua new file mode 100644 index 00000000..73328269 --- /dev/null +++ b/test/artifacts/sequence_test.lua @@ -0,0 +1,47 @@ +local t = require('luatest') +local utils = require('luatest.utils') + +local g = t.group() +local Server = t.Server + +g.s = Server:new() +g.s:start() + +g.before_each(function() + g.each = Server:new() + g.each:start() +end) + +g.test_foo = function() + g.foo_test = rawget(_G, 'current_test').value + g.foo_test_server = g.each.id +end + +g.test_bar = function() + g.bar_test = rawget(_G, 'current_test').value + g.bar_test_server = g.each.id +end + +g.after_test('test_foo', function() + t.fail_if( + utils.table_len(g.foo_test.servers) ~= 1, + 'Test instance should contain server') +end) + +g.after_test('test_bar', function() + t.fail_if( + utils.table_len(g.bar_test.servers) ~= 1, + 'Test instance should contain server') +end) + +g.after_each(function() + g.each:drop() +end) + +g.after_all(function() + g.s:drop() + t.fail_if( + g.foo_test_server == g.bar_test_server, + 'Servers must be unique within the group' + ) +end) diff --git a/test/artifacts/start_group_test.lua b/test/artifacts/start_group_test.lua new file mode 100644 index 00000000..286795f8 --- /dev/null +++ b/test/artifacts/start_group_test.lua @@ -0,0 +1,35 @@ +local t = require('luatest') +local utils = require('luatest.utils') + +local Server = t.Server + +local g = t.group() + +g.before_all(function() + g.s = Server:new() + g.s:start() +end) + +g.test_foo = function() + g.foo_test = rawget(_G, 'current_test').value +end + +g.after_test('test_foo', function() + t.fail_if( + utils.table_len(g.foo_test.servers) ~= 0, + 'Test instance should not contain a servers') +end) + +g.test_bar = function() + g.bar_test = rawget(_G, 'current_test').value +end + +g.after_test('test_bar', function() + t.fail_if( + utils.table_len(g.bar_test.servers) ~= 0, + 'Test instance should not contain a servers') +end) + +g.after_all(function() + g.s:drop() +end) diff --git a/test/assertions_test.lua b/test/assertions_test.lua index 20d3fd10..d88d5571 100644 --- a/test/assertions_test.lua +++ b/test/assertions_test.lua @@ -1,7 +1,7 @@ local t = require('luatest') local g = t.group() -local helper = require('test.helper') +local helper = require('test.helpers.general') g.test_custom_errors = function() local function assert_no_exception(fn) @@ -62,3 +62,25 @@ g.test_assert_comparisons_error = function() helper.assert_failure_contains('must supply only number arguments.\n'.. 'Arguments supplied: \"one\", 3', t.assert_gt, 'one', 3) end + +local function external_error_fn(msg) + error(msg) +end + +local g2 = t.group('g2', { + {fn = external_error_fn}, + {fn = error}, + {fn = function(msg) error(msg) end} +}) + +g2.test_assert_error_msg_content_equals = function(cg) + local msg = "error" + t.assert_error_msg_content_equals(msg, cg.params.fn, msg) + t.assert_error_msg_content_equals(msg, cg.params.fn, "foo.bar:1: " .. msg) + t.assert_error_msg_content_equals(msg, cg.params.fn, "foo.bar:123: " .. msg) + t.assert_error_msg_content_equals(msg, cg.params.fn, "/foo/bar.lua:1: " .. msg) + t.assert_error_msg_content_equals(msg, cg.params.fn, ".../foo/bar.lua:1: " .. msg) + t.assert_error_msg_content_equals(msg, cg.params.fn, "foo.bar:1: foo.bar:1: " .. msg) + t.assert_error_msg_content_equals(msg, cg.params.fn, "foo.bar:1: .../foo/bar.lua:1: " .. msg) + t.assert_error_msg_content_equals(msg, cg.params.fn, "foo.bar.bar:1: foo.bar.bar:1: " .. msg) +end diff --git a/test/autorequire_luatest_test.lua b/test/autorequire_luatest_test.lua index 9ec3b77e..59898cda 100644 --- a/test/autorequire_luatest_test.lua +++ b/test/autorequire_luatest_test.lua @@ -4,7 +4,7 @@ local t = require('luatest') local g = t.group() local Server = t.Server -local root = fio.dirname(fio.dirname(fio.abspath(package.search('test.helper')))) +local root = fio.dirname(fio.abspath('test.helpers')) local datadir = fio.pathjoin(root, 'tmp', 'luatest_module') local command = fio.pathjoin(root, 'test', 'server_instance.lua') @@ -75,12 +75,15 @@ g.test_exec_with_upvalue_and_local_variable = function() end g.before_test('test_exec_when_luatest_not_found', function() - -- Setup custom server without LUA_PATH variable + -- Setup custom server without luatest in LUA_PATH. g.bad_env_server = Server:new({ command = command, workdir = fio.tempdir(), http_port = 8183, net_box_port = 3134, + env = { + LUA_PATH = '', + }, }) fio.mktree(g.bad_env_server.workdir) @@ -104,3 +107,12 @@ end g.after_test('test_exec_when_luatest_not_found', function() g.bad_env_server:drop() end) + +g.test_exec_with_sparse_output = function() + local res1, res2 = g.server:exec(function() + return nil, 'some error' + end) + + t.assert_equals(res1, nil) + t.assert_equals(res2, 'some error') +end diff --git a/test/boxcfg_interaction_test.lua b/test/boxcfg_interaction_test.lua index 56ef6096..e359970f 100644 --- a/test/boxcfg_interaction_test.lua +++ b/test/boxcfg_interaction_test.lua @@ -4,7 +4,7 @@ local t = require('luatest') local g = t.group() local Server = t.Server -local root = fio.dirname(fio.dirname(fio.abspath(package.search('test.helper')))) +local root = fio.dirname(fio.abspath('test.helpers')) local datadir = fio.pathjoin(root, 'tmp', 'boxcfg_interaction') local command = fio.pathjoin(root, 'test', 'server_instance.lua') diff --git a/test/capture_test.lua b/test/capture_test.lua index e205a08a..2b20ff5c 100644 --- a/test/capture_test.lua +++ b/test/capture_test.lua @@ -5,6 +5,9 @@ local g = t.group() local Capture = require('luatest.capture') local capture = Capture:new() +-- Disable luatest logging to avoid capturing it. +require('luatest.log').info = function() end + g.setup = function() capture:enable() end g.teardown = function() capture:flush() diff --git a/test/capturing_test.lua b/test/capturing_test.lua index 62684f3c..43ab0f7d 100644 --- a/test/capturing_test.lua +++ b/test/capturing_test.lua @@ -1,10 +1,13 @@ local t = require('luatest') local g = t.group() -local helper = require('test.helper') +local helper = require('test.helpers.general') local Capture = require('luatest.capture') local capture = Capture:new() +-- Disable luatest logging to avoid capturing it. +require('luatest.log').info = function() end + g.setup = function() capture:enable() end g.teardown = function() capture:flush() @@ -19,8 +22,8 @@ end local function assert_captured(fn) helper.run_suite(fn) local captured = capture:flush() - t.assert_not_str_contains(captured.stdout, 'test-') - t.assert_not_str_contains(captured.stderr, 'test-') + t.assert_not_str_contains(captured.stdout, 'test-out') + t.assert_not_str_contains(captured.stderr, 'test-err') assert_capture_restored() end diff --git a/test/cbuilder_test.lua b/test/cbuilder_test.lua new file mode 100644 index 00000000..06e93bff --- /dev/null +++ b/test/cbuilder_test.lua @@ -0,0 +1,253 @@ +local t = require('luatest') + +local config_builder = require('luatest.cbuilder') +local utils = require('luatest.utils') + +local DEFAULT_CONFIG = { + credentials = { + users = { + client = {password = 'secret', roles = {'super'}}, + replicator = {password = 'secret', roles = {'replication'}}, + }, + }, + iproto = { + advertise = {peer = {login = 'replicator'}}, + listen = {{uri = 'unix/:./{{ instance_name }}.iproto'}}, + }, + replication = {timeout = 0.1}, +} + +local function merge_config(base, diff) + if type(base) ~= 'table' or type(diff) ~= 'table' then + return diff + end + local result = table.copy(base) + for k, v in pairs(diff) do + result[k] = merge_config(result[k], v) + end + return result +end + +local g = t.group() + +g.test_default_config = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + t.assert_equals(config_builder:new():config(), DEFAULT_CONFIG) + t.assert_equals(config_builder:new({}):config(), {}) +end + +g.test_set_global_option = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + local config = config_builder:new() + :set_global_option('replication.timeout', 0.5) + :set_global_option('console.enabled', false) + :set_global_option('credentials.users.guest.privileges', { + {permissions = {'read', 'write'}, spaces = {'src'}}, + {permissions = {'read', 'write'}, spaces = {'dest'}}, + }) + :config() + t.assert_equals(config, merge_config(DEFAULT_CONFIG, { + replication = {timeout = 0.5}, + console = {enabled = false}, + credentials = { + users = { + guest = { + privileges = { + {permissions = {'read', 'write'}, spaces = {'src'}}, + {permissions = {'read', 'write'}, spaces = {'dest'}}, + }, + }, + }, + }, + })) + local builder = config_builder:new() + t.assert_error_msg_contains( + 'Unexpected data type for a record: "string"', + builder.set_global_option, builder, 'replication', 'bar') + t.assert_error_msg_contains( + 'Expected "boolean", got "string"', + builder.set_global_option, builder, 'replication.anon', 'bar') +end + +g.test_add_instance = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + local config = config_builder:new() + :add_instance('foo', {}) + :add_instance('bar', { + replication = {anon = true}, + }) + :config() + t.assert_equals(config, merge_config(DEFAULT_CONFIG, { + groups = { + ['group-001'] = { + replicasets = { + ['replicaset-001'] = { + instances = { + foo = {}, + bar = {replication = {anon = true}}, + }, + }, + }, + }, + }, + })) + local builder = config_builder:new() + t.assert_error_msg_contains( + 'Unexpected data type for a record: "string"', + builder.add_instance, builder, 'foo', {replication = 'bar'}) + t.assert_error_msg_contains( + 'Expected "boolean", got "string"', + builder.add_instance, builder, 'foo', {replication = {anon = 'bar'}}) +end + +g.test_set_instance_option = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + local config = config_builder:new() + :add_instance('foo', {}) + :set_instance_option('foo', 'database.mode', 'rw') + :add_instance('bar', { + replication = {anon = true}, + }) + :set_instance_option('bar', 'replication.anon', false) + :set_instance_option('bar', 'replication.election_mode', 'off') + :config() + t.assert_equals(config, merge_config(DEFAULT_CONFIG, { + groups = { + ['group-001'] = { + replicasets = { + ['replicaset-001'] = { + instances = { + foo = {database = {mode = 'rw'}}, + bar = { + replication = { + anon = false, + election_mode = 'off', + }, + }, + }, + }, + }, + }, + }, + })) + local builder = config_builder:new():add_instance('foo', {}) + t.assert_error_msg_contains( + 'Unexpected data type for a record: "string"', + builder.set_instance_option, builder, 'foo', 'replication', 'bar') + t.assert_error_msg_contains( + 'Expected "boolean", got "string"', + builder.set_instance_option, builder, 'foo', 'replication.anon', 'bar') +end + +g.test_set_replicaset_option = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + local config = config_builder:new() + :add_instance('foo', {}) + :set_replicaset_option('leader', 'foo') + :set_replicaset_option('replication.failover', 'manual') + :set_replicaset_option('replication.timeout', 0.5) + :config() + t.assert_equals(config, merge_config(DEFAULT_CONFIG, { + groups = { + ['group-001'] = { + replicasets = { + ['replicaset-001'] = { + leader = 'foo', + replication = { + failover = 'manual', + timeout = 0.5, + }, + instances = {foo = {}}, + }, + }, + }, + }, + })) + local builder = config_builder:new():add_instance('foo', {}) + t.assert_error_msg_contains( + 'Unexpected data type for a record: "string"', + builder.set_replicaset_option, builder, 'replication', 'bar') + t.assert_error_msg_contains( + 'Expected "boolean", got "string"', + builder.set_replicaset_option, builder, 'replication.anon', 'bar') +end + +g.test_custom_group_and_replicaset = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + local config = config_builder:new() + :use_group('group-a') + + :use_replicaset('replicaset-x') + :set_replicaset_option('replication.failover', 'manual') + :set_replicaset_option('leader', 'instance-x1') + :add_instance('instance-x1', {}) + :add_instance('instance-x2', {}) + :set_instance_option('instance-x1', 'memtx.memory', 100000000) + + :use_replicaset('replicaset-y') + :set_replicaset_option('replication.failover', 'manual') + :set_replicaset_option('leader', 'instance-y1') + :add_instance('instance-y1', {}) + :add_instance('instance-y2', {}) + :set_instance_option('instance-y1', 'memtx.memory', 100000000) + + :use_group('group-b') + + :use_replicaset('replicaset-z') + :set_replicaset_option('replication.failover', 'manual') + :set_replicaset_option('leader', 'instance-z1') + :add_instance('instance-z1', {}) + :add_instance('instance-z2', {}) + :set_instance_option('instance-z1', 'memtx.memory', 100000000) + + :config() + + t.assert_equals(config, merge_config(DEFAULT_CONFIG, { + groups = { + ['group-a'] = { + replicasets = { + ['replicaset-x'] = { + leader = 'instance-x1', + replication = {failover = 'manual'}, + instances = { + ['instance-x1'] = {memtx = {memory = 100000000}}, + ['instance-x2'] = {}, + }, + }, + ['replicaset-y'] = { + leader = 'instance-y1', + replication = {failover = 'manual'}, + instances = { + ['instance-y1'] = {memtx = {memory = 100000000}}, + ['instance-y2'] = {}, + }, + }, + }, + }, + ['group-b'] = { + replicasets = { + ['replicaset-z'] = { + leader = 'instance-z1', + replication = {failover = 'manual'}, + instances = { + ['instance-z1'] = {memtx = {memory = 100000000}}, + ['instance-z2'] = {}, + }, + }, + }, + }, + }, + })) +end diff --git a/test/cluster_test.lua b/test/cluster_test.lua new file mode 100644 index 00000000..74bb6ae2 --- /dev/null +++ b/test/cluster_test.lua @@ -0,0 +1,459 @@ +local t = require('luatest') +local cbuilder = require('luatest.cbuilder') +local cluster = require('luatest.cluster') +local utils = require('luatest.utils') +local fio = require('fio') + +local g = t.group() + +local root = fio.dirname(fio.abspath('test.helpers')) + +-- These are extra server opts passed to the cluster. +-- They are needed for the server to be able to access +-- luatest.coverage. +local server_opts = { + env = { + LUA_PATH = root .. '/?.lua;' .. + root .. '/?/init.lua;' .. + root .. '/.rocks/share/tarantool/?.lua', + } +} + +local function assert_instance_running(c, instance, replicaset) + local server = c[instance] + t.assert(type(server) == 'table') + + t.assert_equals(server:eval('return box.info.name'), instance) + + if replicaset ~= nil then + t.assert_equals(server:eval('return box.info.replicaset.name'), + replicaset) + end +end + +local function assert_instance_stopped(c, instance) + local server = c[instance] + t.assert(type(server) == 'table') + t.assert_is(server.process, nil) +end + +g.test_start_stop = function() + local function assert_instance_is_ro(c, instance, is_ro) + local server = c[instance] + t.assert(type(server) == 'table') + + t.assert_equals(server:eval('return box.info.ro'), is_ro) + end + + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + + local config = cbuilder:new() + :use_group('group-a') + :use_replicaset('replicaset-x') + :set_replicaset_option('replication.failover', 'manual') + :set_replicaset_option('leader', 'instance-x1') + :add_instance('instance-x1', {}) + :add_instance('instance-x2', {}) + + :use_group('group-b') + :use_replicaset('replicaset-y') + :set_replicaset_option('replication.failover', 'manual') + :set_replicaset_option('leader', 'instance-y1') + :add_instance('instance-y1', {}) + :add_instance('instance-y2', {}) + + :config() + + local c = cluster:new(config, server_opts) + c:start() + + assert_instance_running(c, 'instance-x1', 'replicaset-x') + assert_instance_running(c, 'instance-x2', 'replicaset-x') + assert_instance_running(c, 'instance-y1', 'replicaset-y') + assert_instance_running(c, 'instance-y2', 'replicaset-y') + + assert_instance_is_ro(c, 'instance-x1', false) + assert_instance_is_ro(c, 'instance-x2', true) + assert_instance_is_ro(c, 'instance-y1', false) + assert_instance_is_ro(c, 'instance-y2', true) + + c:stop() + + assert_instance_stopped(c, 'instance-x1') + assert_instance_stopped(c, 'instance-x2') + assert_instance_stopped(c, 'instance-y1') + assert_instance_stopped(c, 'instance-y2') +end + +g.test_start_instance = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + + t.assert_equals(g.cluster, nil) + + local config = cbuilder:new() + :use_group('g-001') + :use_replicaset('r-001') + :add_instance('i-001', {}) + :use_replicaset('r-002') + :add_instance('i-002', {}) + + :use_group('g-002') + :use_replicaset('r-003') + :add_instance('i-003', {}) + + :config() + + local c = cluster:new(config, server_opts) + + t.assert_equals(c:size(), 3) + c:start_instance('i-002') + + assert_instance_running(c, 'i-002') + + assert_instance_stopped(c, 'i-001') + assert_instance_stopped(c, 'i-003') + + c:stop() + + assert_instance_stopped(c, 'i-002') +end + +g.test_manual_lifecycle = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + + local config = cbuilder:new() + :use_group('cluster') + :use_replicaset('cluster-rs') + :add_instance('cluster-1', {}) + :config() + + local c1 = cluster:new(config, server_opts, {auto_cleanup = false}) + + t.assert_equals(g._cluster, nil) + + c1:start() + assert_instance_running(c1, 'cluster-1') + c1:drop() + + local c2 = cluster:new(config, server_opts, {auto_cleanup = false}) + + t.assert_equals(g._cluster, nil) + + c2:start() + assert_instance_running(c2, 'cluster-1') + c2:drop() +end + +g.test_sync = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + + t.assert_equals(g._cluster, nil) + + local config = cbuilder:new() + :use_group('g-001') + :use_replicaset('r-001') + :add_instance('i-001', {}) + :config() + + local c = cluster:new(config, server_opts) + + t.assert_equals(c:size(), 1) + + c:start() + assert_instance_running(c, 'i-001') + + local config2 = cbuilder:new() + :use_group('g-001') + :use_replicaset('r-001') + :add_instance('i-002', {}) + + :use_group('g-002') + :use_replicaset('r-002') + :add_instance('i-003', {}) + + :config() + + local server1 = c['i-001'] + + c:sync(config2) + + -- Check that the server that was removed from the config is expelled + -- from the cluster but not stopped. + t.assert_equals(c:size(), 2) + t.assert_is(c['i-001'], nil) + t.assert_is_not(server1.process, nil) + + -- Check that the new servers are not started. + assert_instance_stopped(c, 'i-002') + assert_instance_stopped(c, 'i-003') + + c:start() + assert_instance_running(c, 'i-002') + assert_instance_running(c, 'i-003') + + -- Check config reload works after sync. + c:reload() + + c:stop() + assert_instance_stopped(c, 'i-002') + assert_instance_stopped(c, 'i-003') + + -- Starting/stopping the cluster shouldn't affect the expelled server. + -- However, dropping the cluster should also drop the expelled server. + t.assert_is_not(server1.process, nil) + c:drop() + t.assert_is(server1.process, nil) +end + +g.test_sync_start_stop = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + + t.assert_equals(g._cluster, nil) + + local config = cbuilder:new() + :use_group('g-001') + :use_replicaset('r-001') + :add_instance('i-001', {}) + :config() + + local c = cluster:new(config, server_opts) + + t.assert_equals(c:size(), 1) + + c:start() + assert_instance_running(c, 'i-001') + + local config2 = cbuilder:new() + :use_group('g-001') + :use_replicaset('r-001') + :add_instance('i-002', {}) + + :use_group('g-002') + :use_replicaset('r-002') + :add_instance('i-003', {}) + + :config() + + local server1 = c['i-001'] + + c:sync(config2, {start_stop = true}) + + -- Check that the server that was removed from the config is expelled + -- from the cluster and stopped. + t.assert_equals(c:size(), 2) + t.assert_is(c['i-001'], nil) + t.assert_is(server1.process, nil) + + -- Check that the new servers are started. + assert_instance_running(c, 'i-002') + assert_instance_running(c, 'i-003') + + -- Check config reload works after sync. + c:reload() +end + +g.test_reload = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + + local function assert_instance_failover_mode(c, instance, mode) + local server = c._server_map[instance] + t.assert_equals( + server:eval('return require("config"):get("replication.failover")'), + mode) + end + + t.assert_equals(g._cluster, nil) + + local config = cbuilder:new() + :set_global_option('replication.failover', 'election') + :use_group('g-001') + :use_replicaset('r-001') + :add_instance('i-001', {}) + :add_instance('i-002', {}) + + :use_replicaset('r-002') + :add_instance('i-003', {}) + + :config() + + local c = cluster:new(config, server_opts) + c:start() + + assert_instance_failover_mode(c, 'i-001', 'election') + assert_instance_failover_mode(c, 'i-002', 'election') + assert_instance_failover_mode(c, 'i-003', 'election') + + local config2 = cbuilder:new(config) + :set_global_option('replication.failover', 'off') + :config() + + c:reload(config2) + + assert_instance_failover_mode(c, 'i-001', 'off') + assert_instance_failover_mode(c, 'i-002', 'off') + assert_instance_failover_mode(c, 'i-003', 'off') +end + +g.test_modify_config = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + + local config = cbuilder:new() + :use_group('group-001') + :use_replicaset('replicaset-001') + :set_replicaset_option('replication.failover', 'manual') + :set_replicaset_option('leader', 'i-001') + :add_instance('i-001', {}) + :config() + + local c = cluster:new(config, server_opts) + + c:start() + + c:modify_config() + :use_group('group-001') + :use_replicaset('replicaset-001') + :add_instance('i-002', {}) + c:apply_config_changes({start_stop = true}) + + assert_instance_running(c, 'i-001', 'replicaset-001') + assert_instance_running(c, 'i-002', 'replicaset-001') + + c:modify_config() + :use_group('group-001') + :use_replicaset('replicaset-001') + :set_replicaset_option('leader', 'i-002') + + local expected_msg = + ':modify_config() was called; apply configuration changes with ' .. + ':apply_config_changes() before calling :reload()' + t.assert_error_msg_contains(expected_msg, c.reload, c) + + expected_msg = + ':modify_config() was called; apply configuration changes with ' .. + ':apply_config_changes() before calling :sync()' + t.assert_error_msg_contains(expected_msg, c.sync, c, config) + + expected_msg = + ':modify_config() was called; apply configuration changes with ' .. + ':apply_config_changes() before calling :config()' + t.assert_error_msg_contains(expected_msg, c.config, c) + + c:apply_config_changes() + + local updated_config = c:config() + local replicaset = + updated_config.groups['group-001'].replicasets['replicaset-001'] + t.assert_equals(replicaset.leader, 'i-002') + t.assert_not_equals(replicaset.instances['i-002'], nil) + + t.assert_equals(c:size(), 2) + + c:stop() + assert_instance_stopped(c, 'i-001') + assert_instance_stopped(c, 'i-002') +end + +g.test_each = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + + local config = cbuilder:new() + :use_group('g-001') + :use_replicaset('r-001') + :add_instance('i-001', {}) + :add_instance('i-002', {}) + + :use_replicaset('r-002') + :add_instance('i-003', {}) + + :config() + + local c = cluster:new(config, server_opts) + + local res = {} + c:each(function(server) + table.insert(res, server.alias) + end) + + t.assert_items_equals(res, {'i-001', 'i-002', 'i-003'}) +end + +g.test_startup_error = function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + + local config = cbuilder:new() + :use_group('g-001') + :use_replicaset('r-001') + :add_instance('i-001', {}) + :set_global_option('app.file', 'non-existent.lua') + :config() + + cluster:startup_error(config, 'No such file') +end + +local g_persistent_clusters = t.group('persistent_clusters') + +g_persistent_clusters.before_all(function() + t.run_only_if(utils.version_current_ge_than(3, 0, 0), + [[Declarative configuration works on Tarantool 3.0.0+. + See tarantool/tarantool@13149d65bc9d for details]]) + + g_persistent_clusters.instances = {} + g_persistent_clusters.pids = {} + + for i = 1, 3 do + local index = tostring(i) + local instance = 'persistent-' .. index .. '-1' + local config = cbuilder:new() + :use_group('persistent-group-' .. index) + :use_replicaset('persistent-rs-' .. index) + :add_instance(instance, {}) + :config() + + local c = cluster:new(config, server_opts, {auto_cleanup = false}) + c:start() + assert_instance_running(c, instance) + + g_persistent_clusters.instances[i] = {cluster = c, instance = instance} + g_persistent_clusters.pids[i] = c[instance].process.pid + end +end) + +g_persistent_clusters.after_all(function() + for _, cdata in ipairs(g_persistent_clusters.instances or {}) do + cdata.cluster:drop() + end +end) + +g_persistent_clusters.test_clusters_survive_between_tests = function() + for _, cdata in ipairs(g_persistent_clusters.instances) do + assert_instance_running(cdata.cluster, cdata.instance) + end +end + +g_persistent_clusters.test_clusters_keep_same_process = function() + for i, cdata in ipairs(g_persistent_clusters.instances) do + local server = cdata.cluster[cdata.instance] + + t.assert_is_not(server.process, nil) + t.assert_equals(server.process.pid, g_persistent_clusters.pids[i]) + assert_instance_running(cdata.cluster, cdata.instance) + end +end diff --git a/test/collect_rs_artifacts_test.lua b/test/collect_rs_artifacts_test.lua new file mode 100644 index 00000000..a785018c --- /dev/null +++ b/test/collect_rs_artifacts_test.lua @@ -0,0 +1,101 @@ +local fio = require('fio') +local t = require('luatest') +local utils = require('luatest.utils') +local ReplicaSet = require('luatest.replica_set') + +local g = t.group() +local Server = t.Server + +local function build_specific_replica_set(alias_suffix) + local rs = ReplicaSet:new() + local box_cfg = { + replication_timeout = 0.1, + replication_connect_timeout = 1, + replication_sync_lag = 0.01, + replication_connect_quorum = 3, + } + + local s1_alias = ('replica1-%s'):format(alias_suffix) + local s2_alias = ('replica2-%s'):format(alias_suffix) + local s3_alias = ('replica3-%s'):format(alias_suffix) + + box_cfg = utils.merge( + table.deepcopy(box_cfg), + { + replication ={ + Server.build_listen_uri(s1_alias, rs.id), + Server.build_listen_uri(s2_alias, rs.id), + Server.build_listen_uri(s3_alias, rs.id) + }}) + + rs:build_and_add_server({alias = s1_alias, box_cfg = box_cfg}) + rs:build_and_add_server({alias = s2_alias, box_cfg = box_cfg}) + rs:build_and_add_server({alias = s3_alias, box_cfg = box_cfg}) + return rs +end + +local function get_replica_set_artifacts_path(rs) + return ('%s/artifacts/%s'):format(rs._server.vardir, rs.id) +end + +local function get_server_artifacts_path_by_alias(rs, position, alias_node) + local rs_artifacts = get_replica_set_artifacts_path(rs) + return ('%s/%s'):format( + rs_artifacts, + rs:get_server(('replica%s-%s'):format(position, alias_node)).id) +end + +local function assert_artifacts_paths(rs, alias_suffix) + t.assert_equals(fio.path.exists(get_replica_set_artifacts_path(rs)), true) + t.assert_equals(fio.path.is_dir(get_replica_set_artifacts_path(rs)), true) + + t.assert_equals( + fio.path.exists(get_server_artifacts_path_by_alias(rs, 1, alias_suffix)), true) + t.assert_equals( + fio.path.is_dir(get_server_artifacts_path_by_alias(rs, 1, alias_suffix)), true) + + t.assert_equals( + fio.path.exists(get_server_artifacts_path_by_alias(rs, 2, alias_suffix)), true) + t.assert_equals( + fio.path.is_dir(get_server_artifacts_path_by_alias(rs, 2, alias_suffix)), true) + + t.assert_equals( + fio.path.exists(get_server_artifacts_path_by_alias(rs, 3, alias_suffix)), true) + t.assert_equals( + fio.path.is_dir(get_server_artifacts_path_by_alias(rs, 3, alias_suffix)), true) +end + +g.before_all(function() + g.rs_all = build_specific_replica_set('all') + + g.rs_all:start() + g.rs_all:wait_for_fullmesh() +end) + +g.before_each(function() + g.rs_each = build_specific_replica_set('each') + + g.rs_each:start() + g.rs_each:wait_for_fullmesh() +end) + +g.before_test('test_foo', function() + g.rs_test = build_specific_replica_set('test') + + g.rs_test:start() + g.rs_test:wait_for_fullmesh() +end) + +g.test_foo = function() + local test = rawget(_G, 'current_test') + + test.status = 'fail' + g.rs_test:drop() + g.rs_each:drop() + g.rs_all:drop() + test.status = 'success' + + assert_artifacts_paths(g.rs_test, 'test') + assert_artifacts_paths(g.rs_each, 'each') + assert_artifacts_paths(g.rs_all, 'all') +end diff --git a/test/collect_server_artifacts_test.lua b/test/collect_server_artifacts_test.lua new file mode 100644 index 00000000..00e39ed9 --- /dev/null +++ b/test/collect_server_artifacts_test.lua @@ -0,0 +1,55 @@ +local fio = require('fio') + +local t = require('luatest') +local g = t.group() + +local Server = t.Server + +local function assert_artifacts_path(s) + t.assert_equals(fio.path.exists(s.artifacts), true) + t.assert_equals(fio.path.is_dir(s.artifacts), true) +end + +g.before_all(function() + g.s_all = Server:new({alias = 'all'}) + g.s_all2 = Server:new({alias = 'all2'}) + + g.s_all:start() + g.s_all2:start() +end) + +g.before_each(function() + g.s_each = Server:new({alias = 'each'}) + g.s_each2 = Server:new({alias = 'each2'}) + + g.s_each:start() + g.s_each2:start() +end) + +g.before_test('test_foo', function() + g.s_test = Server:new({alias = 'test'}) + g.s_test2 = Server:new({alias = 'test2'}) + + g.s_test:start() + g.s_test2:start() +end) + +g.test_foo = function() + local test = rawget(_G, 'current_test') + + test.status = 'fail' + g.s_test:drop() + g.s_test2:drop() + g.s_each:drop() + g.s_each2:drop() + g.s_all:drop() + g.s_all2:drop() + test.status = 'success' + + assert_artifacts_path(g.s_test) + assert_artifacts_path(g.s_test2) + assert_artifacts_path(g.s_each) + assert_artifacts_path(g.s_each2) + assert_artifacts_path(g.s_all) + assert_artifacts_path(g.s_all2) +end diff --git a/test/fixtures/trace.lua b/test/fixtures/trace.lua new file mode 100644 index 00000000..da0cf1f4 --- /dev/null +++ b/test/fixtures/trace.lua @@ -0,0 +1,47 @@ +local fio = require('fio') + +local t = require('luatest') +local server = require('luatest.server') + +local g = t.group('fixtures.trace') + +local root = fio.dirname(fio.abspath('test.helpers')) + +g.before_all(function(cg) + cg.server = server:new{ + env = { + LUA_PATH = root .. '/?.lua;' .. + root .. '/?/init.lua;' .. + root .. '/.rocks/share/tarantool/?.lua' + } + } + cg.server:start() +end) + +g.after_all(function(cg) + cg.server:drop() +end) + +g.test_error = function(cg) + local function outer() + cg.server:exec(function() + local function inner() + error('test error') + end + inner() + end) + end + outer() +end + +g.test_fail = function(cg) + local function outer() + cg.server:exec(function() + local function inner() + t.assert(false) + end + inner() + end) + end + outer() +end diff --git a/test/helper.lua b/test/helpers/general.lua similarity index 96% rename from test/helper.lua rename to test/helpers/general.lua index f6613757..e9a2f91a 100644 --- a/test/helper.lua +++ b/test/helpers/general.lua @@ -2,8 +2,6 @@ local t = require('luatest') local Runner = require('luatest.runner') local utils = require('luatest.utils') -t.configure({shuffle = 'group'}) - local helper = {} function helper.run_suite(load_tests, args) diff --git a/test/hooks_test.lua b/test/hooks_test.lua index 6a7c3255..59e6c2cd 100644 --- a/test/hooks_test.lua +++ b/test/hooks_test.lua @@ -2,7 +2,7 @@ local t = require('luatest') local g = t.group() local Capture = require('luatest.capture') -local helper = require('test.helper') +local helper = require('test.helpers.general') g.test_hooks = function() local hooks = {} @@ -62,6 +62,73 @@ g.test_hooks = function() t.assert_equals(hooks, expected) end +g.test_predefined_hooks = function() + local _hooks = require('luatest.hooks') + local hooks = {} + + _hooks.before_suite_preloaded(function() table.insert(hooks, 'before_suite') end) + _hooks.after_suite_preloaded(function() table.insert(hooks, 'after_suite') end) + _hooks.before_suite_preloaded(function() table.insert(hooks, 'before_suite2') end) + _hooks.after_suite_preloaded(function() table.insert(hooks, 'after_suite2') end) + + _hooks.before_all_preloaded(function() table.insert(hooks, 'before_all') end) + _hooks.after_all_preloaded(function() table.insert(hooks, 'after_all') end) + _hooks.before_all_preloaded(function() table.insert(hooks, 'before_all2') end) + _hooks.after_all_preloaded(function() table.insert(hooks, 'after_all2') end) + + _hooks.before_each_preloaded(function() table.insert(hooks, 'before_each') end) + _hooks.after_each_preloaded(function() table.insert(hooks, 'after_each') end) + _hooks.before_each_preloaded(function() table.insert(hooks, 'before_each2') end) + _hooks.after_each_preloaded(function() table.insert(hooks, 'after_each2') end) + + _hooks.before_suite_preloaded(function() table.insert(hooks, 'before_suite3') end) + _hooks.before_all_preloaded(function() table.insert(hooks, 'before_all3') end) + _hooks.before_all_preloaded(function() table.insert(hooks, 'before_all4') end) + _hooks.after_suite_preloaded(function() table.insert(hooks, 'after_suite3') end) + _hooks.after_all_preloaded(function() table.insert(hooks, 'after_all3') end) + _hooks.after_all_preloaded(function() table.insert(hooks, 'after_all4') end) + + local result = helper.run_suite(function(lu2) + local t2 = lu2.group('test') + t2.before_all(function() table.insert(hooks, 'before_all_inner') end) + lu2.before_suite(function() table.insert(hooks, 'before_suite_inner') end) + lu2.after_suite(function() table.insert(hooks, 'after_suite_inner') end) + t2.after_all(function() table.insert(hooks, 'after_all_inner') end) + t2.before_each(function() table.insert(hooks, 'before_each_inner') end) + t2.after_each(function() table.insert(hooks, 'after_each_inner') end) + t2.test = function() table.insert(hooks, 'test') end + end) + + t.assert_equals(result, 0) + t.assert_equals(hooks, { + "before_suite", + "before_suite2", + "before_suite3", + "before_suite_inner", + "before_all", + "before_all2", + "before_all3", + "before_all4", + "before_all_inner", + "before_each", + "before_each2", + "before_each_inner", + "test", + "after_each_inner", + "after_each2", + "after_each", + "after_all_inner", + "after_all4", + "after_all3", + "after_all2", + "after_all", + "after_suite_inner", + "after_suite3", + "after_suite2", + "after_suite", + }) +end + g.test_hooks_legacy = function() local hooks = {} local expected = {} diff --git a/test/justrun_test.lua b/test/justrun_test.lua new file mode 100644 index 00000000..0686132a --- /dev/null +++ b/test/justrun_test.lua @@ -0,0 +1,94 @@ +local t = require('luatest') +local fio = require('fio') + +local justrun = require('luatest.justrun') +local utils = require('luatest.utils') + +local g = t.group() + +g.before_each(function() + g.tempdir = fio.tempdir() + g.tempfile = fio.pathjoin(g.tempdir, 'main.lua') + + local default_flags = {'O_CREAT', 'O_WRONLY', 'O_TRUNC'} + local default_mode = tonumber('644', 8) + + g.tempfile_fh = fio.open(g.tempfile, default_flags, default_mode) +end) + +g.after_each(function() + fio.rmdir(g.tempdir) +end) + +g.before_test('test_stdout_stderr_output', function() + g.tempfile_fh:write([[ + local log = require('log') + + print('hello stdout!') + log.info('hello stderr!') + ]]) +end) + +g.test_stdout_stderr_output = function() + t.skip_if(not utils.version_current_ge_than(2, 4, 1), + "popen module is available since Tarantool 2.4.1.") + local res = justrun.tarantool(g.tempdir, {}, {g.tempfile}, {nojson = true, stderr = true}) + + t.assert_equals(res.exit_code, 0) + t.assert_str_contains(res.stdout, 'hello stdout!') + t.assert_str_contains(res.stderr, 'hello stderr!') +end + +g.before_test('test_decode_stdout_as_json', function() + g.tempfile_fh:write([[ + print('{"a": 1, "b": 2}') + ]]) +end) + +g.test_decode_stdout_as_json = function() + t.skip_if(not utils.version_current_ge_than(2, 4, 1), + "popen module is available since Tarantool 2.4.1.") + local res = justrun.tarantool(g.tempdir, {}, {g.tempfile}, {nojson = false, stdout = true}) + + t.assert_equals(res.exit_code, 0) + t.assert_equals(res.stdout, {{ a = 1, b = 2}}) +end + +g.before_test('test_bad_exit_code', function() + g.tempfile_fh:write([[ + local magic = require('magic_lib') + ]]) +end) + +g.test_bad_exit_code = function() + t.skip_if(not utils.version_current_ge_than(2, 4, 1), + "popen module is available since Tarantool 2.4.1.") + local res = justrun.tarantool(g.tempdir, {}, {g.tempfile}, {nojson = true, stderr = true}) + + t.assert_equals(res.exit_code, 1) + + t.assert_str_contains(res.stderr, "module 'magic_lib' not found") + t.assert_equals(res.stdout, nil) +end + +g.test_error_when_popen_is_not_available = function() + -- Substitute `require` function to test the behavior of `justrun.tarantool` + -- if the `popen` module is not available (on versions below 2.4.1). + + -- luacheck: push ignore 121 + local old = require + require = function(name) -- ignore: + if name == 'popen' then + return error("module " .. name .. " not found:") + else + return old(name) + end + end + + local _, err = pcall(justrun.tarantool, g.tempdir, {}, {g.tempfile}, {nojson = true}) + + t.assert_str_contains(err, 'module popen not found:') + + require = old + -- luacheck: pop +end diff --git a/test/luatest_test.lua b/test/luatest_test.lua index 6ab19963..ba6e7507 100644 --- a/test/luatest_test.lua +++ b/test/luatest_test.lua @@ -1,7 +1,7 @@ local t = require('luatest') local g = t.group() -local helper = require('test.helper') +local helper = require('test.helpers.general') g.test_assert_returns_velue = function() t.assert_equals(t.assert(1), 1) @@ -40,26 +40,26 @@ g.test_assert_is_box_null = function() end g.test_assert_equals_tnt_tuples = function() - t.assert_equals(box.tuple.new(1), box.tuple.new(1)) - t.assert_equals(box.tuple.new(1, 'a', box.NULL), box.tuple.new(1, 'a', box.NULL)) - t.assert_equals(box.tuple.new(1, {'a'}), box.tuple.new(1, {'a'})) - t.assert_equals({box.tuple.new(1)}, {box.tuple.new(1)}) - t.assert_equals({box.tuple.new(1)}, {{1}}) - helper.assert_failure(t.assert_equals, box.tuple.new(1), box.tuple.new(2)) - - t.assert_not_equals(box.tuple.new(1), box.tuple.new(2)) - t.assert_not_equals(box.tuple.new(1, 'a', box.NULL, {}), box.tuple.new(1, 'a')) - t.assert_not_equals(box.tuple.new(1, {'a'}), box.tuple.new(1, {'b'})) - helper.assert_failure(t.assert_not_equals, box.tuple.new(1), box.tuple.new(1)) + t.assert_equals(box.tuple.new({1}), box.tuple.new({1})) + t.assert_equals(box.tuple.new({1, 'a', box.NULL}), box.tuple.new({1, 'a', box.NULL})) + t.assert_equals(box.tuple.new({1, {'a'}}), box.tuple.new({1, {'a'}})) + t.assert_equals({box.tuple.new({1})}, {box.tuple.new({1})}) + t.assert_equals({box.tuple.new({1})}, {{1}}) + helper.assert_failure(t.assert_equals, box.tuple.new({1}), box.tuple.new({2})) + + t.assert_not_equals(box.tuple.new({1}), box.tuple.new({2})) + t.assert_not_equals(box.tuple.new({1, 'a', box.NULL, {}}), box.tuple.new({1, 'a'})) + t.assert_not_equals(box.tuple.new({1, {'a'}}), box.tuple.new({1, {'b'}})) + helper.assert_failure(t.assert_not_equals, box.tuple.new({1}), box.tuple.new({1})) -- Check that other cdata values works fine. t.assert_equals(1ULL, 0ULL + 1) end -g.test_assert_items_equals_tnt_tuples = function() - t.assert_items_equals({box.tuple.new(1)}, {box.tuple.new(1)}) +g.test_assert_items_equals_tnt_tuples_v3 = function() + t.assert_items_equals({box.tuple.new({1})}, {box.tuple.new({1})}) helper.assert_failure_contains('Item values of the tables are not identical', - t.assert_items_equals, {box.tuple.new(1)}, {box.tuple.new(2)}) + t.assert_items_equals, {box.tuple.new({1})}, {box.tuple.new({2})}) end g.test_fail_if_tnt_specific = function() @@ -103,26 +103,25 @@ g.test_success_if_tnt_specific = function() t.assert_equals(helper.assert_failure(t.success_if, {}).status, 'success') end -g.test_assert_aliases = function () - t.assert_is(t.assert, t.assert_eval_to_true) - t.assert_is(t.assert_not, t.assert_eval_to_false) -end - g.test_assert_covers = function() local subject = t.assert_covers subject({a = 1, b = 2, c = 3}, {}) subject({a = 1, b = 2, c = 3}, {a = 1}) subject({a = 1, b = 2, c = 3}, {a = 1, c = 3}) subject({a = 1, b = 2, c = 3}, {a = 1, b = 2, c = 3}) + subject({a = {b = 1, c = 2}}, {a = {b = 1}}) + subject({a = 1, b = {c = 2, d = {e = 3, f = 4}}}, {b = {d = {f = 4}}}) subject({a = box.NULL}, {a = box.NULL}) - subject({a = box.tuple.new(1)}, {a = box.tuple.new(1)}) + subject({a = box.tuple.new({1})}, {a = box.tuple.new({1})}) helper.assert_failure(subject, {a = 1, b = 2, c = 3}, {a = 2}) helper.assert_failure(subject, {a = 1, b = 2, c = 3}, {a = 1, b = 1}) helper.assert_failure(subject, {a = 1, b = 2, c = 3}, {a = 1, b = 2, c = 3, d = 4}) helper.assert_failure(subject, {a = 1, b = 2, c = 3}, {d = 1}) + helper.assert_failure(subject, {a = {b = 1, c = 2}}, {a = {b = 2, c = 2}}) + helper.assert_failure(subject, {a = {b = 1, c = 2}}, {a = {b = 1, c = 2, d = 3}}) helper.assert_failure(subject, {a = nil}, {a = box.NULL}) - helper.assert_failure(subject, {a = box.tuple.new(1)}, {a = box.tuple.new(2)}) + helper.assert_failure(subject, {a = box.tuple.new({1})}, {a = box.tuple.new({2})}) helper.assert_failure_contains('Argument 1 and 2 must be tables', subject, {a = 1, b = 2, c = 3}, nil) end @@ -144,9 +143,9 @@ end g.test_assert_items_include = function() local subject = t.assert_items_include - subject({1, box.tuple.new(1)}, {box.tuple.new(1)}) + subject({1, box.tuple.new({1})}, {box.tuple.new({1})}) - helper.assert_failure(subject, {box.tuple.new(1)}, {box.tuple.new(2)}) + helper.assert_failure(subject, {box.tuple.new({1})}, {box.tuple.new({2})}) end g.test_assert_type = function() diff --git a/test/luaunit/assertions_error_test.lua b/test/luaunit/assertions_error_test.lua index 8fa44360..2884cdb6 100644 --- a/test/luaunit/assertions_error_test.lua +++ b/test/luaunit/assertions_error_test.lua @@ -1,9 +1,11 @@ local t = require('luatest') local g = t.group() -local helper = require('test.helper') +local helper = require('test.helpers.general') local assert_failure = helper.assert_failure local assert_failure_equals = helper.assert_failure_equals +local assert_failure_contains = helper.assert_failure_contains +local assert_failure_matches = helper.assert_failure_matches local function f() end @@ -17,6 +19,28 @@ local function f_with_table_error() error(setmetatable({this_table="has error"}, ts)) end +local f_check_trace = function(level) + box.error(box.error.UNKNOWN, level) +end + +local wrapper_line = debug.getinfo(1, 'l').currentline + 2 +local f_check_trace_wrapper = function() + f_check_trace(2) +end + +local _, wrapper_err = pcall(f_check_trace_wrapper) +local box_error_has_level = wrapper_err:unpack().trace[1].line == wrapper_line + +local f_check_success = function() + return {1, 'foo'} +end + +local THIS_MODULE = debug.getinfo(1, 'S').short_src + +g.after_each(function() + t.private.check_trace_module = nil +end) + function g.test_assert_error() local x = 1 @@ -51,6 +75,10 @@ function g.test_assert_error() -- error generated as table t.assert_error(f_with_table_error, 1) + -- test assert failure due to unexpected error trace + t.private.check_trace_module = THIS_MODULE + assert_failure_contains('Unexpected error trace, expected:', + t.assert_error, f_check_trace, 1) end function g.test_assert_errorMsgContains() @@ -64,6 +92,12 @@ function g.test_assert_errorMsgContains() -- error message is a table which converts to a string t.assert_error_msg_contains('This table has error', f_with_table_error, 1) + + -- test assert failure due to unexpected error trace + t.private.check_trace_module = THIS_MODULE + assert_failure_contains('Unexpected error trace, expected:', + t.assert_error_msg_contains, 'bar', f_check_trace, + 1) end function g.test_assert_error_msg_equals() @@ -103,6 +137,11 @@ function g.test_assert_error_msg_equals() -- expected table, error generated as string, no match assert_failure(t.assert_error_msg_equals, {1}, function() error("{1}") end, 33) + + -- test assert failure due to unexpected error trace + t.private.check_trace_module = THIS_MODULE + assert_failure_contains('Unexpected error trace, expected:', + t.assert_error_msg_equals, 'bar', f_check_trace, 1) end function g.test_assert_errorMsgMatches() @@ -117,4 +156,123 @@ function g.test_assert_errorMsgMatches() -- one space added to cause failure assert_failure(t.assert_error_msg_matches, ' This is an error', f_with_error, x) assert_failure(t.assert_error_msg_matches, "This", f_with_table_error, 33) + + -- test assert failure due to unexpected error trace + t.private.check_trace_module = THIS_MODULE + assert_failure_contains('Unexpected error trace, expected:', + t.assert_error_msg_matches, 'bar', f_check_trace, 1) +end + +function g.test_assert_errorCovers() + local actual + local expected + -- function executes successfully + assert_failure_equals('Function successfully returned: {1, "foo"}\n' .. + 'Expected error: {}', t.assert_error_covers, {}, + function() return {1, 'foo'} end) + ---------------- + -- good coverage + ---------------- + t.assert_error_covers({}, error, {}) + t.assert_error_covers({b = 2}, error, {b = 2}) + t.assert_error_covers({b = 2}, error, {a = 1, b = 2}) + actual = {a = 1, b = 2, prev = {x = 3, y = 4}} + expected = {b = 2, prev = {x = 3}} + t.assert_error_covers(expected, error, actual) + actual.prev.prev = {i = 5, j = 6} + expected.prev.prev = {j = 6} + t.assert_error_covers(expected, error, actual) + --------------- + -- bad coverage + --------------- + local msg = 'Error expected: .*\nError received: .*' + assert_failure_matches(msg, t.assert_error_covers, + {b = 2}, error, {a = 1, b = 3}) + assert_failure_matches(msg, t.assert_error_covers, {b = 2}, error, {a = 1}) + assert_failure_matches(msg, t.assert_error_covers, {b = 2}, error, {}) + actual = {a = 1, b = 2, prev = {x = 3, y = 4}} + expected = {b = 2, prev = {x = 4}} + assert_failure_matches(msg, t.assert_error_covers, expected, error, actual) + actual = {a = 1, b = 2, prev = {x = 3, y = 4, prev = {i = 5, j = 6}}} + expected = {b = 2, prev = {x = 3, prev = {i = 6}}} + assert_failure_matches(msg, t.assert_error_covers, expected, error, actual) + -------- + --- misc + -------- + -- several arguments for tested function + local error_args = function(a, b) error({a = a, b = b}) end + t.assert_error_covers({b = 2}, error_args, 1, 2) + -- full error message + assert_failure_equals('Error expected: {b = 2}\n' .. + 'Error received: {a = 1, b = 3}', + t.assert_error_covers, {b = 2}, error, {a = 1, b = 3}) + --------------- + -- corner cases + --------------- + -- strange, but still + t.assert_error_covers('foo', error, 'foo') + -- same but for stacked diagnostics + t.assert_error_covers({b = 2, prev = 'foo'}, + error, {a = 1, b = 2, prev = 'foo'}) + -- actual error is not table + assert_failure_matches(msg, t.assert_error_covers, {}, error, 'foo') + -- expected in not a table + assert_failure_matches(msg, t.assert_error_covers, 2, error, {}) + -- actual error is not indexable + assert_failure_matches(msg, t.assert_error_covers, {}, error, 1LL) + -- actual error prev is not table + assert_failure_matches(msg, t.assert_error_covers, {prev = {}}, + error, {prev = 'foo'}) + -- expected error prev is not table + assert_failure_matches(msg, t.assert_error_covers, {prev = 'foo'}, + error, {prev = {}}) + -- actual error prev is not indexable + assert_failure_matches(msg, t.assert_error_covers, {prev = {}}, + error, {prev = 1LL}) + ------------ + -- box.error + ------------ + t.assert_error_covers({type = 'ClientError', code = 0}, box.error, 0) + local err = box.error.new(box.error.UNKNOWN) + if err.set_prev ~= nil then + err:set_prev(box.error.new(box.error.UNSUPPORTED, 'foo', 'bar')) + expected = { + type = 'ClientError', + code = box.error.UNKNOWN, + prev = {type = 'ClientError', code = box.error.UNSUPPORTED} + } + t.assert_error_covers(expected, box.error, err) + end + + -- test assert failure due to unexpected error trace + t.private.check_trace_module = THIS_MODULE + assert_failure_contains('Unexpected error trace, expected:', + t.assert_error_covers, 'bar', f_check_trace, 1) +end + +function g.test_error_trace_check() + local foo = function(a) error(a) end + -- test when trace check is NOT required + t.assert_error_msg_content_equals('foo', foo, 'foo') + + local ftor = setmetatable({}, { + __call = function(_, ...) return f_check_trace(...) end + }) + t.private.check_trace_module = THIS_MODULE + + -- test when trace check IS required + if box_error_has_level then + t.assert_error_covers({code = box.error.UNKNOWN}, f_check_trace, 2) + t.assert_error_covers({code = box.error.UNKNOWN}, ftor, 2) + end + + -- check if there is no error then the returned value is reported correctly + assert_failure_contains('Function successfully returned: {1, "foo"}', + t.assert_error_msg_equals, 'bar', f_check_success) + -- test assert failure due to unexpected error type + assert_failure_contains('Error raised is not a box.error:', + t.assert_error, foo, 'foo') + -- test assert failure due to unexpected error trace + assert_failure_contains('Unexpected error trace, expected:', + t.assert_error, f_check_trace, 1) end diff --git a/test/luaunit/assertions_test.lua b/test/luaunit/assertions_test.lua index b94369c1..c82acb13 100644 --- a/test/luaunit/assertions_test.lua +++ b/test/luaunit/assertions_test.lua @@ -1,7 +1,7 @@ local t = require('luatest') local g = t.group() -local helper = require('test.helper') +local helper = require('test.helpers.general') local assert_failure = helper.assert_failure local assert_failure_contains = helper.assert_failure_contains @@ -333,6 +333,39 @@ function g.test_assert_items_include() assert_failure(subject, {1,2,3}, {1,1,2,3}) end +function g.test_assert_items_exclude() + local subject = t.assert_items_exclude + assert_failure(subject, {1,2,3}, {3,1,2}) + assert_failure(subject, {one=1,two=2,three=3}, {two=2,one=1,three=3}) + assert_failure(subject, {one=1,two=2,three=3}, {a=1,b=2,c=3}) + assert_failure(subject, {1,2,three=3}, {3,1,two=2}) + + assert_failure(subject, {1,2,3,4}, {3,1,2}) + assert_failure(subject, {1,1,2,3}, {3,1,2}) + assert_failure(subject, {1,2,3}, {1,2,3,4}) + assert_failure(subject, {1,2,3}, {1,1,2,3}) + + assert_failure(subject, nil, {1}) + assert_failure(subject, {}, nil) + assert_failure(subject, 1, {1}) + assert_failure(subject, {}, 1) + assert_failure(subject, 'asd', {one = 'asd', two = 'dsa', three = 1}) + assert_failure(subject, {one = 'asd', two = 'dsa', three = 1}, 'dsa') + assert_failure(subject, 1, 1) + assert_failure(subject, nil, 1) + assert_failure(subject, 1, nil) + + subject({}, {1}) + subject({},{}) + subject({nil},{nil}) + subject({1},{}) + subject({one=1},{}) + subject({},{one=1}) + subject({1, 2, 3},{4, 5, 6, 7}) + subject({one=1, two=2, three=3},{four=4, five=5}) + subject({one=1, 2, 3},{four=4, 5}) +end + function g.test_assert_nan() assert_failure(t.assert_nan, "hi there!") assert_failure(t.assert_nan, nil) diff --git a/test/luaunit/error_msg_test.lua b/test/luaunit/error_msg_test.lua index ac538f74..09c26044 100644 --- a/test/luaunit/error_msg_test.lua +++ b/test/luaunit/error_msg_test.lua @@ -1,7 +1,7 @@ local t = require('luatest') local g = t.group() -local helper = require('test.helper') +local helper = require('test.helpers.general') local assert_failure_matches = helper.assert_failure_matches local assert_failure_contains = helper.assert_failure_contains local assert_failure_equals = helper.assert_failure_equals diff --git a/test/luaunit/utility_test.lua b/test/luaunit/utility_test.lua index 7c9ec947..1afa7b99 100644 --- a/test/luaunit/utility_test.lua +++ b/test/luaunit/utility_test.lua @@ -5,7 +5,7 @@ local fun = require('fun') local Runner = require('luatest.runner') local utils = require('luatest.utils') -local helper = require('test.helper') +local helper = require('test.helpers.general') local assert_failure_matches = helper.assert_failure_matches local function range(start, stop) @@ -610,6 +610,12 @@ function g.test_parse_cmd_line() output_file_name='toto.xml', }) + -- list-test-cases + assert_subject({'--list-test-cases'}, {list_test_cases = true}) + + -- run-test-case + assert_subject({'--run-test-case', 'foo'}, {run_test_case = 'foo'}) + t.assert_error_msg_contains('option: -$', subject, {'-$',}) end @@ -708,6 +714,21 @@ function g.test_filter_tests() included, excluded = subject(testset, {'foo', 'bar', '!t.t.', '%.bar'}) t.assert_equals(included, {testset[2], testset[4], testset[6], testset[7], testset[8]}) t.assert_equals(#excluded, 3) + + -- --run-test-case without patterns + included, excluded = subject(testset, nil, 'toto.foo') + t.assert_equals(included, {testset[1]}) + t.assert_equals(#excluded, 7) + + -- --run-test-case with a matching pattern + included, excluded = subject(testset, {'toto'}, 'toto.foo') + t.assert_equals(included, {testset[1]}) + t.assert_equals(#excluded, 7) + + -- --run-test-case with a non-matching pattern + included, excluded = subject(testset, {'tutu'}, 'toto.foo') + t.assert_equals(included, {}) + t.assert_equals(#excluded, 8) end function g.test_str_match() @@ -826,6 +847,43 @@ function g.test_stripStackTrace() [[stack traceback: luaunit2/example_with_luaunit.lua:124: in function 'test1_withFailure']] ) + + t.assert_equals(subject([[stack traceback: + /tmp/luatest/luatest/utils.lua:55: in function 'luatest_error' + /tmp/luatest/luatest/assertions.lua:66: in function 'failure' + /tmp/luatest/luatest/assertions.lua:71: in function 'fail_fmt' + /tmp/luatest/luatest/assertions.lua:341: in function 'assert_le' + /tmp/luatest/test/example_test.lua:37: in function 'check' + /tmp/luatest/test/example_test.lua:40: in function + [C]: in function 'xpcall' + eval:9: in main chunk + [C]: at 0x5da8d110ced6 + /tmp/luatest/luatest/server.lua:745: in function 'exec' + /tmp/luatest/test/example_test.lua:34: in function 'test.test_fail_server' + /tmp/luatest/luatest/runner.lua:472: in function + [C]: in function 'xpcall' + /tmp/luatest/luatest/runner.lua:471: in function 'super' + /tmp/luatest/luatest/capturing.lua:106: in function 'protected_call' + /tmp/luatest/luatest/runner.lua:559: in function 'super' + /tmp/luatest/luatest/hooks.lua:290: in function 'invoke_test_function' + /tmp/luatest/luatest/runner.lua:554: in function 'super' + ... + [C]: in function 'xpcall' + /tmp/luatest/luatest/utils.lua:39: in function 'run_tests' + /tmp/luatest/luatest/runner.lua:381: in function + [C]: in function 'xpcall' + /tmp/luatest/luatest/capturing.lua:74: in function + [C]: in function 'xpcall' + /tmp/luatest/luatest/runner.lua:55: in function 'fn' + .../vlad/src/tarantool/luatest/luatest/sandboxed_runner.lua:14: in function 'run' + /tmp/luatest/luatest/cli_entrypoint.lua:4: in function + /tmp/luatest/bin/luatest:5: in main chunk]] + ), + [[stack traceback: + /tmp/luatest/test/example_test.lua:37: in function 'check' + /tmp/luatest/test/example_test.lua:40: in function + /tmp/luatest/test/example_test.lua:34: in function 'test.test_fail_server']] + ) end function g.test_eps_value() diff --git a/test/malformed_args_test.lua b/test/malformed_args_test.lua new file mode 100644 index 00000000..38b15e25 --- /dev/null +++ b/test/malformed_args_test.lua @@ -0,0 +1,80 @@ +local fio = require('fio') +local t = require('luatest') + +local g = t.group() +local Server = t.Server + +local root = fio.dirname(fio.abspath('test.helpers')) +local datadir = fio.pathjoin(root, 'tmp', 'malformed_args') +local command = fio.pathjoin(root, 'test', 'server_instance.lua') + +g.before_all(function() + fio.rmtree(datadir) + + local log = fio.pathjoin(datadir, 'malformed_args_server.log') + g.server = Server:new({ + command = command, + workdir = datadir, + env = { + TARANTOOL_LOG = log + }, + http_port = 8186, + net_box_port = 3139, + }) + fio.mktree(g.server.workdir) + + g.server:start() + t.helpers.retrying({timeout = 2}, function() + g.server:http_request('get', '/ping') + end) + + g.server:connect_net_box() +end) + +g.after_all(function() + g.server:drop() + fio.rmtree(datadir) +end) + +g.test_exec_correct_args = function() + local a = g.server:exec(function(a, b) return a + b end, {1, 1}) + t.assert_equals(a, 2) +end + +g.test_exec_no_args = function() + local a = g.server:exec(function() return 1 + 1 end) + t.assert_equals(a, 2) +end + +g.test_exec_specific_args = function() + -- nil + local a = g.server:exec(function(a) return a end) + t.assert_equals(a, nil) + + -- too few args + local b, c = g.server:exec(function(b, c) return b, c end, {1}) + t.assert_equals(b, 1) + t.assert_equals(c, nil) + + -- too many args + local d = g.server:exec(function(d) return d end, {1, 2}) + t.assert_equals(d, 1) +end + +g.test_exec_non_array_args = function() + local function f1() + g.server:exec(function(a, b, c) return a, b, c end, {a="a", 2, 3}) + end + + local function f2() + g.server:exec(function(a, b, c) return a, b, c end, {1, a="a", 2}) + end + + local function f3() + g.server:exec(function(a, b, c) return a, b, c end, {1, 2, a="a"}) + end + + t.assert_error_msg_contains("bad argument #3 for exec at malformed_args_test.lua:66:", f1) + t.assert_error_msg_contains("bad argument #3 for exec at malformed_args_test.lua:70:", f2) + t.assert_error_msg_contains("bad argument #3 for exec at malformed_args_test.lua:74:", f3) +end diff --git a/test/output_test.lua b/test/output_test.lua index 332692de..515ecb58 100644 --- a/test/output_test.lua +++ b/test/output_test.lua @@ -4,7 +4,7 @@ local g = t.group() local Capture = require('luatest.capture') local capture = Capture:new() -local helper = require('test.helper') +local helper = require('test.helpers.general') g.setup = function() capture:enable() end g.teardown = function() diff --git a/test/parametrization_test.lua b/test/parametrization_test.lua index 1e9a49d7..f6e4ada1 100644 --- a/test/parametrization_test.lua +++ b/test/parametrization_test.lua @@ -1,7 +1,7 @@ local t = require('luatest') local g = t.group() -local helper = require('test.helper') +local helper = require('test.helpers.general') g.test_validation = function() t.assert_error_msg_contains( diff --git a/test/pp_test.lua b/test/pp_test.lua index 1f0d60e8..e703b6fa 100644 --- a/test/pp_test.lua +++ b/test/pp_test.lua @@ -1,7 +1,7 @@ local t = require('luatest') local g = t.group() -local helper = require('test.helper') +local helper = require('test.helpers.general') local clock = require('clock') local pp = require('luatest.pp') diff --git a/test/proxy_test.lua b/test/proxy_test.lua new file mode 100644 index 00000000..46b5d877 --- /dev/null +++ b/test/proxy_test.lua @@ -0,0 +1,81 @@ +local t = require('luatest') +local proxy = require('luatest.replica_proxy') +local utils = require('luatest.utils') +local replica_set = require('luatest.replica_set') +local server = require('luatest.server') + +local fiber = require('fiber') + +local g = t.group('proxy-version-check') + +g.test_proxy_errors = function() + t.skip_if(utils.version_current_ge_than(2, 10, 1), + "Proxy works on Tarantool 2.10.1+, nothing to test") + t.assert_error_msg_contains('Proxy requires Tarantool 2.10.1 and newer', + proxy.new, proxy, { + client_socket_path = 'somepath', + server_socket_path = 'somepath' + }) +end + +local g1 = t.group('proxy', { + {is_paused = true}, + {is_paused = false} +}) + +g1.before_all(function(cg) + -- Proxy only works on tarantool 2.10+ + t.run_only_if(utils.version_current_ge_than(2, 10, 1), + [[Proxy works on Tarantool 2.10.1+. + See tarantool/tarantool@57ecb6cd90b4 for details]]) + cg.rs = replica_set:new{} + cg.box_cfg = { + replication_timeout = 0.1, + replication = { + server.build_listen_uri('server2_proxy', cg.rs.id), + }, + } + cg.server1 = cg.rs:build_and_add_server{ + alias = 'server1', + box_cfg = cg.box_cfg, + } + cg.box_cfg.replication = nil + cg.server2 = cg.rs:build_and_add_server{ + alias = 'server2', + box_cfg = cg.box_cfg, + } + cg.proxy = proxy:new{ + client_socket_path = server.build_listen_uri('server2_proxy', cg.rs.id), + server_socket_path = server.build_listen_uri('server2', cg.rs.id), + } + t.assert(cg.proxy:start{force = true}, 'Proxy is started') + cg.rs:start{} +end) + +g1.test_server_disconnect_is_noticed = function(cg) + local id = cg.server2:get_instance_id() + t.helpers.retrying({}, cg.server1.assert_follows_upstream, cg.server1, id) + if cg.params.is_paused then + cg.proxy:pause() + end + cg.server2:stop() + fiber.sleep(cg.box_cfg.replication_timeout) + local upstream = cg.server1:exec(function(upstream_id) + return box.info.replication[upstream_id].upstream + end, {id}) + if cg.params.is_paused then + t.assert_equals(upstream.status, 'follow', + 'Server disconnect is not noticed') + else + t.assert_equals(upstream.system_message, 'Broken pipe', + 'Server disconnect is noticed') + end + if cg.params.is_paused then + cg.proxy:resume() + end + cg.server2:start() +end + +g1.after_all(function(cg) + cg.rs:drop() +end) diff --git a/test/replica_set_test.lua b/test/replica_set_test.lua new file mode 100644 index 00000000..45ca3466 --- /dev/null +++ b/test/replica_set_test.lua @@ -0,0 +1,130 @@ +local fio = require('fio') +local t = require('luatest') +local ReplicaSet = require('luatest.replica_set') + +local g = t.group() +local Server = t.Server + +g.before_each(function() + g.rs = ReplicaSet:new() + g.box_cfg = { + replication_timeout = 0.1, + replication_connect_timeout = 10, + replication_sync_lag = 0.01, + replication_connect_quorum = 3, + replication = { + Server.build_listen_uri('replica1', g.rs.id), + Server.build_listen_uri('replica2', g.rs.id), + Server.build_listen_uri('replica3', g.rs.id), + } + } +end) + +g.before_test('test_save_rs_artifacts_when_test_failed', function() + g.rs:build_and_add_server({alias = 'replica1', box_cfg = g.box_cfg}) + g.rs:build_and_add_server({alias = 'replica2', box_cfg = g.box_cfg}) + g.rs:build_and_add_server({alias = 'replica3', box_cfg = g.box_cfg}) + g.rs:start() + + g.rs_artifacts = ('%s/artifacts/%s'):format(Server.vardir, g.rs.id) + g.s1_artifacts = ('%s/%s'):format(g.rs_artifacts, g.rs:get_server('replica1').id) + g.s2_artifacts = ('%s/%s'):format(g.rs_artifacts, g.rs:get_server('replica2').id) + g.s3_artifacts = ('%s/%s'):format(g.rs_artifacts, g.rs:get_server('replica3').id) +end) + +g.test_save_rs_artifacts_when_test_failed = function() + local test = rawget(_G, 'current_test') + -- the test must be failed to save artifacts + test.status = 'fail' + g.rs:drop() + test.status = 'success' + + t.assert_equals(fio.path.exists(g.rs_artifacts), true) + t.assert_equals(fio.path.is_dir(g.rs_artifacts), true) + + t.assert_equals(fio.path.exists(g.s1_artifacts), true) + t.assert_equals(fio.path.is_dir(g.s1_artifacts), true) + + t.assert_equals(fio.path.exists(g.s2_artifacts), true) + t.assert_equals(fio.path.is_dir(g.s2_artifacts), true) + + t.assert_equals(fio.path.exists(g.s3_artifacts), true) + t.assert_equals(fio.path.is_dir(g.s3_artifacts), true) +end + +g.before_test('test_save_rs_artifacts_when_server_workdir_passed', function() + local s1_workdir = ('%s/%s'):format(Server.vardir, os.tmpname()) + local s2_workdir = ('%s/%s'):format(Server.vardir, os.tmpname()) + local s3_workdir = ('%s/%s'):format(Server.vardir, os.tmpname()) + + g.rs:build_and_add_server({workdir = s1_workdir, alias = 'replica1', box_cfg = g.box_cfg}) + g.rs:build_and_add_server({workdir = s2_workdir, alias = 'replica2', box_cfg = g.box_cfg}) + g.rs:build_and_add_server({workdir = s3_workdir, alias = 'replica3', box_cfg = g.box_cfg}) + g.rs:start() + + g.rs_artifacts = ('%s/artifacts/%s'):format(Server.vardir, g.rs.id) + g.s1_artifacts = ('%s/%s'):format(g.rs_artifacts, g.rs:get_server('replica1').id) + g.s2_artifacts = ('%s/%s'):format(g.rs_artifacts, g.rs:get_server('replica2').id) + g.s3_artifacts = ('%s/%s'):format(g.rs_artifacts, g.rs:get_server('replica3').id) +end) + +g.test_save_rs_artifacts_when_server_workdir_passed = function() + local test = rawget(_G, 'current_test') + -- the test must be failed to save artifacts + test.status = 'fail' + g.rs:drop() + test.status = 'success' + + t.assert_equals(fio.path.exists(g.rs_artifacts), true) + t.assert_equals(fio.path.is_dir(g.rs_artifacts), true) + + t.assert_equals(fio.path.exists(g.s1_artifacts), true) + t.assert_equals(fio.path.is_dir(g.s1_artifacts), true) + + t.assert_equals(fio.path.exists(g.s2_artifacts), true) + t.assert_equals(fio.path.is_dir(g.s2_artifacts), true) + + t.assert_equals(fio.path.exists(g.s3_artifacts), true) + t.assert_equals(fio.path.is_dir(g.s3_artifacts), true) + +end + +g.test_rs_no_socket_collision_with_custom_alias = function() + local s1 = g.rs:build_server({alias = 'foo'}) + local s2 = g.rs:build_server({alias = 'bar'}) + + t.assert(s1.vardir:find(g.rs.id, 1, true)) + t.assert(s2.vardir:find(g.rs.id, 1, true)) + t.assert_equals(s1.net_box_uri, ('%s/foo.sock'):format(s1.vardir)) + t.assert_equals(s2.net_box_uri, ('%s/bar.sock'):format(s2.vardir)) +end + +g.after_test('test_rs_no_socket_collision_with_custom_alias', function() + g.rs:drop() +end) + +g.test_rs_custom_properties_are_not_overridden = function() + local socket = ('%s/custom.sock'):format(Server.vardir) + local workdir = ('%s/custom'):format(Server.vardir) + + local s = g.rs:build_server({net_box_uri = socket, workdir = workdir}) + + t.assert_equals(s.net_box_uri, socket) + t.assert_equals(s.workdir, workdir) +end + +g.after_test('test_rs_custom_properties_are_not_overridden', function() + g.rs:drop() +end) + +g.test_rs_raise_error_when_add_custom_server = function() + local s = Server:new() + + t.assert_error_msg_contains( + 'Server should be built via `ReplicaSet:build_server` function', + function() g.rs:add_server(s) end) +end + +g.after_test('test_rs_raise_error_when_add_custom_server', function() + g.rs:drop() +end) diff --git a/test/runner_test.lua b/test/runner_test.lua index 559d7bda..e31f96b7 100644 --- a/test/runner_test.lua +++ b/test/runner_test.lua @@ -5,7 +5,7 @@ local fio = require('fio') local uuid = require('uuid') local Capture = require('luatest.capture') -local helper = require('test.helper') +local helper = require('test.helpers.general') g.test_run_pass = function() local result = helper.run_suite(function(lu2) @@ -224,3 +224,30 @@ g.test_show_help = function() local captured = capture:flush() t.assert_str_contains(captured.stdout, 'Usage: luatest') end + +g.test_trace = function() + local f = io.popen('bin/luatest test/fixtures/trace.lua') + local output = f:read('*a') + f:close() + t.assert_str_matches( + output, + ".*" .. + "[^\n]*trace%.lua:29: test error[^\n]*\n" .. + "stack traceback:\n" .. + "[^\n]*trace%.lua:29: in function 'inner'\n" .. + "[^\n]*trace%.lua:31: in function <[^\n]*trace%.lua:27>\n" .. + "[^\n]*trace%.lua:27: in function 'outer'\n" .. + "[^\n]*trace%.lua:34: in function 'fixtures%.trace%.test_error'\n" .. + ".*") + t.assert_str_matches( + output, + ".*" .. + "[^\n]*trace%.lua:41: expected: a value evaluating to true, " .. + "actual: false[^\n]*\n" .. + "stack traceback:\n" .. + "[^\n]*trace%.lua:41: in function 'inner'\n" .. + "[^\n]*trace%.lua:43: in function <[^\n]*trace%.lua:39>\n" .. + "[^\n]*trace%.lua:39: in function 'outer'\n" .. + "[^\n]*trace%.lua:46: in function 'fixtures%.trace%.test_fail'\n" .. + ".*") +end diff --git a/test/server_instance.lua b/test/server_instance.lua index 35ee87a9..10aadd40 100755 --- a/test/server_instance.lua +++ b/test/server_instance.lua @@ -5,11 +5,10 @@ local json = require('json') local workdir = os.getenv('TARANTOOL_WORKDIR') local listen = os.getenv('TARANTOOL_LISTEN') local http_port = os.getenv('TARANTOOL_HTTP_PORT') -local log = os.getenv('TARANTOOL_LOG') local httpd = require('http.server').new('0.0.0.0', http_port) -box.cfg({work_dir = workdir, log = log}) +box.cfg({work_dir = workdir}) box.schema.user.grant('guest', 'super', nil, nil, {if_not_exists = true}) box.cfg({listen = listen}) diff --git a/test/server_test.lua b/test/server_test.lua index a618788f..c7493cd6 100644 --- a/test/server_test.lua +++ b/test/server_test.lua @@ -1,21 +1,30 @@ local fio = require('fio') local json = require('json') +local urilib = require('uri') +local yaml = require('yaml') local t = require('luatest') local g = t.group() local utils = require('luatest.utils') +local helper = require('test.helpers.general') + local Process = t.Process local Server = t.Server -local root = fio.dirname(fio.dirname(fio.abspath(package.search('test.helper')))) +local root = fio.dirname(fio.abspath('test.helpers')) local datadir = fio.pathjoin(root, 'tmp', 'db_test') local command = fio.pathjoin(root, 'test', 'server_instance.lua') local server = Server:new({ command = command, workdir = fio.pathjoin(datadir, 'common'), - env = {custom_env = 'test_value'}, + env = { + LUA_PATH = root .. '/?.lua;' .. + root .. '/?/init.lua;' .. + root .. '/.rocks/share/tarantool/?.lua', + custom_env = 'test_value', + }, http_port = 8182, net_box_port = 3133, }) @@ -151,7 +160,7 @@ g.test_net_box = function() t.assert_equals(server.net_box.state, 'active') server:eval('function f(x,y) return {x, y} end;') - t.assert_equals(server:call('f', {1,'test'}), {1, 'test'}) + t.assert_equals(server:call('f', {1, 'test'}), {1, 'test'}) server.net_box:close() t.assert_error_msg_equals('Connection closed', server.eval, server, '') @@ -282,8 +291,8 @@ g.test_max_unix_socket_path_exceeded = function() t.assert_equals(string.len(net_box_uri), max_unix_socket_path[system] + 1) t.assert_error_msg_contains( - string.format('Net box URI must be <= max Unix domain socket path length (%s chars)', - max_unix_socket_path[system]), + string.format('Unix domain socket path cannot be longer than %d ' .. + 'chars. Current path is:', max_unix_socket_path[system]), Server.new, Server, { command = command, workdir = workdir, @@ -293,6 +302,79 @@ g.test_max_unix_socket_path_exceeded = function() ) end +g.test_unix_socket_not_include_uri_fields = function() + local max_unix_socket_path = {linux = 107, other = 103} + local system = os.execute('[ $(uname) = Linux ]') == 0 and 'linux' or + 'other' + local workdir = fio.pathjoin(datadir, 'unix_socket') + fio.mktree(workdir) + local workdir_len = string.len(workdir) + local socket_name_len = max_unix_socket_path[system] + 1 - workdir_len + local socket_name = string.format('test_socket%s.sock', + string.rep('t', socket_name_len - 18)) + local socket_path = fio.pathjoin(workdir, socket_name) + t.assert_equals(string.len(socket_path), max_unix_socket_path[system]) + local net_box_uri = 'unix/:' .. socket_path .. '?three=1&four=2' + local s = Server:new({ + command = command, + workdir = workdir, + net_box_uri = net_box_uri, + http_port = 0, -- unused + }) + s:start() + t.helpers.retrying({}, function() s:connect_net_box() end) + t.assert_equals(s:exec(function() return box.cfg.listen end), net_box_uri) + s:stop() +end + +g.test_table_uri_success = function() + t.skip_if(not utils.version_current_ge_than(2, 10, 0), + "URI as a table is supported since Tarantool 2.10.0.") + local workdir = fio.pathjoin(datadir, 'unix_socket') + fio.mktree(workdir) + local net_box_uri = { + uri = 'unix/:' .. fio.pathjoin(workdir, '/test_socket.sock'), + params = { + transport = 'plain' + }, + } + local res = urilib.format(urilib.parse(net_box_uri)) + local s = Server:new({ + command = command, + workdir = workdir, + net_box_uri = net_box_uri, + http_port = 0, -- unused + }) + s:start() + t.helpers.retrying({}, function() s:connect_net_box() end) + t.assert_equals(s:exec(function() return box.cfg.listen end), res) + s:stop() +end + +g.test_table_uri_error = function() + t.skip_if(utils.version_current_ge_than(2, 10, 0), + "URI as a table is supported since Tarantool 2.10.0.") + local workdir = fio.pathjoin(datadir, 'unix_socket') + fio.mktree(workdir) + local net_box_uri = { + login = 'guest', + uri = 'unix/:' .. fio.pathjoin(workdir, '/test_socket.sock'), + params = { + transport = 'plain' + }, + } + local err = [[bad argument #2 to 'uri_parse' (cannot convert 'table' ]] .. + [[to 'const char *')]] + t.assert_error_msg_contains( + err, Server.new, Server, { + command = command, + workdir = workdir, + net_box_uri = net_box_uri, + http_port = 0, -- unused + } + ) +end + g.test_server_start_with_coverage_enabled = function() t.skip_if(server.coverage_report, 'Coverage is already enabled. Nothing to test') server:restart({coverage_report = true}) @@ -301,3 +383,265 @@ g.test_server_start_with_coverage_enabled = function() server:exec(function() return box.info.status end), 'running' ) end + +g.test_wait_when_server_is_not_running_by_bad_option = function() + local s1 = Server:new({ + box_cfg = { + bad_option = 'bad' + } + }) + local s2 = Server:new({ + box_cfg = { + replication = { + 'bad_uri' + } + } + }) + + local expected_msg = 'Process is terminated when waiting for "server is ready"' + + local status, msg = pcall(Server.start, s1) + t.assert_equals(status, false) + t.assert_str_contains(msg, expected_msg) + t.assert_equals(s1.process:is_alive(), false) + s1:drop() + + status, msg = pcall(Server.start, s2) + t.assert_equals(status, false) + t.assert_str_contains(msg, expected_msg) + t.assert_equals(s2.process:is_alive(), false) + s2:drop() +end + +g.test_drop_server_if_process_is_dead = function() + local s = Server:new({ + box_cfg = { + bad_option = 'bad' + } + }) + local status, _ = pcall(Server.start, s) + t.assert_equals(status, false) + t.assert_equals(s.process:is_alive(), false) + + s:drop() +end + +g.test_save_server_artifacts_when_test_failed = function() + local s1 = Server:new() -- empty config + local s2 = Server:new( + {workdir = ('%s/%s'):format(Server.vardir, os.tmpname())} + ) -- workdir passed + + s1:start() + s2:start() + + local s1_artifacts = ('%s/artifacts/%s'):format(s1.vardir, s1.id) + local s2_artifacts = ('%s/artifacts/%s'):format(s2.vardir, s2.id) + local test = rawget(_G, 'current_test') + + -- the test must be failed to save artifacts + test.status = 'fail' + s1:drop() + s2:drop() + test.status = 'success' + + t.assert_equals(fio.path.exists(s1_artifacts), true) + t.assert_equals(fio.path.is_dir(s1_artifacts), true) + + t.assert_equals(fio.path.exists(s2_artifacts), true) + t.assert_equals(fio.path.is_dir(s2_artifacts), true) +end + +g.test_server_build_listen_uri = function() + local uri = Server.build_listen_uri('foo') + t.assert_equals(uri, ('%s/foo.sock'):format(Server.vardir)) + + local uri_extra = Server.build_listen_uri('foo', 'bar') + t.assert_equals(uri_extra, ('%s/bar/foo.sock'):format(Server.vardir)) +end + +g.before_test('test_no_socket_collision_with_default_alias', function() + g.s1 = Server:new() + g.s2 = Server:new() + + g.s1:start() + g.s2:start() +end) + +g.test_no_socket_collision_with_default_alias = function() + g.s1:exec(function() rawset(_G, 'foo', 'foo-value') end) + local foo = g.s2:exec(function() rawget(_G, 'foo') end) + + t.assert_equals(foo, nil) + t.assert_not_equals(g.s1.net_box_uri, g.s2.net_box_uri) +end + +g.after_test('test_no_socket_collision_with_default_alias', function() + g.s1:drop() + g.s2:drop() +end) + +g.test_no_socket_collision_with_duplicate_alias = function() + g.s1 = Server:new({alias = 'foo'}) + g.s2 = Server:new({alias = 'foo'}) + + t.assert_not_equals(g.s1.net_box_uri, g.s2.net_box_uri) +end + +g.after_test('test_no_socket_collision_with_duplicate_alias', function() + g.s1:drop() + g.s2:drop() +end) + +g.test_net_box_uri_is_taken_from_matching_replicaset = function() + local tempdir = fio.tempdir() + local config_path = fio.pathjoin(tempdir, 'config.yaml') + + local config = { + groups = { + ['group-001'] = { + replicasets = { + ['rs-1'] = { + iproto = { + listen = {{ + uri = 'unix/:./{{ instance_name }}.iproto' + }}, + }, + instances = { + ['router-1'] = {}, + }, + }, + ['rs-2'] = { + iproto = { + listen = {{ + uri = 'unix/:./{{ instance_name }}.iproto' + }}, + }, + instances = { + ['storage-1'] = {}, + }, + }, + }, + }, + }, + } + + local fh = fio.open(config_path, {'O_CREAT', 'O_WRONLY', 'O_TRUNC'}, + tonumber('644', 8)) + fh:write(yaml.encode(config)) + fh:close() + + local s1 = Server:new({ + alias = 'storage-1', + config_file = config_path, + }) + local s2 = Server:new({ + alias = 'router-1', + config_file = config_path, + }) + + t.assert_equals(s1.net_box_uri, 'unix/:nil/storage-1.iproto') + t.assert_equals(s2.net_box_uri, 'unix/:nil/router-1.iproto') + s1:drop() + s2:drop() + + fio.rmtree(tempdir) +end + +g.test_netbox_uri_is_not_overridden = function() + local socket = ('%s/my-custom.sock'):format(Server.vardir) + g.s1 = Server:new({net_box_uri = socket}) + + t.assert_equals(g.s1.net_box_uri, socket) +end + +g.after_test('test_netbox_uri_is_not_overridden', function() + g.s1:drop() +end) + +g.before_test('test_error_level_is_correct', function() + g.s = Server:new() + g.s:start() +end) + +g.test_error_level_is_correct = function() + local c = require('net.box').connect(g.s.net_box_uri) + + t.assert_error_msg_contains( -- error in exec + "My error", g.s.exec, g.s, + function() error("My error") end) + + t.assert_error_msg_contains( -- error in eval + "eval", g.s.eval, g.s, + [[error("My error")]]) + + t.assert_error_msg_contains( -- error in closures + "My error", g.s.exec, g.s, + function() + local function internal() error("My error") end + internal() + end) + + t.assert_error_msg_contains( -- error in tx netbox connection + "My error", c.eval, c, + [[box.begin() error("My error")]]) + + t.assert_error_msg_contains( -- error in tx eval + "My error", g.s.eval, g.s, + [[box.begin() error("My error")]]) + + t.assert_error_msg_contains( -- error in tx exec + "My error", g.s.exec, g.s, + function() box.begin() error("My error") end) + + t.assert_error_msg_contains( -- error in tx closures + "My error", g.s.exec, g.s, + function() + local function internal() box.begin() error("My error") end + internal() + end) +end + +g.after_test('test_error_level_is_correct', function() + g.s:drop() +end) + +g.test_grep_log = function() + server:connect_net_box() + + -- Test that grep_log just works. + server:exec(function() require('log').info('test grep_log') end) + t.assert(server:grep_log('test grep_log')) + + -- By default opts.reset in server:grep_log() is true, so we + -- should not find the message after instance restart. + server:restart() + t.helpers.retrying({}, function() server:http_request('get', '/ping') end) + server:connect_net_box() + t.assert_not(server:grep_log('test grep_log')) + + -- Test that opts.reset = false works. + t.assert(server:grep_log('test grep_log', nil, {reset = false})) + + server.net_box:close() + server.net_box = nil +end + +g.before_test('test_assertion_failure', function() + -- The compat module option may be unavailable. + pcall(function() + local compat = require('compat') + compat.box_error_serialize_verbose = 'new' + end) +end) + +g.after_test('test_assertion_failure', function() + pcall(function() + require('compat').box_error_serialize_verbose = 'default' + end) +end) + +g.test_assertion_failure = function() + server:connect_net_box() + helper.assert_failure(server.exec, server, function() t.assert(false) end) +end diff --git a/test/treegen_test.lua b/test/treegen_test.lua new file mode 100644 index 00000000..5d19de83 --- /dev/null +++ b/test/treegen_test.lua @@ -0,0 +1,48 @@ +local t = require('luatest') +local fio = require('fio') + +local treegen = require('luatest.treegen') + +local g = t.group() + + +local function assert_file_content_equals(file, expected) + local fh = fio.open(file) + t.assert_equals(fh:read(), expected) +end + +g.test_prepare_directory = function() + treegen.add_template('^.*$', 'test_script') + local dir = treegen.prepare_directory({'foo/bar.lua', 'baz.lua'}) + + t.assert(fio.path.is_dir(dir)) + t.assert(fio.path.exists(dir)) + + t.assert(fio.path.exists(fio.pathjoin(dir, 'foo', 'bar.lua'))) + t.assert(fio.path.exists(fio.pathjoin(dir, 'baz.lua'))) + + assert_file_content_equals(fio.pathjoin(dir, 'foo', 'bar.lua'), 'test_script') + assert_file_content_equals(fio.pathjoin(dir, 'baz.lua'), 'test_script') +end + +g.before_test('test_clean_keep_data', function() + treegen.add_template('^.*$', 'test_script') + + os.setenv('KEEP_DATA', 'true') + + g.dir = treegen.prepare_directory(g, {'foo.lua'}) + + t.assert(fio.path.is_dir(g.dir)) + t.assert(fio.path.exists(g.dir)) +end) + +g.test_clean_keep_data = function() + t.assert(fio.path.is_dir(g.dir)) + t.assert(fio.path.exists(g.dir)) +end + +g.after_test('test_clean_keep_data', function() + os.setenv('KEEP_DATA', '') + t.assert(fio.path.is_dir(g.dir)) + t.assert(fio.path.exists(g.dir)) +end) diff --git a/test/utils_test.lua b/test/utils_test.lua new file mode 100644 index 00000000..0c451223 --- /dev/null +++ b/test/utils_test.lua @@ -0,0 +1,32 @@ +local t = require('luatest') +local g = t.group() + +local utils = require('luatest.utils') + +g.test_is_tarantool_binary = function() + local cases = { + {'/usr/bin/tarantool', true}, + {'/usr/local/bin/tarantool', true}, + {'/usr/local/bin/tt', false}, + {'/usr/bin/ls', false}, + {'/home/myname/app/bin/tarantool', true}, + {'/home/tarantool/app/bin/go-server', false}, + {'/usr/bin/tarantool-ee_gc64-2.11.0-0-r577', true}, + {'/home/tarantool/app/bin/tarantool', true}, + {'/home/tarantool/app/bin/tarantool-ee_gc64-2.11.0-0-r577', true}, + } + + for _, case in ipairs(cases) do + local path, result = unpack(case) + t.assert_equals(utils.is_tarantool_binary(path), result, + ("Unexpected result for %q"):format(path)) + end +end + +g.test_table_pack = function() + t.assert_equals(utils.table_pack(), {n = 0}) + t.assert_equals(utils.table_pack(1), {n = 1, 1}) + t.assert_equals(utils.table_pack(1, 2), {n = 2, 1, 2}) + t.assert_equals(utils.table_pack(1, 2, nil), {n = 3, 1, 2}) + t.assert_equals(utils.table_pack(1, 2, nil, 3), {n = 4, 1, 2, nil, 3}) +end diff --git a/test/xfail_test.lua b/test/xfail_test.lua index 3eb8dfac..5c51cf78 100644 --- a/test/xfail_test.lua +++ b/test/xfail_test.lua @@ -1,7 +1,7 @@ local t = require('luatest') local g = t.group() -local helper = require('test.helper') +local helper = require('test.helpers.general') g.test_failed = function() local result = helper.run_suite(function(lu2)