use crate::cli::{self, shared::IrCliOpt};
use ansiterm::Colour;
use clap::Parser;
use forc_pkg as pkg;
use forc_test::{
GasCostsSource, TestFilter, TestGasLimit, TestResult, TestRunnerCount, TestedPackage,
};
use forc_tracing::println_action_green;
use forc_util::{
tx_utils::{decode_fuel_vm_log_data, format_log_receipts},
ForcError, ForcResult,
};
use fuel_abi_types::{abi::program::PanickingCall, revert_info::RevertKind};
use sway_core::{asm_generation::ProgramABI, fuel_prelude::fuel_tx::Receipt, IrCli};
use tracing::info;
forc_util::cli_examples! {
crate::cli::Opt {
[ Run test => "forc test" ]
[ Run test with a filter => "forc test $filter" ]
[ Run test without any output => "forc test --silent" ]
[ Run test without creating or update the lock file => "forc test --locked" ]
}
}
#[derive(Debug, Parser)]
#[clap(bin_name = "forc test", version, after_help = help())]
pub struct Command {
#[clap(flatten)]
pub build: cli::shared::Build,
#[clap(flatten)]
pub test_print: TestPrintOpts,
pub filter: Option<String>,
#[clap(long)]
pub filter_exact: bool,
#[clap(long)]
pub test_threads: Option<usize>,
#[clap(flatten)]
pub experimental: sway_features::CliFields,
#[clap(long)]
pub gas_costs: Option<GasCostsSource>,
#[clap(long)]
pub no_gas_limit: bool,
}
#[derive(Parser, Debug, Clone)]
pub struct TestPrintOpts {
#[clap(long = "pretty")]
pub pretty_print: bool,
#[clap(long, short = 'l')]
pub logs: bool,
#[clap(long)]
pub raw_logs: bool,
#[clap(long)]
pub reverts: bool,
#[clap(long)]
pub dbgs: bool,
}
pub(crate) fn exec(cmd: Command) -> ForcResult<()> {
let test_runner_count = match cmd.test_threads {
Some(runner_count) => TestRunnerCount::Manual(runner_count),
None => TestRunnerCount::Auto,
};
let test_print_opts = cmd.test_print.clone();
let test_filter_phrase = cmd.filter.clone();
let test_filter = test_filter_phrase.as_ref().map(|filter_phrase| TestFilter {
filter_phrase,
exact_match: cmd.filter_exact,
});
let gas_costs_values = cmd
.gas_costs
.as_ref()
.unwrap_or(&GasCostsSource::BuiltIn)
.provide_gas_costs()?;
let gas_limit = if cmd.no_gas_limit {
TestGasLimit::Unlimited
} else {
TestGasLimit::Default
};
let opts = opts_from_cmd(cmd);
let built_tests = forc_test::build(opts)?;
let start = std::time::Instant::now();
let test_count = built_tests.test_count(test_filter.as_ref());
let num_tests_running = test_count.total - test_count.ignored;
let num_tests_ignored = test_count.ignored;
println_action_green(
"Running",
&format!(
"{} {}, filtered {} {}",
num_tests_running,
formatted_test_count_string(num_tests_running),
num_tests_ignored,
formatted_test_count_string(num_tests_ignored)
),
);
let tested = built_tests.run(test_runner_count, test_filter, gas_costs_values, gas_limit)?;
let duration = start.elapsed();
let all_tests_passed = match tested {
forc_test::Tested::Workspace(pkgs) => {
for pkg in &pkgs {
let built = &pkg.built.descriptor.name;
info!("\ntested -- {built}\n");
print_tested_pkg(pkg, &test_print_opts)?;
}
info!("");
println_action_green("Finished", &format!("in {duration:?}"));
pkgs.iter().all(|pkg| pkg.tests_passed())
}
forc_test::Tested::Package(pkg) => {
print_tested_pkg(&pkg, &test_print_opts)?;
pkg.tests_passed()
}
};
if all_tests_passed {
Ok(())
} else {
let forc_error: ForcError = "Some tests failed.".into();
const FAILING_UNIT_TESTS_EXIT_CODE: u8 = 101;
Err(forc_error.exit_code(FAILING_UNIT_TESTS_EXIT_CODE))
}
}
fn print_tested_pkg(pkg: &TestedPackage, test_print_opts: &TestPrintOpts) -> ForcResult<()> {
let succeeded = pkg.tests.iter().filter(|t| t.passed()).count();
let failed = pkg.tests.len() - succeeded;
let mut failed_tests = Vec::new();
let program_abi = match &pkg.built.program_abi {
ProgramABI::Fuel(fuel_abi) => Some(fuel_abi),
_ => None,
};
for test in &pkg.tests {
let test_passed = test.passed();
let (state, color) = match test_passed {
true => ("ok", Colour::Green),
false => ("FAILED", Colour::Red),
};
info!(
" test {} ... {} ({:?}, {} gas)",
test.name,
color.paint(state),
test.duration,
test.gas_used
);
print_test_output(test, program_abi, Some(test_print_opts))?;
if !test_passed {
failed_tests.push(test);
}
}
let (state, color) = match succeeded == pkg.tests.len() {
true => ("OK", Colour::Green),
false => ("FAILED", Colour::Red),
};
if failed != 0 {
info!("\n {}", Colour::Red.paint("failures:"));
for failed_test in failed_tests {
let failed_test_name = &failed_test.name;
let failed_test_details = failed_test.details()?;
let path = &*failed_test_details.file_path;
let line_number = failed_test_details.line_number;
info!(" test {failed_test_name}, {path:?}:{line_number}");
print_test_output(failed_test, program_abi, None)?;
info!("\n");
}
}
let pkg_test_durations: std::time::Duration = pkg
.tests
.iter()
.map(|test_result| test_result.duration)
.sum();
info!(
"\ntest result: {}. {} passed; {} failed; finished in {:?}",
color.paint(state),
succeeded,
failed,
pkg_test_durations
);
Ok(())
}
fn print_test_output(
test: &TestResult,
program_abi: Option<&fuel_abi_types::abi::program::ProgramABI>,
test_print_opts: Option<&TestPrintOpts>,
) -> Result<(), ForcError> {
const TEST_NAME_INDENT: &str = " ";
let print_reverts = test_print_opts.map(|opts| opts.reverts).unwrap_or(true);
let print_dbgs = test_print_opts.map(|opts| opts.dbgs).unwrap_or(true);
let print_logs = test_print_opts.map(|opts| opts.logs).unwrap_or(true);
let print_raw_logs = test_print_opts.map(|opts| opts.raw_logs).unwrap_or(true);
let pretty_print = test_print_opts
.map(|opts| opts.pretty_print)
.unwrap_or(true);
if print_reverts {
if let Some(revert_info) = test.revert_info(program_abi, &test.logs) {
info!(
"{TEST_NAME_INDENT}revert code: {:x}",
revert_info.revert_code
);
match revert_info.kind {
RevertKind::RawRevert => {}
RevertKind::KnownErrorSignal { err_msg } => {
info!("{TEST_NAME_INDENT} └─ error message: {err_msg}");
}
RevertKind::Panic {
err_msg,
err_val,
pos,
backtrace,
} => {
if let Some(err_msg) = err_msg {
info!("{TEST_NAME_INDENT} ├─ panic message: {err_msg}");
}
if let Some(err_val) = err_val {
info!("{TEST_NAME_INDENT} ├─ panic value: {err_val}");
}
info!(
"{TEST_NAME_INDENT} {} panicked: in {}",
if backtrace.is_empty() {
"└─"
} else {
"├─"
},
pos.function
);
info!(
"{TEST_NAME_INDENT} {} └─ at {}, {}:{}:{}",
if backtrace.is_empty() { " " } else { "│ " },
pos.pkg,
pos.file,
pos.line,
pos.column
);
fn print_backtrace_call(call: &PanickingCall, is_first: bool) {
if call.pos.function.ends_with("::__entry")
|| call.pos.function.eq("__entry")
{
return;
}
let prefix = if is_first {
"└─ backtrace:"
} else {
" "
};
info!(
"{TEST_NAME_INDENT} {prefix} called in {}",
call.pos.function
);
info!(
"{TEST_NAME_INDENT} └─ at {}, {}:{}:{}",
call.pos.pkg, call.pos.file, call.pos.line, call.pos.column
);
}
if let Some((first, others)) = backtrace.split_first() {
print_backtrace_call(first, true);
for call in others {
print_backtrace_call(call, false);
}
}
}
}
}
}
if print_dbgs && !test.ecal.captured.is_empty() {
info!("{TEST_NAME_INDENT}debug output:");
for captured in test.ecal.captured.iter() {
captured.apply();
}
}
if print_logs && !test.logs.is_empty() {
if let Some(program_abi) = program_abi {
info!("{TEST_NAME_INDENT}decoded log values:");
for log in &test.logs {
if let Receipt::LogData {
rb,
data: Some(data),
..
} = log
{
let decoded_log_data =
decode_fuel_vm_log_data(&rb.to_string(), data, program_abi)?;
let var_value = decoded_log_data.value;
info!("{var_value}, log rb: {rb}");
}
}
}
}
if print_raw_logs && !test.logs.is_empty() {
let formatted_logs = format_log_receipts(&test.logs, pretty_print)?;
info!("{TEST_NAME_INDENT}raw logs:\n{}", formatted_logs);
}
Ok(())
}
fn opts_from_cmd(cmd: Command) -> forc_test::TestOpts {
forc_test::TestOpts {
pkg: pkg::PkgOpts {
path: cmd.build.pkg.path.clone(),
offline: cmd.build.pkg.offline,
terse: cmd.build.pkg.terse,
locked: cmd.build.pkg.locked,
output_directory: cmd.build.pkg.output_directory.clone(),
ipfs_node: cmd.build.pkg.ipfs_node.clone().unwrap_or_default(),
},
print: pkg::PrintOpts {
ast: cmd.build.print.ast,
dca_graph: cmd.build.print.dca_graph.clone(),
dca_graph_url_format: cmd.build.print.dca_graph_url_format.clone(),
asm: cmd.build.print.asm(),
bytecode: cmd.build.print.bytecode,
bytecode_spans: false,
ir: cmd.build.print.ir(),
reverse_order: cmd.build.print.reverse_order,
},
verify_ir: cmd
.build
.verify_ir
.as_ref()
.map_or(IrCli::default(), |opts| IrCliOpt::from(opts).0),
time_phases: cmd.build.print.time_phases,
profile: cmd.build.print.profile,
metrics_outfile: cmd.build.print.metrics_outfile,
minify: pkg::MinifyOpts {
json_abi: cmd.build.minify.json_abi,
json_storage_slots: cmd.build.minify.json_storage_slots,
},
build_profile: cmd.build.profile.build_profile,
release: cmd.build.profile.release,
error_on_warnings: cmd.build.profile.error_on_warnings,
binary_outfile: cmd.build.output.bin_file,
debug_outfile: cmd.build.output.debug_file,
hex_outfile: cmd.build.output.hex_file,
build_target: cmd.build.build_target,
experimental: cmd.experimental.experimental,
no_experimental: cmd.experimental.no_experimental,
no_output: false,
}
}
fn formatted_test_count_string(count: usize) -> &'static str {
if count == 1 {
"test"
} else {
"tests"
}
}