--!A cross-platform build utility based on Lua -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. -- You may obtain a copy of the License at -- -- http://www.apache.org/licenses/LICENSE-2.0 -- -- Unless required by applicable law or agreed to in writing, software -- distributed under the License is distributed on an "AS IS" BASIS, -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -- See the License for the specific language governing permissions and -- limitations under the License. -- -- Copyright (C) 2015-present, Xmake Open Source Community. -- -- @author ruki -- @file main.lua -- -- imports import("core.base.option") import("core.base.task") import("core.project.config") import("core.base.global") import("core.project.project") import("core.platform.platform") import("core.theme.theme") import("async.runjobs") import("private.action.run.runenvs") import("private.service.remote_build.action", {alias = "remote_build_action"}) import("actions.build.main", {rootdir = os.programdir(), alias = "build_action"}) import("utils.progress") -- test target function _do_test_target(target, opt) opt = opt or {} -- early out: results were computed during build if opt.build_should_fail or opt.build_should_pass then if opt.errors then vprint(opt.errors) end return opt.passed end -- get run environments local envs = opt.runenvs if not envs then local addenvs, setenvs = runenvs.make(target) envs = runenvs.join(addenvs, setenvs) end -- run test local outdata local errors local rundir = opt.rundir or target:rundir() local targetfile = path.absolute(target:targetfile()) local runargs = table.wrap(opt.runargs or target:get("runargs")) local autogendir = path.join(target:autogendir(), "tests") local logname = opt.name:gsub("[/\\>=<|%*]", "_") local outfile = path.absolute(path.join(autogendir, logname .. ".out")) local errfile = path.absolute(path.join(autogendir, logname .. ".err")) os.tryrm(outfile) os.tryrm(errfile) os.mkdir(autogendir) local should_fail = opt.should_fail or false local run_timeout = opt.run_timeout local ok, syserrors = os.execv(targetfile, runargs, {try = true, timeout = run_timeout, curdir = rundir, envs = envs, stdout = outfile, stderr = errfile}) local outdata = os.isfile(outfile) and io.readfile(outfile) or "" local errdata = os.isfile(errfile) and io.readfile(errfile) or "" if outdata and #outdata > 0 then opt.stdout = outdata end if errdata and #errdata > 0 then opt.stderr = errdata end if opt.trim_output then outdata = outdata:trim() end if ok ~= 0 then if not errors or #errors == 0 then if ok ~= nil then if syserrors then errors = string.format("run %s failed, exit code: %d, exit error: %s", opt.name, ok, syserrors) else errors = string.format("run %s failed, exit code: %d", opt.name, ok) end else errors = string.format("run %s failed, exit error: %s", opt.name, syserrors and syserrors or "unknown reason") end end end os.tryrm(outfile) os.tryrm(errfile) local passed if ok == 0 then local pass_outputs = table.wrap(opt.pass_outputs) local fail_outputs = table.wrap(opt.fail_outputs) for _, pass_output in ipairs(pass_outputs) do if opt.plain then if pass_output == outdata then passed = true break end else if outdata:match("^" .. pass_output .. "$") then passed = true break end end end for _, fail_output in ipairs(fail_outputs) do if opt.plain then if fail_output == outdata then passed = false if not errors or #errors == 0 then errors = string.format("matched failed output: ${color.failure}%s${clear}", fail_output) local actual_output = outdata if not option.get("diagnosis") then actual_output = outdata:sub(1, 64) if #outdata > #actual_output then actual_output = actual_output .. "..." end end errors = errors .. ", actual output: ${color.failure}" .. actual_output end break end else if outdata:match("^" .. fail_output .. "$") then passed = false if not errors or #errors == 0 then errors = string.format("matched failed output: ${color.failure}%s${clear}", fail_output) local actual_output = outdata if not option.get("diagnosis") then actual_output = outdata:sub(1, 64) if #outdata > #actual_output then actual_output = actual_output .. "..." end end errors = errors .. ", actual output: ${color.failure}" .. actual_output end break end end end if passed == nil then if #pass_outputs == 0 then passed = true else passed = false if not errors or #errors == 0 then errors = string.format("not matched passed output: ${color.success}%s${clear}", table.concat(pass_outputs, ", ")) local actual_output = outdata if not option.get("diagnosis") then actual_output = outdata:sub(1, 64) if #outdata > #actual_output then actual_output = actual_output .. "..." end end errors = errors .. ", actual output: ${color.failure}" .. actual_output end end end end if should_fail then if not passed then passed = true errors = nil -- clear errors, since failure was expected else passed = false local extra_info = string.format("Test %s unexpectedly passed (should fail)", opt.name) if errors and #errors > 0 then errors = errors .. "\n" .. extra_info else errors = extra_info end end end if errors and #errors > 0 then opt.errors = errors end return passed end -- test target function _on_test_target(target, opt) -- build target with rules local passed local done = false for _, r in ipairs(target:orderules()) do local on_test = r:script("test") if on_test then passed = on_test(target, opt) done = true end end if done then return passed end -- do test return _do_test_target(target, opt) end -- recursively add target envs function _add_target_pkgenvs(target, targets_added) if targets_added[target:name()] then return end targets_added[target:name()] = true os.addenvs(target:pkgenvs()) for _, dep in ipairs(target:orderdeps()) do _add_target_pkgenvs(dep, targets_added) end end -- run the given test function _run_test(target, test) -- enter the environments of the target packages local oldenvs = os.getenvs() _add_target_pkgenvs(target, {}) -- the target scripts local scripts = { target:script("test_before") , function (target, opt) for _, r in ipairs(target:orderules()) do local before_test = r:script("test_before") if before_test then before_test(target, opt) end end end , target:script("test", _on_test_target) , function (target, opt) for _, r in ipairs(target:orderules()) do local after_test = r:script("test_after") if after_test then after_test(target, opt) end end end , target:script("test_after") } -- run the target scripts local passed for i = 1, 5 do local script = scripts[i] if script ~= nil then local ok = script(target, test) if i == 3 then passed = ok end end end -- leave the environments of the target packages os.setenvs(oldenvs) return passed end function _show_output(testinfo, kind) local output = testinfo[kind] if output then if option.get("diagnosis") then local target = testinfo.target local autogendir = path.join(target:autogendir(), "tests") local logfile = path.join(autogendir, testinfo.name .. "." .. kind .. ".log") io.writefile(logfile, output) print("%s: %s", kind, logfile) elseif option.get("verbose") then cprint("%s: %s", kind, output) end end end -- run tests function _run_tests(tests) local ordertests = {} local maxwidth = 0 for name, testinfo in table.orderpairs(tests) do table.insert(ordertests, testinfo) if #testinfo.name > maxwidth then maxwidth = #testinfo.name end end if #ordertests == 0 then print("nothing to test") return end -- do test local spent = os.mclock() print("running tests ...") local report = {passed = 0, total = #ordertests, tests = {}} local jobs = tonumber(option.get("jobs")) or os.default_njob() runjobs("run_tests", function (index, total, opt) local testinfo = ordertests[index] if testinfo then progress.show(opt.progress, "running.test %s", testinfo.name) local target = testinfo.target testinfo.target = nil local spent = os.mclock() local passed = _run_test(target, testinfo) spent = os.mclock() - spent if passed then report.passed = report.passed + 1 end table.insert(report.tests, { target = target, name = testinfo.name, passed = passed, spent = spent, should_fail = testinfo.should_fail or false, stdout = testinfo.stdout, stderr = testinfo.stderr, errors = testinfo.errors}) -- stop it if be failed? if not passed then local stop = target:policy("test.stop_on_first_failure") if stop == nil then stop = project.policy("test.stop_on_first_failure") end if stop then raise(errors) end end end end, {total = #ordertests, comax = jobs, isolate = true}) -- generate report spent = os.mclock() - spent local passed_rate = math.floor(report.passed * 100 / report.total) local failed_tests = {} local expected_failures = {} local unexpected_passes = {} print("") print("report of tests:") for idx, testinfo in ipairs(report.tests) do -- Result label and color logic local result_label local result_color if testinfo.should_fail then if testinfo.passed then table.insert(expected_failures, testinfo.name) result_label = "expected failure" result_color = "${color.warning}" else table.insert(unexpected_passes, testinfo.name) result_label = "unexpected pass" result_color = "${color.failure}" end else if testinfo.passed then result_label = "passed" result_color = "${color.success}" else result_label = "failed" result_color = "${color.failure}" table.insert(failed_tests, testinfo.name) end end local progress_format = result_color .. theme.get("text.build.progress_format") .. ":${clear} " if option.get("verbose") or option.get("diagnosis") then progress_format = progress_format .. "${dim}" end -- Build aligned output line local padding = maxwidth - #testinfo.name local filler = string.rep(".", padding) local progress_percent = math.floor(idx * 100 / #report.tests) cprint(progress_format .. "%s %s " .. result_color .. "%s${clear} ${bright}%0.3fs", progress_percent, testinfo.name, filler, result_label, testinfo.spent / 1000) _show_output(testinfo, "stdout") _show_output(testinfo, "stderr") _show_output(testinfo, "errors") end -- Print the summary if #failed_tests > 0 or #expected_failures > 0 or #unexpected_passes > 0 then cprint("\nDetailed summary:") end if #failed_tests > 0 then cprint("${color.failure}Failed tests:${clear}") for _, name in ipairs(failed_tests) do print(" - " .. name) end end if #unexpected_passes > 0 then cprint("${color.failure}Unexpected passes:${clear}") for _, name in ipairs(unexpected_passes) do print(" - " .. name) end end if #expected_failures > 0 then cprint("${color.warning}Expected failures:${clear}") for _, name in ipairs(expected_failures) do print(" - " .. name) end end local summary = string.format("\n${color.success}%d%%%%${clear} tests passed, ${color.failure}%d${clear} test(s) failed", passed_rate, #failed_tests) if #unexpected_passes > 0 then summary = summary .. string.format(", ${color.failure}%d${clear} unexpected pass(es)", #unexpected_passes) end if #expected_failures > 0 then summary = summary .. string.format(", ${color.warning}%d${clear} expected failure(s)", #expected_failures) end summary = summary .. string.format(" out of ${bright}%d${clear}, spent ${bright}%0.3fs", report.total, spent / 1000) cprint(summary) local return_zero = project.policy("test.return_zero_on_failure") if not return_zero and report.passed < report.total then raise() end end -- try to build the given target function _try_build_target(targetname) local errors local passed = try { function () build_action.build_targets(targetname) return true end, catch { function (errs) errors = tostring(errs) end } } return passed, errors end -- get tests, export this for the `project` plugin function get_tests() local tests = {} local group_pattern = option.get("group") if group_pattern then group_pattern = "^" .. path.pattern(group_pattern) .. "$" end for _, target in ipairs(project.ordertargets()) do for _, name in ipairs(target:get("tests")) do local extra = target:extraconf("tests", name) local testname = target:name() .. "/" .. name local testinfo = {name = testname, target = target} if extra then table.join2(testinfo, extra) if extra.files then local target_new = target:clone() local scriptdir = target:scriptdir() target_new:name_set(target:name() .. "_" .. name) for _, file in ipairs(extra.files) do file = path.absolute(file, scriptdir) file = path.relative(file, os.projectdir()) target_new:add("files", file, {defines = extra.defines, cflags = extra.cflags, cxflags = extra.cxflags, cxxflags = extra.cxxflags, undefines = extra.undefines, languages = extra.languages}) project.target_add(target_new) end for _, file in ipairs(extra.remove_files) do file = path.absolute(file, scriptdir) file = path.relative(file, os.projectdir()) target_new:remove("files", file) end if extra.kind then target_new:set("kind", kind) end if extra.frameworks then target_new:add("frameworks", extra.frameworks) end if extra.links then target_new:add("links", extra.links) end if extra.syslinks then target_new:add("syslinks", extra.syslinks) end if extra.packages then target_new:add("packages", extra.packages) end testinfo.target = target_new end end if not testinfo.group then testinfo.group = target:get("group") end local group = testinfo.group if (not group_pattern) or (group_pattern and group and group:match(group_pattern)) then tests[testname] = testinfo end end end return tests end function main() -- do action for remote? if remote_build_action.enabled() then return remote_build_action() end -- load config first task.run("config", {}, {disable_dump = true}) -- lock the whole project project.lock() -- get tests local tests = get_tests() local test_patterns = option.get("tests") if test_patterns then local tests_new = {} for _, pattern in ipairs(test_patterns) do pattern = "^" .. path.pattern(pattern) .. "$" for name, testinfo in pairs(tests) do if name:match(pattern) then tests_new[name] = testinfo end end end tests = tests_new end -- enter project directory local oldir = os.cd(project.directory()) -- build targets with the given tests first local targetnames = {} for _, testinfo in table.orderpairs(tests) do local targetname = testinfo.target:fullname() if testinfo.build_should_pass then local passed, errors = _try_build_target(targetname) testinfo.passed = passed testinfo.errors = errors elseif testinfo.build_should_fail then local built, _ = _try_build_target(targetname) testinfo.passed = not built if built then testinfo.errors = "Build succeeded when failure was expected" end else table.insert(targetnames, targetname) end end if #targetnames > 0 then build_action.build_targets(targetnames) end -- run tests _run_tests(tests) -- leave project directory os.cd(oldir) -- unlock the whole project project.unlock() end