@@ -12817,6 +13528,11 @@ sub dump_result {
goodprint "HTML Report successfully generated: " . $opt{'reportfile'};
}
+ my $sanitized_res;
+ if ( $opt{'json'} || $opt{'yaml'} ) {
+ $sanitized_res = _sanitized_result_for_export( \%result );
+ }
+
if ( $opt{'json'} ) {
eval { require JSON };
if ($@) {
@@ -12826,7 +13542,7 @@ sub dump_result {
my $json = JSON->new->allow_nonref;
print $json->utf8(1)->pretty( ( $opt{'prettyjson'} ? 1 : 0 ) )
- ->encode( \%result );
+ ->encode($sanitized_res);
if ( $opt{'outputfile'} ) {
unlink $opt{'outputfile'} if ( -e $opt{'outputfile'} );
@@ -12834,7 +13550,21 @@ sub dump_result {
or die
"Unable to open $opt{'outputfile'} in write mode. please check permissions for this file or directory";
print $fh $json->utf8(1)->pretty( ( $opt{'prettyjson'} ? 1 : 0 ) )
- ->encode( \%result );
+ ->encode($sanitized_res);
+ close $fh;
+ }
+ }
+
+ if ( $opt{'yaml'} ) {
+ my $yaml_str = _to_yaml($sanitized_res);
+ print $yaml_str;
+
+ if ( $opt{'outputfile'} ) {
+ unlink $opt{'outputfile'} if ( -e $opt{'outputfile'} );
+ open my $fh, q(>), $opt{'outputfile'}
+ or die
+"Unable to open $opt{'outputfile'} in write mode. please check permissions for this file or directory";
+ print $fh $yaml_str;
close $fh;
}
}
@@ -12888,6 +13618,9 @@ sub dump_csv_files {
open( $fh, '>', $outputfile_path )
or die("Failed to open $outputfile_path for writing: $!");
$opt{nocolor} = 1; # Disable colors in file output
+ if (@raw_output_lines) {
+ print $fh join( "\n", @raw_output_lines ), "\n";
+ }
}
# If outputfile is already set, create a second file handle for raw output
@@ -13080,6 +13813,8 @@ sub dump_csv_files {
# BEGIN 'MAIN'
# ---------------------------------------------------------------------------
if ( !caller ) {
+ $tuner_start_time =
+ eval { require Time::HiRes; Time::HiRes::time(); } || time();
parse_cli_args; # Parse CLI arguments
setup_environment; # Initialize variables and handle early exits
headerprint; # Header Print
@@ -13090,14 +13825,14 @@ sub dump_csv_files {
debugprint "MySQL FINAL Client : $mysqlcmd $mysqllogin";
debugprint "MySQL Admin FINAL Client : $mysqladmincmd $mysqllogin";
- dump_csv_files; # dump csv files
- os_setup; # Set up some OS variables
- get_all_vars; # Toss variables/status into hashes
- mysql_cloud_discovery; # Auto-discover cloud environment
- get_tuning_info; # Get information about the tuning connection
- calculations; # Calculate everything we need
- check_architecture; # Suggest 64-bit upgrade
- check_storage_engines; # Show enabled storage engines
+ os_setup; # Set up some OS variables
+ get_all_vars; # Toss variables/status into hashes
+ print_audit_snapshot_summary; # Summary of the audit snapshot
+ mysql_cloud_discovery; # Auto-discover cloud environment
+ get_tuning_info; # Get information about the tuning connection
+ calculations; # Calculate everything we need
+ check_architecture; # Suggest 64-bit upgrade
+ check_storage_engines; # Show enabled storage engines
if ( $opt{'feature'} ) {
subheaderprint "See FEATURES.md for more information";
@@ -13106,7 +13841,9 @@ sub dump_csv_files {
subheaderprint "Running feature: $feature";
$feature->();
}
+ dump_csv_files; # dump csv files
make_recommendations;
+ print_execution_timings();
goodprint "Terminated successfully";
exit(0);
}
@@ -13134,8 +13871,10 @@ sub dump_csv_files {
$section->();
}
+ dump_csv_files; # dump csv files
make_recommendations; # Make recommendations based on stats
dump_result; # Dump result if debug is on
+ print_execution_timings();
goodprint "Terminated successfully";
close_outputfile; # Close reportfile if needed
@@ -13153,7 +13892,7 @@ sub dump_csv_files {
=head1 NAME
- MySQLTuner 2.8.45 - MySQL High Performance Tuning Script
+ MySQLTuner 2.9.0 - MySQL High Performance Tuning Script
=head1 IMPORTANT USAGE GUIDELINES
@@ -13168,7 +13907,7 @@ =head1 OPTIONS
=head1 VERSION
-Version 2.8.45
+Version 2.9.0
=head1 PERLDOC
You can find documentation for this module with the perldoc command.
@@ -13338,6 +14077,10 @@ =head1 CONTRIBUTORS
Long Radix
+=item *
+
+derZ-dev
+
=back
=head1 SUPPORT
diff --git a/releases/v2.9.0.md b/releases/v2.9.0.md
new file mode 100644
index 000000000..fbd12f549
--- /dev/null
+++ b/releases/v2.9.0.md
@@ -0,0 +1,100 @@
+# Release Notes - v2.9.0
+
+**Date**: 2026-06-15
+
+## 📝 Executive Summary
+
+```text
+2.9.0 2026-06-15
+
+- chore(main): whitelist deps and system commit scopes in check_compliance.pl to support Dependabot and host metrics commits.
+- chore(main): add roadmap to the whitelist of allowed scopes in compliance checks.
+- feat(cli): create an agent-ready output format (JSON/YAML) so that MySQLTuner can be easily integrated by AI agents.
+- feat(report): add verbose execution timing measurements for each section, showing both elapsed time and its percentage relative to the total script execution time.
+- feat(report): finalize a complete HTML report file beginning in v2.8.45.
+- feat(report): move dump_csv_files execution step to immediately before make_recommendations.
+- feat(report): print an environment audit snapshot summary (server, user, RAM, swap, versions, uptime) right after get_all_vars.
+- feat(report): support historical comparison of database diagnostics and performance metrics over time.
+- fix(cli): add mutually exclusive guard for json and yaml options.
+- fix(main): calculate query cache efficiency using Com_select on MariaDB, where Com_select includes query cache hits (MDEV-4981).
+- fix(main): calculate health score early in historical comparison to ensure scores exist for trend analysis.
+- fix(main): format YAML null as tilde (~) and multiline values using literal block style.
+- fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables.
+- fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks.
+- fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance.
+- fix(main): add undefined and 'NULL' guards to hr_num to eliminate uninitialized value warnings.
+- fix(main): sanitize and redact sensitive credentials from json/yaml output exports.
+- fix(main): skip local /proc/loadavg read during remote database audits.
+- fix(report): flush console output trace when opening raw log files late in dumpdir mode.
+- fix(test): add undef fallbacks to human_size, hr_bytes and hr_bytes_rnd mocks in tests.
+- test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/test_issue_927.t (renamed from tests/issue_927.t).
+- test(lab): utilize isolated tempfile for mock json to avoid race conditions in tests/unit_phase13_kpis.t.
+- test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t.
+- test(versions): add unit tests for version caching and comparisons, resolve redundant warnings, and use tempfile in tests/unit_versions.t.
+- feat(main): recommend enabling slow query log if disabled (#517)
+- test(lab): add unit test test_issue_517.t for slow query log recommendations (#517)
+- test(lab): add unit test test_issue_480.t for table_open_cache_instances recommendation (#480)
+- test(lab): split unit_coverage_boost4.t into smaller topic-oriented unit tests: unit_cli_helpers.t, unit_client_privileges.t, unit_cloud_commands.t, unit_fs_info.t, unit_os_vm.t
+- test(lab): normalize all repro_issue_*.t and issue_*.t test file names to test_issue_*.t
+```
+
+## 📈 Diagnostic Growth Indicators
+
+| Metric | Current | Progress | Status |
+| :--- | :--- | :--- | :--- |
+| Total Indicators | 15 | 0 | 🛡️ |
+| Efficiency Checks | 0 | 0 | 🛡️ |
+| Risk Detections | 2 | 0 | 🛡️ |
+| Information Points | 13 | 0 | 🛡️ |
+
+## 🛠️ Internal Commit History
+
+- feat: recommend slow query log when disabled (#517) (da6d437)
+- docs: add LightPath as sponsor, relocate coffee button, and use star-history chart (31e3638)
+- docs: update repository links to major/MySQLTuner-perl and add GitHub stars badge (4cd208e)
+- docs: regenerate release notes (a754147)
+- fix(main): add undefined/NULL guards to hr_num to resolve uninitialized warnings (0a87482)
+- docs(roadmap): link strategic technical evolutions specification and enforce changelog staging (136a977)
+- docs: regenerate release notes (abe582a)
+- fix(main): address PR #931 code review feedback and enhance test validations (64c28ce)
+- feat(metadata): fix test badge and update version references in READMEs (a1be73c)
+- docs(metadata): remove timestamp from doc-sync generated files (4575b0a)
+- fix(main): calculate query cache efficiency using Com_select on MariaDB (MDEV-4981) (780c861)
+- feat(report): add verbose timings, step percentages, and snapshot summary (c23198a)
+- feat(report): implement Phase 13 sectional global indicators and KPIs (5f9cebb)
+- chore: remove execution.log from git repository and sync docs (2a9b66e)
+- feat(report): finalize HTML report, YAML output, and historical comparison (66ebab8)
+
+## ⚙️ Technical Evolutions
+
+### ➕ CLI Options Added
+- `--innodb_buffer_stats_by_schema`
+- `--innodb_buffer_stats_by_table`
+- `--processlist`
+- `--schema_auto_increment_columns`
+- `--schema_index_statistics`
+- `--schema_object_overview`
+- `--schema_redundant_indexes`
+- `--schema_table_lock_waits`
+- `--schema_table_statistics`
+- `--schema_table_statistics_with_buffer`
+- `--schema_tables_with_full_table_scans`
+- `--schema_unused_indexes`
+- `--session`
+- `--statement_analysis`
+- `--statements_with_errors_or_warnings`
+- `--statements_with_full_table_scans`
+- `--statements_with_runtimes_in_95th_percentile`
+- `--statements_with_sorting`
+- `--statements_with_temp_tables`
+- `--yaml`
+
+### ➖ CLI Options Deprecated
+- `--data`
+- `--template`
+
+## ✅ Laboratory Verification Results
+
+- [x] Automated TDD suite passed.
+- [x] Multi-DB version laboratory execution validated.
+- [x] Performance indicator delta analysis completed.
diff --git a/tests/repro_issue_20.t b/tests/test_issue_20.t
similarity index 100%
rename from tests/repro_issue_20.t
rename to tests/test_issue_20.t
diff --git a/tests/repro_issue_22.t b/tests/test_issue_22.t
similarity index 100%
rename from tests/repro_issue_22.t
rename to tests/test_issue_22.t
diff --git a/tests/issue_33.t b/tests/test_issue_33.t
similarity index 100%
rename from tests/issue_33.t
rename to tests/test_issue_33.t
diff --git a/tests/issue_36.t b/tests/test_issue_36.t
similarity index 100%
rename from tests/issue_36.t
rename to tests/test_issue_36.t
diff --git a/tests/issue_37.t b/tests/test_issue_37.t
similarity index 100%
rename from tests/issue_37.t
rename to tests/test_issue_37.t
diff --git a/tests/issue_42.t b/tests/test_issue_42.t
similarity index 100%
rename from tests/issue_42.t
rename to tests/test_issue_42.t
diff --git a/tests/test_issue_480.t b/tests/test_issue_480.t
new file mode 100644
index 000000000..11ef15504
--- /dev/null
+++ b/tests/test_issue_480.t
@@ -0,0 +1,59 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+
+# Load MySQLTuner
+require './mysqltuner.pl';
+require './tests/MySQLTuner/TestHelper.pm';
+
+# Force redefinition of essential subs
+no warnings 'redefine';
+*main::execute_system_command = sub { return ""; };
+*main::which = sub { return "/usr/bin/mysql" };
+*main::infoprint = sub { };
+*main::goodprint = sub { };
+*main::badprint = sub { };
+*main::subheaderprint = sub { };
+*main::debugprint = sub { };
+
+# Mock globals
+$main::good = '[OK]';
+$main::bad = '[!!]';
+$main::info = '[--]';
+$main::deb = '[DG]';
+$main::end = '';
+our %myvar;
+our %mystat;
+our %mycalc;
+our @adjvars;
+
+subtest 'table_open_cache_instances recommendation' => sub {
+ MySQLTuner::TestHelper::reset_state();
+
+ no warnings 'redefine';
+ local *main::logical_cpu_cores = sub { return 8 };
+ local *main::select_one = sub {
+ my $q = shift;
+ return 100 if $q =~ /COUNT\(\*\)/;
+ return 0;
+ };
+
+ main::mysql_stats();
+
+ ok(grep(/table_open_cache_instances \(=\s*4\)/, @main::adjvars), 'Suggested 4 instances for 8 CPU cores');
+
+ MySQLTuner::TestHelper::reset_state();
+ $main::mycalc{'table_cache_hit_rate'} = 10;
+ local *main::logical_cpu_cores = sub { return 64 };
+ local *main::select_one = sub {
+ my $q = shift;
+ return 100 if $q =~ /COUNT\(\*\)/;
+ return 0;
+ };
+ main::mysql_stats();
+ ok(grep(/table_open_cache_instances \(=\s*16\)/, @main::adjvars), 'Suggested max 16 instances for 64 CPU cores');
+};
+
+done_testing();
diff --git a/tests/repro_issue_490.t b/tests/test_issue_490.t
similarity index 100%
rename from tests/repro_issue_490.t
rename to tests/test_issue_490.t
diff --git a/tests/test_issue_517.t b/tests/test_issue_517.t
new file mode 100644
index 000000000..014c935e3
--- /dev/null
+++ b/tests/test_issue_517.t
@@ -0,0 +1,168 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+# Mock environment - set BEFORE require
+$main::devnull = '/dev/null';
+$main::is_win = 0;
+
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+# Mock helper functions to avoid printing or exiting
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { diag "INFO: $_[0]" };
+ *main::badprint = sub { diag "BAD: $_[0]" };
+ *main::goodprint = sub { diag "GOOD: $_[0]" };
+ *main::debugprint = sub { diag "DEBUG: $_[0]" };
+ *main::subheaderprint = sub { diag "SUBHEADER: $_[0]" };
+ *main::get_pf_memory = sub { return 0 };
+ *main::get_gcache_memory = sub { return 0 };
+ *main::mysql_cloud_discovery = sub { return "none" };
+ *main::pretty_uptime = sub { return "1 day" };
+ *main::get_other_process_memory = sub { return 0 };
+ *main::select_array = sub { return () };
+ *main::select_one = sub { return 0 };
+ *main::execute_system_command = sub { return "" };
+ eval '*main::is_remote = sub () { return 0; };';
+}
+
+sub get_base_mock_vars {
+ return (
+ 'version' => '5.7.35',
+ 'version_comment' => 'MySQL Community Server',
+ 'read_buffer_size' => 1024,
+ 'read_rnd_buffer_size' => 1024,
+ 'sort_buffer_size' => 1024,
+ 'thread_stack' => 1024,
+ 'join_buffer_size' => 1024,
+ 'binlog_cache_size' => 1024,
+ 'tmp_table_size' => 1024,
+ 'max_heap_table_size' => 1024,
+ 'max_connections' => 10,
+ 'key_buffer_size' => 5000,
+ 'innodb_buffer_pool_size' => 10000,
+ 'innodb_additional_mem_pool_size' => 1024,
+ 'innodb_log_buffer_size' => 1024,
+ 'query_cache_size' => 64 * 1024 * 1024,
+ 'query_cache_type' => 'ON',
+ 'aria_pagecache_buffer_size' => 1024,
+ 'long_query_time' => 10,
+ 'log_bin' => 'OFF',
+ 'have_innodb' => 'YES',
+ 'open_files_limit' => 1024,
+ 'thread_cache_size' => 8,
+ 'concurrent_insert' => 'AUTO',
+ );
+}
+
+sub get_base_mock_stats {
+ return (
+ 'Questions' => 1500,
+ 'Max_used_connections' => 5,
+ 'Uptime' => 86400,
+ 'Qcache_free_memory' => 32 * 1024 * 1024,
+ 'Qcache_hits' => 1000,
+ 'Com_select' => 1500,
+ 'Qcache_lowmem_prunes' => 0,
+ 'Connections' => 100,
+ 'Aborted_connects' => 0,
+ 'Key_read_requests' => 100,
+ 'Key_reads' => 0,
+ 'Key_write_requests' => 100,
+ 'Key_writes' => 0,
+ 'Slow_queries' => 0,
+ 'Key_blocks_unused' => 100,
+ 'Table_locks_immediate' => 100,
+ 'Table_locks_waited' => 0,
+ 'Created_tmp_tables' => 10,
+ 'Opened_tables' => 10,
+ 'Open_tables' => 10,
+ 'Threads_cached' => 5,
+ 'Bytes_sent' => 1000,
+ 'Bytes_received' => 1000,
+ 'Threads_created' => 2,
+ );
+}
+
+subtest 'slow_query_log is OFF' => sub {
+ @main::generalrec = ();
+ @main::adjvars = ();
+ $main::physical_memory = 32 * 1024 * 1024 * 1024;
+ $main::swap_memory = 4 * 1024 * 1024 * 1024;
+
+ %main::myvar = (
+ get_base_mock_vars(),
+ slow_query_log => 'OFF',
+ log_slow_queries => 'ON', # Should be overridden by slow_query_log
+ );
+ %main::mystat = (
+ get_base_mock_stats(),
+ );
+ %main::mycalc = ();
+
+ eval { main::calculations(); main::mysql_stats(); };
+ ok(!$@, 'calculations() did not crash') or diag("Crashed: $@");
+
+ my $found = grep { /Enable the slow query log/ } @main::generalrec;
+ ok($found, 'Should recommend enabling slow query log when slow_query_log is OFF');
+};
+
+subtest 'slow_query_log is ON' => sub {
+ @main::generalrec = ();
+ @main::adjvars = ();
+ $main::physical_memory = 32 * 1024 * 1024 * 1024;
+ $main::swap_memory = 4 * 1024 * 1024 * 1024;
+
+ %main::myvar = (
+ get_base_mock_vars(),
+ slow_query_log => 'ON',
+ log_slow_queries => 'OFF', # Should be overridden by slow_query_log
+ );
+ %main::mystat = (
+ get_base_mock_stats(),
+ );
+ %main::mycalc = ();
+
+ eval { main::calculations(); main::mysql_stats(); };
+ ok(!$@, 'calculations() did not crash') or diag("Crashed: $@");
+
+ my $found = grep { /Enable the slow query log/ } @main::generalrec;
+ ok(!$found, 'Should not recommend enabling slow query log when slow_query_log is ON');
+};
+
+subtest 'log_slow_queries fallback is OFF' => sub {
+ @main::generalrec = ();
+ @main::adjvars = ();
+ $main::physical_memory = 32 * 1024 * 1024 * 1024;
+ $main::swap_memory = 4 * 1024 * 1024 * 1024;
+
+ %main::myvar = (
+ get_base_mock_vars(),
+ log_slow_queries => 'OFF',
+ );
+ %main::mystat = (
+ get_base_mock_stats(),
+ );
+ %main::mycalc = ();
+
+ eval { main::calculations(); main::mysql_stats(); };
+ ok(!$@, 'calculations() did not crash') or diag("Crashed: $@");
+
+ my $found = grep { /Enable the slow query log/ } @main::generalrec;
+ ok($found, 'Should recommend enabling slow query log when log_slow_queries fallback is OFF');
+};
+
+done_testing();
diff --git a/tests/repro_issue_605.t b/tests/test_issue_605.t
similarity index 100%
rename from tests/repro_issue_605.t
rename to tests/test_issue_605.t
diff --git a/tests/issue_770.t b/tests/test_issue_770.t
similarity index 100%
rename from tests/issue_770.t
rename to tests/test_issue_770.t
diff --git a/tests/issue_774.t b/tests/test_issue_774.t
similarity index 100%
rename from tests/issue_774.t
rename to tests/test_issue_774.t
diff --git a/tests/issue_777.t b/tests/test_issue_777.t
similarity index 100%
rename from tests/issue_777.t
rename to tests/test_issue_777.t
diff --git a/tests/issue_781.t b/tests/test_issue_781.t
similarity index 100%
rename from tests/issue_781.t
rename to tests/test_issue_781.t
diff --git a/tests/issue_782.t b/tests/test_issue_782.t
similarity index 100%
rename from tests/issue_782.t
rename to tests/test_issue_782.t
diff --git a/tests/issue_783.t b/tests/test_issue_783.t
similarity index 100%
rename from tests/issue_783.t
rename to tests/test_issue_783.t
diff --git a/tests/repro_issue_863.t b/tests/test_issue_863.t
similarity index 100%
rename from tests/repro_issue_863.t
rename to tests/test_issue_863.t
diff --git a/tests/issue_863_enhanced.t b/tests/test_issue_863_enhanced.t
similarity index 100%
rename from tests/issue_863_enhanced.t
rename to tests/test_issue_863_enhanced.t
diff --git a/tests/issue_864.t b/tests/test_issue_864.t
similarity index 100%
rename from tests/issue_864.t
rename to tests/test_issue_864.t
diff --git a/tests/issue_869.t b/tests/test_issue_869.t
similarity index 100%
rename from tests/issue_869.t
rename to tests/test_issue_869.t
diff --git a/tests/issue_881_887.t b/tests/test_issue_881_887.t
similarity index 100%
rename from tests/issue_881_887.t
rename to tests/test_issue_881_887.t
diff --git a/tests/issue_888.t b/tests/test_issue_888.t
similarity index 100%
rename from tests/issue_888.t
rename to tests/test_issue_888.t
diff --git a/tests/issue_896.t b/tests/test_issue_896.t
similarity index 100%
rename from tests/issue_896.t
rename to tests/test_issue_896.t
diff --git a/tests/issue_904.t b/tests/test_issue_904.t
similarity index 100%
rename from tests/issue_904.t
rename to tests/test_issue_904.t
diff --git a/tests/issue_913.t b/tests/test_issue_913.t
similarity index 100%
rename from tests/issue_913.t
rename to tests/test_issue_913.t
diff --git a/tests/issue_923.t b/tests/test_issue_923.t
similarity index 98%
rename from tests/issue_923.t
rename to tests/test_issue_923.t
index c2d0e8938..3b8576796 100644
--- a/tests/issue_923.t
+++ b/tests/test_issue_923.t
@@ -26,8 +26,8 @@ no warnings 'redefine';
no warnings 'uninitialized';
*main::debugprint = sub { };
*main::is_int = sub { return $_[0] && $_[0] =~ /^\d+$/ };
-*main::human_size = sub { return $_[0] };
-*main::hr_bytes = sub { return $_[0] };
+*main::human_size = sub { return $_[0] // '0B' };
+*main::hr_bytes = sub { return $_[0] // '0B' };
*main::percentage = sub { return 0 };
*main::badprint = sub { };
*main::infoprint = sub { };
@@ -213,6 +213,7 @@ subtest 'temptable_max_mmap disk space recommendations check' => sub {
'pct_writes' => 50,
'pct_temp_disk' => 0,
'thread_cache_hit_rate' => 90,
+ 'table_cache_hit_rate' => 99,
'total_sorts' => 0,
'joins_without_indexes_per_day' => 0,
);
diff --git a/tests/test_issue_927.t b/tests/test_issue_927.t
new file mode 100644
index 000000000..342cc8c21
--- /dev/null
+++ b/tests/test_issue_927.t
@@ -0,0 +1,241 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+
+use Test::More tests => 2;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+# Load mysqltuner.pl as a library
+my $script_dir = dirname( abs_path(__FILE__) );
+my $script =
+ abs_path( File::Spec->catfile( $script_dir, '..', 'mysqltuner.pl' ) );
+require $script;
+require './tests/MySQLTuner/TestHelper.pm';
+
+# Mock global variables
+our %myvar;
+our %mystat;
+our %mycalc;
+our $physical_memory;
+our $swap_memory;
+our @generalrec;
+our @adjvars;
+
+# Setup common mocks globally
+no warnings 'redefine';
+no warnings 'uninitialized';
+*main::debugprint = sub { };
+*main::is_int = sub { return $_[0] && $_[0] =~ /^\d+$/ };
+*main::human_size = sub { return $_[0] // '0B' };
+*main::hr_bytes = sub { return $_[0] // '0B' };
+*main::hr_bytes_rnd = sub { return $_[0] // '0B' };
+*main::hr_num = sub {
+ my $val = shift;
+ return 0 unless defined $val;
+
+ # simple formatting for tests
+ 1 while $val =~ s/(\d+)(\d{3})/$1,$2/;
+ return $val;
+};
+*main::percentage = sub { return 0 };
+*main::select_one = sub { return 0 };
+*main::select_array = sub { return () };
+*main::mysql_version_ge = sub { return 1 };
+*main::mysql_version_le = sub { return 0 };
+*main::mysql_version_eq = sub { return 0 };
+*main::execute_system_command = sub { return "" };
+*main::get_pf_memory = sub { return 0 };
+*main::get_gcache_memory = sub { return 0 };
+*main::is_remote = sub () { return 0 };
+*main::mysql_cloud_discovery = sub { return "none" };
+*main::pretty_uptime = sub { return "1 day" };
+*main::get_other_process_memory = sub { return 0 };
+
+subtest 'Query cache efficiency on standard MySQL' => sub {
+ $main::physical_memory = 32 * 1024 * 1024 * 1024;
+ $main::swap_memory = 4 * 1024 * 1024 * 1024;
+
+ %main::myvar = (
+ 'version' => '5.7.35',
+ 'version_comment' => 'MySQL Community Server',
+ 'read_buffer_size' => 1024,
+ 'read_rnd_buffer_size' => 1024,
+ 'sort_buffer_size' => 1024,
+ 'thread_stack' => 1024,
+ 'join_buffer_size' => 1024,
+ 'binlog_cache_size' => 1024,
+ 'tmp_table_size' => 1024,
+ 'max_heap_table_size' => 1024,
+ 'max_connections' => 10,
+ 'key_buffer_size' => 5000,
+ 'innodb_buffer_pool_size' => 10000,
+ 'innodb_additional_mem_pool_size' => 1024,
+ 'innodb_log_buffer_size' => 1024,
+ 'query_cache_size' => 64 * 1024 * 1024,
+ 'query_cache_type' => 'ON',
+ 'aria_pagecache_buffer_size' => 1024,
+ 'long_query_time' => 10,
+ 'log_bin' => 'OFF',
+ 'have_innodb' => 'YES',
+ 'open_files_limit' => 1024,
+ 'thread_cache_size' => 8,
+ 'concurrent_insert' => 'AUTO',
+ );
+ %main::mystat = (
+ 'Questions' => 1500,
+ 'Max_used_connections' => 5,
+ 'Uptime' => 86400,
+ 'Qcache_free_memory' => 32 * 1024 * 1024,
+ 'Qcache_hits' => 1000,
+ 'Com_select' => 1500,
+ 'Qcache_lowmem_prunes' => 0,
+ 'Connections' => 100,
+ 'Aborted_connects' => 0,
+ 'Key_read_requests' => 100,
+ 'Key_reads' => 0,
+ 'Key_write_requests' => 100,
+ 'Key_writes' => 0,
+ 'Slow_queries' => 0,
+ 'Key_blocks_unused' => 100,
+ 'Table_locks_immediate' => 100,
+ 'Table_locks_waited' => 0,
+ 'Created_tmp_tables' => 10,
+ 'Opened_tables' => 10,
+ 'Open_tables' => 10,
+ 'Threads_cached' => 5,
+ 'Bytes_sent' => 1000,
+ 'Bytes_received' => 1000,
+ 'Threads_created' => 2,
+ );
+ %main::mycalc = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+
+ eval { main::calculations(); };
+ ok( !$@, "calculations completed without crash" );
+ is( $main::mycalc{'query_cache_efficiency'}, "40.0",
+"MySQL efficiency calculation is Qcache_hits / (Com_select + Qcache_hits) = 40%"
+ );
+
+ my @good_prints;
+ my @bad_prints;
+ my @info_prints;
+ local *main::goodprint = sub { push @good_prints, $_[0] };
+ local *main::badprint = sub { push @bad_prints, $_[0] };
+ local *main::infoprint = sub { push @info_prints, $_[0] };
+ local *main::subheaderprint = sub { };
+
+ eval { main::mysql_stats(); };
+ ok( !$@, "mysql_stats completed without crash" );
+
+ my $found_msg = "";
+ for my $msg ( @good_prints, @bad_prints ) {
+ if ( $msg =~ /Query cache efficiency/ ) {
+ $found_msg = $msg;
+ last;
+ }
+ }
+
+ like(
+ $found_msg,
+ qr/Query cache efficiency: 40\.0% \(1,000 cached \/ 2,500 selects\)/,
+"Display text shows correct MySQL hit ratio and total selects (2,500 selects)"
+ );
+};
+
+subtest 'Query cache efficiency on MariaDB' => sub {
+ $main::physical_memory = 32 * 1024 * 1024 * 1024;
+ $main::swap_memory = 4 * 1024 * 1024 * 1024;
+
+ %main::myvar = (
+ 'version' => '10.11.14-MariaDB',
+ 'version_comment' => 'MariaDB Server',
+ 'read_buffer_size' => 1024,
+ 'read_rnd_buffer_size' => 1024,
+ 'sort_buffer_size' => 1024,
+ 'thread_stack' => 1024,
+ 'join_buffer_size' => 1024,
+ 'binlog_cache_size' => 1024,
+ 'tmp_table_size' => 1024,
+ 'max_heap_table_size' => 1024,
+ 'max_connections' => 10,
+ 'key_buffer_size' => 5000,
+ 'innodb_buffer_pool_size' => 10000,
+ 'innodb_additional_mem_pool_size' => 1024,
+ 'innodb_log_buffer_size' => 1024,
+ 'query_cache_size' => 64 * 1024 * 1024,
+ 'query_cache_type' => 'ON',
+ 'aria_pagecache_buffer_size' => 1024,
+ 'long_query_time' => 10,
+ 'log_bin' => 'OFF',
+ 'have_innodb' => 'YES',
+ 'open_files_limit' => 1024,
+ 'thread_cache_size' => 8,
+ 'concurrent_insert' => 'AUTO',
+ );
+ %main::mystat = (
+ 'Questions' => 1500,
+ 'Max_used_connections' => 5,
+ 'Uptime' => 86400,
+ 'Qcache_free_memory' => 32 * 1024 * 1024,
+ 'Qcache_hits' => 1000,
+ 'Com_select' => 1500,
+ 'Qcache_lowmem_prunes' => 0,
+ 'Connections' => 100,
+ 'Aborted_connects' => 0,
+ 'Key_read_requests' => 100,
+ 'Key_reads' => 0,
+ 'Key_write_requests' => 100,
+ 'Key_writes' => 0,
+ 'Slow_queries' => 0,
+ 'Key_blocks_unused' => 100,
+ 'Table_locks_immediate' => 100,
+ 'Table_locks_waited' => 0,
+ 'Created_tmp_tables' => 10,
+ 'Opened_tables' => 10,
+ 'Open_tables' => 10,
+ 'Threads_cached' => 5,
+ 'Bytes_sent' => 1000,
+ 'Bytes_received' => 1000,
+ 'Threads_created' => 2,
+ );
+ %main::mycalc = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+
+ eval { main::calculations(); };
+ ok( !$@, "calculations completed without crash" );
+ is( $main::mycalc{'query_cache_efficiency'},
+ "66.7",
+ "MariaDB efficiency calculation is Qcache_hits / Com_select = 66.7%" );
+
+ my @good_prints;
+ my @bad_prints;
+ my @info_prints;
+ local *main::goodprint = sub { push @good_prints, $_[0] };
+ local *main::badprint = sub { push @bad_prints, $_[0] };
+ local *main::infoprint = sub { push @info_prints, $_[0] };
+ local *main::subheaderprint = sub { };
+
+ eval { main::mysql_stats(); };
+ ok( !$@, "mysql_stats completed without crash" );
+
+ my $found_msg = "";
+ for my $msg ( @good_prints, @bad_prints ) {
+ if ( $msg =~ /Query cache efficiency/ ) {
+ $found_msg = $msg;
+ last;
+ }
+ }
+
+ like(
+ $found_msg,
+ qr/Query cache efficiency: 66\.7% \(1,000 cached \/ 1,500 selects\)/,
+"Display text shows correct MariaDB hit ratio and total selects without double-counting (1,500 selects)"
+ );
+};
+
+1;
diff --git a/tests/unit_cli_helpers.t b/tests/unit_cli_helpers.t
new file mode 100644
index 000000000..1644300c6
--- /dev/null
+++ b/tests/unit_cli_helpers.t
@@ -0,0 +1,153 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+our $mock_exit_active = 0;
+BEGIN {
+ *CORE::GLOBAL::exit = sub {
+ my $code = shift // 0;
+ if ($mock_exit_active) {
+ die "MOCKED_EXIT: $code\n";
+ }
+ CORE::exit($code);
+ };
+}
+
+$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
+
+# Declare globals before loading script
+our @adjvars;
+our @generalrec;
+our @modeling;
+our @sysrec;
+our @secrec;
+our %opt;
+our %myvar;
+our %mystat;
+our %mycalc;
+our %result;
+our $mysqlcmd;
+our $devnull;
+
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+my @mock_output;
+sub reset_mocks {
+ @mock_output = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+ @main::modeling = ();
+ @main::sysrec = ();
+ @main::secrec = ();
+ %main::result = ();
+ %main::opt = ();
+ %main::myvar = ();
+}
+
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { push @mock_output, "INFO: $_[0]" };
+ *main::badprint = sub { push @mock_output, "BAD: $_[0]" };
+ *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" };
+ *main::debugprint = sub { };
+ *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" };
+ *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" };
+}
+
+# =====================================================================
+# 1. parse_cli_args
+# =====================================================================
+subtest 'parse_cli_args' => sub {
+ reset_mocks();
+ local @ARGV = ('--host', '192.168.1.100', '--user', 'tuner_user');
+ main::parse_cli_args();
+ is($main::opt{host}, '192.168.1.100', 'host option parsed');
+ is($main::opt{user}, 'tuner_user', 'user option parsed');
+};
+
+subtest 'parse_cli_args invalid option' => sub {
+ reset_mocks();
+ local @ARGV = ('--invalid-opt-name-xyz');
+ local $mock_exit_active = 1;
+ # Capture STDERR or redirect it to avoid clutter
+ open my $old_err, '>&', \*STDERR or die "Can't dup STDERR: $!";
+ close STDERR;
+ open STDERR, '>', File::Spec->devnull() or die "Can't redirect STDERR: $!";
+ eval {
+ main::parse_cli_args();
+ };
+ my $err = $@;
+ # Restore STDERR
+ open STDERR, '>&', $old_err or die "Can't restore STDERR: $!";
+ close $old_err;
+ like($err, qr/MOCKED_EXIT: 1/, 'exit code 1 on invalid option');
+};
+
+# =====================================================================
+# 2. show_help
+# =====================================================================
+subtest 'show_help' => sub {
+ reset_mocks();
+ local $mock_exit_active = 1;
+ my $help_output = '';
+ # Capture STDOUT
+ open my $old_out, '>&', \*STDOUT or die "Can't dup STDOUT: $!";
+ close STDOUT;
+ open STDOUT, '>', \$help_output or die "Can't redirect STDOUT: $!";
+ eval {
+ main::show_help();
+ };
+ my $err = $@;
+ # Restore STDOUT
+ open STDOUT, '>&', $old_out or die "Can't restore STDOUT: $!";
+ close $old_out;
+ like($err, qr/MOCKED_EXIT: 0/, 'exit code 0 on show_help');
+ like($help_output, qr/Usage: \.\/mysqltuner\.pl/, 'help usage output present');
+ like($help_output, qr/CONNECTION AND AUTHENTICATION/, 'help categories present');
+};
+
+# =====================================================================
+# 3. get_http_cli
+# =====================================================================
+subtest 'get_http_cli' => sub {
+ reset_mocks();
+ # Case 1: curl exists
+ {
+ no warnings 'redefine';
+ local *main::which = sub {
+ my ($cmd) = @_;
+ return "/usr/bin/curl\n" if $cmd eq 'curl';
+ return "";
+ };
+ is(main::get_http_cli(), "/usr/bin/curl", 'curl detected');
+ }
+ # Case 2: wget exists (curl does not)
+ {
+ no warnings 'redefine';
+ local *main::which = sub {
+ my ($cmd) = @_;
+ return "/usr/bin/wget\n" if $cmd eq 'wget';
+ return "";
+ };
+ is(main::get_http_cli(), "/usr/bin/wget", 'wget fallback works');
+ }
+ # Case 3: neither exists
+ {
+ no warnings 'redefine';
+ local *main::which = sub { return ""; };
+ is(main::get_http_cli(), "", 'empty returned if neither exists');
+ }
+};
+
+done_testing();
diff --git a/tests/unit_client_privileges.t b/tests/unit_client_privileges.t
new file mode 100644
index 000000000..1086ebe3d
--- /dev/null
+++ b/tests/unit_client_privileges.t
@@ -0,0 +1,119 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
+
+# Declare globals before loading script
+our @adjvars;
+our @generalrec;
+our @modeling;
+our @sysrec;
+our @secrec;
+our %opt;
+our %myvar;
+our %mystat;
+our %mycalc;
+our %result;
+our $mysqlcmd;
+
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+my @mock_output;
+sub reset_mocks {
+ @mock_output = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+ @main::modeling = ();
+ @main::sysrec = ();
+ @main::secrec = ();
+ %main::result = ();
+ %main::opt = ();
+ %main::myvar = ();
+}
+
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { push @mock_output, "INFO: $_[0]" };
+ *main::badprint = sub { push @mock_output, "BAD: $_[0]" };
+ *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" };
+ *main::debugprint = sub { };
+ *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" };
+ *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" };
+}
+
+# =====================================================================
+# 1. get_tuning_info
+# =====================================================================
+subtest 'get_tuning_info' => sub {
+ reset_mocks();
+ no warnings 'redefine';
+ local *main::select_array = sub {
+ return (
+ " Connection: localhost via UNIX socket\n",
+ " Server: Localhost via UNIX socket\n",
+ );
+ };
+ main::get_tuning_info();
+ is($result{'MySQL Client'}{'Connection'}, 'localhost via UNIX socket', 'parsed client connection');
+ is($result{'MySQL Client'}{'Client Path'}, $mysqlcmd, 'Client Path set');
+};
+
+# =====================================================================
+# 2. check_privileges
+# =====================================================================
+subtest 'check_privileges' => sub {
+ # Case 1: ALL PRIVILEGES
+ {
+ reset_mocks();
+ no warnings 'redefine';
+ local *main::select_array = sub { return ("GRANT ALL PRIVILEGES ON *.* TO 'root'\@'localhost'"); };
+ main::check_privileges();
+ is(scalar @mock_output, 0, 'No warning output when ALL PRIVILEGES present');
+ }
+ # Case 2: SUPER privilege
+ {
+ reset_mocks();
+ no warnings 'redefine';
+ local *main::select_array = sub { return ("GRANT SUPER ON *.* TO 'root'\@'localhost'"); };
+ main::check_privileges();
+ is(scalar @mock_output, 0, 'No warning output when SUPER present');
+ }
+ # Case 3: Missing privileges on MySQL 8.0
+ {
+ reset_mocks();
+ $main::myvar{'version'} = '8.0.35';
+ no warnings 'redefine';
+ local *main::select_array = sub { return ("GRANT SELECT, PROCESS ON *.* TO 'user'\@'localhost'"); };
+ local *main::mysql_version_ge = sub { return 1; };
+ main::check_privileges();
+ ok(grep({ /missing the following privileges/ } @mock_output), 'Warning print on missing privileges');
+ }
+ # Case 4: Missing privileges on MariaDB 10.5
+ {
+ reset_mocks();
+ $main::myvar{'version'} = '10.5.25-MariaDB';
+ no warnings 'redefine';
+ local *main::select_array = sub { return ("GRANT SELECT, PROCESS ON *.* TO 'user'\@'localhost'"); };
+ local *main::mysql_version_ge = sub {
+ my ($major, $minor) = @_;
+ return 1 if $major == 10 && $minor == 5;
+ return 0;
+ };
+ main::check_privileges();
+ ok(grep({ /missing the following privileges/ } @mock_output), 'Warning print on missing MariaDB privileges');
+ }
+};
+
+done_testing();
diff --git a/tests/unit_cloud_commands.t b/tests/unit_cloud_commands.t
new file mode 100644
index 000000000..a15a1e6e2
--- /dev/null
+++ b/tests/unit_cloud_commands.t
@@ -0,0 +1,133 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
+
+# Declare globals before loading script
+our @adjvars;
+our @generalrec;
+our @modeling;
+our @sysrec;
+our @secrec;
+our %opt;
+our %myvar;
+our %mystat;
+our %mycalc;
+our %result;
+
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+my @mock_output;
+sub reset_mocks {
+ @mock_output = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+ @main::modeling = ();
+ @main::sysrec = ();
+ @main::secrec = ();
+ %main::result = ();
+ %main::opt = ();
+ %main::myvar = ();
+}
+
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { push @mock_output, "INFO: $_[0]" };
+ *main::badprint = sub { push @mock_output, "BAD: $_[0]" };
+ *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" };
+ *main::debugprint = sub { };
+ *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" };
+ *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" };
+}
+
+# =====================================================================
+# 1. infoprintcmd & infoprinthcmd
+# =====================================================================
+subtest 'infoprintcmd & infoprinthcmd' => sub {
+ reset_mocks();
+ my @cmd_prints;
+ my @info_prints;
+ my @header_prints;
+ no warnings 'redefine';
+ local *main::cmdprint = sub { push @cmd_prints, $_[0] };
+ local *main::infoprintml = sub { push @info_prints, @_ };
+ local *main::subheaderprint = sub { push @header_prints, $_[0] };
+
+ main::infoprintcmd("echo 'test_infoprintcmd'");
+ is(scalar @cmd_prints, 1, 'cmdprint called once');
+ like($cmd_prints[0], qr/echo 'test_infoprintcmd'/, 'cmdprint received command');
+ ok(grep({ /test_infoprintcmd/ } @info_prints), 'infoprintml printed output of command');
+
+ main::infoprinthcmd("My Header", "echo 'test_infoprinthcmd'");
+ is($header_prints[0], "My Header", 'subheaderprint called with header');
+ like($cmd_prints[1], qr/echo 'test_infoprinthcmd'/, 'infoprintcmd called inside infoprinthcmd');
+};
+
+# =====================================================================
+# 2. cloud_setup
+# =====================================================================
+subtest 'cloud_setup' => sub {
+ # Case 1: direct connection mode
+ {
+ reset_mocks();
+ $main::opt{cloud} = 1;
+ $main::opt{'ssh-host'} = undef;
+ $main::opt{forcemem} = 0;
+ main::cloud_setup();
+ is($main::opt{cloud}, 1, 'cloud is 1');
+ is($main::opt{nosysstat}, 1, 'nosysstat is set to 1 in direct mode');
+ is($main::opt{forcemem}, 1024, 'forcemem defaults to 1024');
+ }
+ # Case 2: ssh connection mode with command outputs
+ {
+ reset_mocks();
+ $main::opt{cloud} = 1;
+ $main::opt{'ssh-host'} = 'my-remote-db';
+ $main::opt{forcemem} = 0;
+ $main::opt{forceswap} = 0;
+ no warnings 'redefine';
+ local *main::execute_system_command = sub {
+ my ($cmd) = @_;
+ if ($cmd =~ /uname/) {
+ return "Linux remote-host 5.4.0\n";
+ }
+ elsif ($cmd =~ /MemTotal/) {
+ return "MemTotal: 4194304 kB\n"; # 4GB
+ }
+ elsif ($cmd =~ /SwapTotal/) {
+ return "SwapTotal: 2097152 kB\n"; # 2GB
+ }
+ return "";
+ };
+ main::cloud_setup();
+ is($main::opt{forcemem}, 4096, 'remote memory detected and set');
+ is($main::opt{forceswap}, 2048, 'remote swap detected and set');
+ }
+ # Case 3: ssh connection mode but commands fail (default fallback)
+ {
+ reset_mocks();
+ $main::opt{cloud} = 1;
+ $main::opt{'ssh-host'} = 'my-remote-db';
+ $main::opt{forcemem} = 0;
+ $main::opt{forceswap} = 0;
+ no warnings 'redefine';
+ local *main::execute_system_command = sub { return (); };
+ main::cloud_setup();
+ is($main::opt{forcemem}, 1024, 'forcemem fallback to 1024');
+ is($main::opt{forceswap}, 0, 'forceswap fallback to 0');
+ }
+};
+
+done_testing();
diff --git a/tests/unit_fs_info.t b/tests/unit_fs_info.t
new file mode 100644
index 000000000..2ac3d2b65
--- /dev/null
+++ b/tests/unit_fs_info.t
@@ -0,0 +1,103 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
+
+# Declare globals before loading script
+our @adjvars;
+our @generalrec;
+our @modeling;
+our @sysrec;
+our @secrec;
+our %opt;
+our %myvar;
+our %mystat;
+our %mycalc;
+our %result;
+
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+my @mock_output;
+sub reset_mocks {
+ @mock_output = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+ @main::modeling = ();
+ @main::sysrec = ();
+ @main::secrec = ();
+ %main::result = ();
+ %main::opt = ();
+ %main::myvar = ();
+}
+
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { push @mock_output, "INFO: $_[0]" };
+ *main::badprint = sub { push @mock_output, "BAD: $_[0]" };
+ *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" };
+ *main::debugprint = sub { };
+ *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" };
+ *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" };
+}
+
+# =====================================================================
+# 1. get_fs_info & get_fs_info_win
+# =====================================================================
+subtest 'get_fs_info' => sub {
+ reset_mocks();
+ no warnings 'redefine';
+ local *main::execute_system_command = sub {
+ my ($cmd) = @_;
+ if ($cmd =~ /df -P/) {
+ return (
+ "Filesystem 1024-blocks Used Available Capacity Mounted on\n",
+ "/dev/sda1 10485760 9437184 1048576 90% /\n", # >85%
+ "/dev/sda2 10485760 5242880 5242880 50% /home\n", # <=85%
+ );
+ }
+ elsif ($cmd =~ /df -Pi/) {
+ return (
+ "Filesystem Inodes IUsed IFree IUse% Mounted on\n",
+ "/dev/sda1 1000000 900000 100000 90% /\n", # >85%
+ "/dev/sda2 1000000 500000 500000 50% /home\n", # <=85%
+ );
+ }
+ return ();
+ };
+ main::get_fs_info();
+ is($result{'Filesystem'}{'Space Pct'}{'/'}, 90, 'root Space Pct parsed');
+ is($result{'Filesystem'}{'Space Pct'}{'/home'}, 50, 'home Space Pct parsed');
+ is($result{'Filesystem'}{'Inode Pct'}{'/'}, 90, 'root Inode Pct parsed');
+ ok(grep({ /mount point \/ is using 90 % total space/ } @mock_output), 'warning on high disk usage printed');
+ ok(grep({ /mount point \/ is using 90 % of max allowed inodes/ } @mock_output), 'warning on high inode usage printed');
+};
+
+subtest 'get_fs_info_win' => sub {
+ reset_mocks();
+ no warnings 'redefine';
+ local *main::execute_system_command = sub {
+ return (
+ "FreeSpace Name Size\n",
+ "1073741824 C: 10737418240\n", # 90% used
+ "5368709120 D: 10737418240\n", # 50% used
+ );
+ };
+ main::get_fs_info_win();
+ is($result{'Filesystem'}{'Space Pct'}{'C:'}, 90, 'Disk C Space Pct parsed');
+ is($result{'Filesystem'}{'Space Pct'}{'D:'}, 50, 'Disk D Space Pct parsed');
+ ok(grep({ /Disk C: is using 90 % total space/ } @mock_output), 'warning on high windows disk usage printed');
+};
+
+done_testing();
diff --git a/tests/unit_os_vm.t b/tests/unit_os_vm.t
new file mode 100644
index 000000000..9591f5b78
--- /dev/null
+++ b/tests/unit_os_vm.t
@@ -0,0 +1,114 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
+
+# Declare globals before loading script
+our @adjvars;
+our @generalrec;
+our @modeling;
+our @sysrec;
+our @secrec;
+our %opt;
+our %myvar;
+our %mystat;
+our %mycalc;
+our %result;
+
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+{
+ local @ARGV = ();
+ no warnings 'redefine';
+ require $script;
+}
+
+my @mock_output;
+sub reset_mocks {
+ @mock_output = ();
+ @main::generalrec = ();
+ @main::adjvars = ();
+ @main::modeling = ();
+ @main::sysrec = ();
+ @main::secrec = ();
+ %main::result = ();
+ %main::opt = ();
+ %main::myvar = ();
+}
+
+{
+ no warnings 'redefine';
+ *main::infoprint = sub { push @mock_output, "INFO: $_[0]" };
+ *main::badprint = sub { push @mock_output, "BAD: $_[0]" };
+ *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" };
+ *main::debugprint = sub { };
+ *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" };
+ *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" };
+}
+
+# =====================================================================
+# 1. get_os_release
+# =====================================================================
+subtest 'get_os_release' => sub {
+ reset_mocks();
+ my $release = main::get_os_release();
+ ok(defined $release && $release ne '', "get_os_release returns some string: $release");
+};
+
+# =====================================================================
+# 2. is_virtual_machine
+# =====================================================================
+subtest 'is_virtual_machine' => sub {
+ # Case 1: Linux with ssh prefix
+ {
+ reset_mocks();
+ local $^O = 'linux';
+ no warnings 'redefine';
+ local *main::get_transport_prefix = sub { return 'ssh'; };
+ local *main::execute_system_command = sub {
+ my ($cmd) = @_;
+ return 1 if $cmd =~ /grep -Ec/;
+ return 0;
+ };
+ is(main::is_virtual_machine(), 1, 'Virtual machine detected via ssh hypervisor grep');
+ }
+ # Case 2: Linux local check
+ {
+ reset_mocks();
+ local $^O = 'linux';
+ no warnings 'redefine';
+ local *main::get_transport_prefix = sub { return ''; };
+ my $is_vm = main::is_virtual_machine();
+ ok(defined $is_vm, 'Local VM detection check executed');
+ }
+ # Case 3: FreeBSD check
+ {
+ reset_mocks();
+ local $^O = 'freebsd';
+ no warnings 'redefine';
+ local *main::execute_system_command = sub {
+ my ($cmd) = @_;
+ return "bhyve\n" if $cmd =~ /sysctl/;
+ return "none\n";
+ };
+ is(main::is_virtual_machine(), 1, 'FreeBSD VM detected');
+ }
+ # Case 4: Windows check
+ {
+ reset_mocks();
+ local $main::is_win = 1;
+ no warnings 'redefine';
+ local *main::execute_system_command = sub {
+ return "System Model: Virtual Machine\n";
+ };
+ is(main::is_virtual_machine(), 1, 'Windows VM model detected');
+ }
+};
+
+done_testing();
diff --git a/tests/unit_phase13_kpis.t b/tests/unit_phase13_kpis.t
new file mode 100644
index 000000000..d55ea357d
--- /dev/null
+++ b/tests/unit_phase13_kpis.t
@@ -0,0 +1,202 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use File::Temp qw(tempfile);
+
+# Get the script path
+my $script_path = File::Spec->rel2abs(File::Spec->catfile(dirname(__FILE__), '..', 'mysqltuner.pl'));
+
+# Load MySQLTuner logic
+{
+ local @ARGV = ('--silent');
+ package main;
+ require $script_path;
+}
+
+package main;
+
+# Write a mock JSON file for historical delta testing
+my ( $mock_old_fh, $mock_old_file ) = tempfile( 'mock_old_XXXX', SUFFIX => '.json', UNLINK => 1 );
+print $mock_old_fh q({
+ "General": { "Date": "2026-06-01" },
+ "Stats": {
+ "QPS": 10.0,
+ "Total Data Size": 83886080
+ },
+ "HealthScore": 80,
+ "SectionalHealthScore": {
+ "General": 95,
+ "Storage": 70,
+ "Security": 90,
+ "Replication": 100,
+ "Modeling": 90
+ }
+});
+close($mock_old_fh);
+
+# 1. Test get_load_average with mock file2string or fallback to execute_system_command
+{
+ no warnings 'redefine';
+ local *main::file2string = sub {
+ my $path = shift;
+ if ($path eq '/proc/loadavg') {
+ return "1.25 0.80 0.50 2/250 12345\n";
+ }
+ return undef;
+ };
+
+ my @load = main::get_load_average();
+ is_deeply(\@load, ['1.25', '0.80', '0.50'], "get_load_average should parse /proc/loadavg correctly");
+}
+
+{
+ no warnings 'redefine';
+ local *main::file2string = sub { return undef; };
+ local *main::execute_system_command = sub {
+ my $cmd = shift;
+ if ($cmd eq 'uptime') {
+ return " 01:25:00 up 10 days, 1:23, 1 user, load average: 0.75, 1.10, 1.35\n";
+ }
+ return undef;
+ };
+
+ my @load = main::get_load_average();
+ is_deeply(\@load, ['0.75', '1.10', '1.35'], "get_load_average should fallback to uptime command");
+}
+
+# 2. Reset and Mock Sectional Indicators & Health Scores
+sub reset_state {
+ @main::generalrec = ();
+ @main::sysrec = ();
+ @main::secrec = ();
+ @main::modeling = ();
+ @main::adjvars = ();
+ %main::mycalc = ();
+ %main::mystat = ();
+ %main::myrepl = ();
+ %main::result = ();
+ $main::physical_memory = 16 * 1024 * 1024 * 1024; # 16 GB
+ $main::swap_memory = 2 * 1024 * 1024 * 1024; # 2 GB
+}
+
+# 2a. Test Optimal state: All scores 100
+reset_state();
+$main::mystat{'Uptime'} = 100000; # > 86400
+$main::mycalc{'pct_read_efficiency'} = 98.0;
+$main::mycalc{'pct_temp_disk'} = 10.0;
+$main::mycalc{'table_cache_hit_rate'} = 80.0;
+$main::mystat{'Questions'} = 200000;
+$main::mystat{'Innodb_buffer_pool_read_requests'} = 500000;
+
+# Load mock setup for loadavg
+{
+ no warnings 'redefine';
+ local *main::get_load_average = sub { return (0.1, 0.2, 0.3) };
+ local *main::get_other_process_memory = sub { return 1024 * 1024; }; # tiny
+ main::calculate_sectional_health_scores();
+}
+
+is($main::result{'SectionalHealthScore'}{'General'}, 100, "Optimal General score should be 100");
+is($main::result{'SectionalHealthScore'}{'Storage'}, 100, "Optimal Storage score should be 100");
+is($main::result{'SectionalHealthScore'}{'Security'}, 100, "Optimal Security score should be 100");
+is($main::result{'SectionalHealthScore'}{'Replication'}, 100, "Optimal Replication score should be 100");
+is($main::result{'SectionalHealthScore'}{'Modeling'}, 100, "Optimal SQL Modeling score should be 100");
+
+# 2b. Test deductions
+reset_state();
+$main::mystat{'Uptime'} = 5000; # < 86400 -> -20
+$main::mycalc{'pct_read_efficiency'} = 90.0; # < 95 -> -25
+$main::mycalc{'pct_temp_disk'} = 30.0; # > 25 -> -25
+push @main::secrec, "Vulnerable configuration."; # 1 item -> -15
+push @main::modeling, "Missing primary key."; # 1 item -> -10
+
+{
+ no warnings 'redefine';
+ local *main::get_load_average = sub { return (8.0, 8.0, 8.0) };
+ $main::mycalc{'cpu_cores'} = 4; # load_1 / cpu_cores = 2.0 > 1.0 -> -20
+ local *main::get_other_process_memory = sub { return 8 * 1024 * 1024 * 1024; }; # 50% RAM -> -20
+ main::calculate_sectional_health_scores();
+}
+
+# Print diagnostics for deduction test
+diag("--- Diagnostic Info ---");
+diag("General Recs: " . join(", ", @main::generalrec));
+diag("Other Mem: " . main::get_other_process_memory());
+diag("Load Avg: " . join(", ", main::get_load_average()));
+diag("Calculated General Score: " . $main::result{'SectionalHealthScore'}{'General'});
+
+is($main::result{'SectionalHealthScore'}{'General'}, 40, "General score should deduct for uptime, load, and other processes memory");
+is($main::result{'SectionalHealthScore'}{'Storage'}, 50, "Storage score should deduct for read efficiency and temp disk tables");
+is($main::result{'SectionalHealthScore'}{'Security'}, 85, "Security score should deduct for 1 recommendation");
+is($main::result{'SectionalHealthScore'}{'Modeling'}, 90, "SQL Modeling score should deduct for 1 modeling finding");
+
+# 3. Test Resource Saturation & Throughput Efficiency Index
+reset_state();
+$main::mystat{'Uptime'} = 10000;
+$main::mycalc{'pct_max_physical_memory'} = "88%";
+$main::mycalc{'pct_connections_used'} = "75.5%";
+$main::mycalc{'pct_read_efficiency'} = 97.5; # disk read pressure = 2.5 * 20 = 50%
+$main::mycalc{'pct_temp_disk'} = 40.0; # max(50, 40) = 50% IO sat
+$main::mystat{'Questions'} = 50000; # QPS = 5.0
+$main::mystat{'Innodb_buffer_pool_read_requests'} = 100000; # Reads/sec = 10.0
+# TEI = QPS / Reads/sec = 5.0 / 10.0 = 0.5
+
+{
+ no warnings 'redefine';
+ local *main::get_load_average = sub { return (2.5, 2.5, 2.5) };
+ $main::mycalc{'cpu_cores'} = 4; # CPU sat = 2.5 / 4 = 62.5% -> 62%
+ local *main::get_other_process_memory = sub { return 0; };
+ main::calculate_sectional_health_scores();
+}
+
+is($main::result{'ResourceSaturation'}{'CPU'}, 62, "CPU saturation calculation");
+is($main::result{'ResourceSaturation'}{'Memory'}, 88, "Memory saturation calculation");
+is($main::result{'ResourceSaturation'}{'Connections'}, 75, "Connections saturation calculation");
+is($main::result{'ResourceSaturation'}{'IO'}, 50, "Disk I/O saturation calculation");
+
+is($main::result{'ThroughputEfficiency'}{'QPS'}, 5.0, "Throughput Efficiency QPS");
+is($main::result{'ThroughputEfficiency'}{'LogicalReadsSec'}, 10.0, "Throughput Efficiency Reads/sec");
+is($main::result{'ThroughputEfficiency'}{'Index'}, 0.5, "Throughput Efficiency Index (TEI)");
+
+# 4. Test get_top_findings ranking logic
+reset_state();
+push @main::secrec, "Unencrypted connection detected."; # Critical keyword
+push @main::secrec, "Check password complexity."; # Standard finding
+push @main::secrec, "Accounts with empty password (risk)."; # Critical keyword (contains risk)
+push @main::secrec, "Some minor issue.";
+
+my @top_sec = main::get_top_findings(\@main::secrec);
+is(scalar(@top_sec), 3, "get_top_findings returns at most 3 items");
+is($top_sec[0]->{badge}, "Critical", "First item should be critical due to keyword");
+is($top_sec[1]->{badge}, "Critical", "Second item should be critical due to keyword");
+is($top_sec[2]->{badge}, "Finding", "Third item should be standard finding");
+
+# 5. Test historical delta trend analysis
+reset_state();
+$main::result{'Stats'}{'QPS'} = 12.5;
+$main::result{'HealthScore'} = 85;
+$main::result{'Stats'}{'Total Data Size'} = 104857600; # 100MB
+$main::result{'SectionalHealthScore'}{'General'} = 90;
+$main::result{'SectionalHealthScore'}{'Storage'} = 80;
+$main::result{'SectionalHealthScore'}{'Security'} = 95;
+$main::result{'SectionalHealthScore'}{'Replication'} = 100;
+$main::result{'SectionalHealthScore'}{'Modeling'} = 90;
+
+{
+ no warnings 'redefine';
+ $main::opt{'compare-file'} = $mock_old_file;
+ main::historical_comparison();
+}
+
+is($main::result{'Trends'}{'SnapshotDate'}, '2026-06-01', "Snapshot date correctly parsed");
+like($main::result{'Trends'}{'QPS'}, qr/\+25\.00%/, "QPS trend delta should show +25.00%");
+like($main::result{'Trends'}{'HealthScore'}, qr/\+5/, "Health score trend delta should show +5");
+like($main::result{'Trends'}{'TotalDataSize'}, qr/80.0M -> 100.0M \(20.0M\)/, "Data growth should show correct formatting");
+is($main::result{'Trends'}{'Sectional'}{'General'}, '95 -> 90 (-5)', "General score delta trend");
+is($main::result{'Trends'}{'Sectional'}{'Storage'}, '70 -> 80 (+10)', "Storage score delta trend");
+
+done_testing();
diff --git a/tests/unit_versions.t b/tests/unit_versions.t
index 64fec04ad..a6e31e31d 100644
--- a/tests/unit_versions.t
+++ b/tests/unit_versions.t
@@ -1,8 +1,8 @@
use strict;
use warnings;
no warnings 'once';
-no warnings 'once';
use Test::More;
+use File::Temp qw(tempfile);
use File::Basename;
use File::Spec;
use Cwd 'abs_path';
@@ -11,24 +11,152 @@ use Cwd 'abs_path';
$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
# Load mysqltuner.pl as a library
-my $script_dir = dirname(abs_path(__FILE__));
-my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+my $script_dir = dirname( abs_path(__FILE__) );
+my $script =
+ abs_path( File::Spec->catfile( $script_dir, '..', 'mysqltuner.pl' ) );
require $script;
# 1. Test compare_tuner_version
# This function is not pure, it uses global $tunerversion and prints.
subtest 'compare_tuner_version' => sub {
no warnings 'redefine';
- local *main::goodprint = sub { };
- local *main::badprint = sub { };
+ local *main::goodprint = sub { };
+ local *main::badprint = sub { };
local *main::update_tuner_version = sub { };
-
+
$main::tunerversion = "2.8.33";
-
+
# It returns undef, so we just check if it runs without crashing for now
# or check the behavior if we mocked the prints to capture output.
- ok(defined eval { main::compare_tuner_version("2.8.33"); 1 }, "Runs with same version");
- ok(defined eval { main::compare_tuner_version("2.9.0"); 1 }, "Runs with newer version");
+ ok( defined eval { main::compare_tuner_version("2.8.33"); 1 },
+ "Runs with same version" );
+ ok( defined eval { main::compare_tuner_version("2.9.0"); 1 },
+ "Runs with newer version" );
+};
+
+subtest 'mysql_version comparisons with cache' => sub {
+ $main::myvar{'version'} = '8.0.33';
+ ok( main::mysql_version_eq( 8, 0, 33 ), '8.0.33 eq 8.0.33' );
+ ok( main::mysql_version_eq( 8, 0 ), '8.0.33 eq 8.0' );
+ ok( main::mysql_version_eq(8), '8.0.33 eq 8' );
+ ok( !main::mysql_version_eq( 5, 7 ), '8.0.33 not eq 5.7' );
+
+ ok( main::mysql_version_ge( 8, 0, 30 ), '8.0.33 ge 8.0.30' );
+ ok( main::mysql_version_ge( 8, 0 ), '8.0.33 ge 8.0' );
+ ok( !main::mysql_version_ge( 9, 0 ), '8.0.33 not ge 9.0' );
+
+ ok( main::mysql_version_le( 8, 0, 35 ), '8.0.33 le 8.0.35' );
+ ok( main::mysql_version_le( 9, 0 ), '8.0.33 le 9.0' );
+ ok( !main::mysql_version_le( 5, 7 ), '8.0.33 not le 5.7' );
+};
+
+subtest '_to_yaml serialization' => sub {
+ my $data = {
+ name => 'MySQLTuner',
+ version => '2.9.0',
+ special => '2:9#0',
+ active => 1,
+ details => {
+ perf_bp => 10,
+ empty_val => '',
+ },
+ list => [ 'a', 'b', { k => 'v' } ],
+ };
+
+ my $yaml = main::_to_yaml($data);
+
+ # Check that strings are generated correctly
+ like( $yaml, qr/name: MySQLTuner/, 'Plain scalar serialized correctly' );
+ like( $yaml, qr/version: 2\.9\.0/, 'Unquoted scalar serialized correctly' );
+ like(
+ $yaml,
+ qr/special: '2:9#0'/,
+ 'Quoted scalar (with special chars) serialized correctly'
+ );
+ like( $yaml, qr/active: 1/, 'Number serialized correctly' );
+ like( $yaml, qr/empty_val: ''/, 'Empty string serialized correctly' );
+ like( $yaml, qr/perf_bp: 10/, 'Nested hash scalar serialized correctly' );
+ like( $yaml, qr/list:/, 'Array prefix serialized correctly' );
+ like( $yaml, qr/-\s+a/, 'Array items serialized correctly' );
+ like( $yaml, qr/-\s+k:\s+v/,
+ 'Array with nested hash serialized correctly' );
+};
+
+subtest 'historical_comparison health score' => sub {
+ no warnings 'redefine';
+ my $captured_trend;
+ local *main::infoprint = sub {
+ my $msg = shift;
+ if ( $msg =~ /Health Score Trend/ ) {
+ $captured_trend = $msg;
+ }
+ };
+ local *main::badprint = sub { };
+
+ # Prepare current results
+ $main::result{'HealthScore'} = 85;
+
+ # Mocking reading from file
+ my $compare_json =
+'{"General":{"Date":"2026-06-15"},"HealthScore":70,"Stats":{"QPS":1.5,"Total Data Size":1000}}';
+
+ my ( $tfh, $temp_file ) = tempfile( 'temp_compare_XXXX', SUFFIX => '.json', UNLINK => 1 );
+ print $tfh $compare_json;
+ close $tfh;
+
+ local $main::opt{'compare-file'} = $temp_file;
+ main::historical_comparison();
+
+ ok( defined $captured_trend,
+ "Health score trend comparison was triggered" );
+ like(
+ $captured_trend,
+ qr/70 -> 85 \(\+15\)/,
+ "Trend correctly shows +15 improvement"
+ );
+};
+
+subtest '_sanitized_result_for_export' => sub {
+ my $mock_result = {
+ 'MySQLTuner' => {
+ 'options' => {
+ 'host' => 'localhost',
+ 'pass' => 'mysecretpassword',
+ 'password' => 'anothersecret',
+ 'ssh-password' => 'sshsec',
+ 'passenv' => 'ENVVAR',
+ 'userenv' => 'USERVAR',
+ }
+ },
+ 'MySQL Client' => {
+ 'Authentication Info' => "mysql -u root -p'mysecretpassword' -h localhost"
+ }
+ };
+
+ my $sanitized = main::_sanitized_result_for_export($mock_result);
+
+ # Original remains unmodified
+ is($mock_result->{'MySQLTuner'}{'options'}{'pass'}, 'mysecretpassword', 'Original remains unmodified');
+
+ # Sanitized has redacted fields
+ is($sanitized->{'MySQLTuner'}{'options'}{'host'}, 'localhost', 'Host is kept');
+ is($sanitized->{'MySQLTuner'}{'options'}{'pass'}, '[REDACTED]', 'pass is redacted');
+ is($sanitized->{'MySQLTuner'}{'options'}{'password'}, '[REDACTED]', 'password is redacted');
+ is($sanitized->{'MySQLTuner'}{'options'}{'ssh-password'}, '[REDACTED]', 'ssh-password is redacted');
+ is($sanitized->{'MySQLTuner'}{'options'}{'passenv'}, '[REDACTED]', 'passenv is redacted');
+ is($sanitized->{'MySQLTuner'}{'options'}{'userenv'}, '[REDACTED]', 'userenv is redacted');
+
+ # Authentication Info is sanitized
+ like($sanitized->{'MySQL Client'}{'Authentication Info'}, qr/-p'\[REDACTED\]'/, 'Authentication Info password in quotes is redacted');
+
+ # Test without quotes
+ my $mock_result2 = {
+ 'MySQL Client' => {
+ 'Authentication Info' => "mysql -u root -psecret -h localhost"
+ }
+ };
+ my $sanitized2 = main::_sanitized_result_for_export($mock_result2);
+ like($sanitized2->{'MySQL Client'}{'Authentication Info'}, qr/-p\[REDACTED\]/, 'Authentication Info password without quotes is redacted');
};
done_testing();
diff --git a/tests/verbose_timing.t b/tests/verbose_timing.t
new file mode 100644
index 000000000..dfb84840e
--- /dev/null
+++ b/tests/verbose_timing.t
@@ -0,0 +1,86 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+no warnings 'once';
+use Test::More;
+use File::Basename;
+use File::Spec;
+use Cwd 'abs_path';
+
+# Suppress warnings from mysqltuner.pl initialization
+$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ };
+
+# Load mysqltuner.pl as a library
+my $script_dir = dirname(abs_path(__FILE__));
+my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl'));
+require $script;
+
+# Ensure verbose is on, and clear state
+$main::opt{'verbose'} = 1;
+@main::section_timings = ();
+@main::raw_output_lines = ();
+$main::tuner_start_time = (eval { require Time::HiRes; Time::HiRes::time(); } || time()) - 1.0;
+$main::current_section_name = undef;
+
+# Call subheaderprint twice to trigger timing print of first section
+main::subheaderprint("Section A");
+$main::current_section_start = $main::current_section_start - 0.5; # Fake elapsed time of 0.5s
+main::subheaderprint("Section B");
+
+# Check if timing for Section A was printed
+my @section_a_lines = grep { /Section A execution time:/ } @main::raw_output_lines;
+ok(scalar @section_a_lines > 0, "Execution time for Section A is printed when verbose is enabled");
+like($section_a_lines[0], qr/Section A execution time: 0\.\d+s/, "Duration format is correct");
+
+# Run print_execution_timings
+@main::raw_output_lines = ();
+main::print_execution_timings();
+
+# Check if summary block is printed with percentages
+my @summary_lines = grep { /Section B\s*:/ || /Total Execution Time:/ } @main::raw_output_lines;
+ok(scalar @summary_lines >= 2, "Summary block and total execution time are printed at the end");
+my @pct_lines = grep { /\(\d+\.\d+%\)/ } @main::raw_output_lines;
+ok(scalar @pct_lines >= 2, "Percentages are printed next to each section duration");
+
+# Run again with verbose = 0 and verify NO timings are printed
+$main::opt{'verbose'} = 0;
+@main::section_timings = ();
+@main::raw_output_lines = ();
+$main::current_section_name = undef;
+
+main::subheaderprint("Section A");
+main::subheaderprint("Section B");
+main::print_execution_timings();
+
+my @no_timing_lines = grep { /execution time:/ || /Total Execution Time:/ } @main::raw_output_lines;
+is(scalar @no_timing_lines, 0, "No timing messages are printed when verbose is disabled");
+
+# 3. Test print_audit_snapshot_summary
+subtest 'print_audit_snapshot_summary' => sub {
+ @main::raw_output_lines = ();
+ $main::tunerversion = "9.9.9";
+ $main::opt{'host'} = "127.0.0.1";
+ $main::opt{'port'} = 3307;
+ $main::physical_memory = 16 * 1024 * 1024 * 1024;
+ $main::swap_memory = 4 * 1024 * 1024 * 1024;
+ $main::myvar{'version'} = "10.11.4-MariaDB";
+ $main::mystat{'Uptime'} = 172800;
+
+ # We mock select_one so that we don't try to query database for CURRENT_USER
+ no warnings 'redefine';
+ local *main::select_one = sub { return 'root@localhost'; };
+
+ main::print_audit_snapshot_summary();
+
+ my $output = join("\n", @main::raw_output_lines);
+ like($output, qr/Audit Snapshot Summary/, "Header printed");
+ like($output, qr/MySQLTuner Version : 9\.9\.9/, "MySQLTuner version matched");
+ like($output, qr/Server Connection : 127\.0\.0\.1:3307/, "Connection matched");
+ like($output, qr/Database User : root\@localhost/, "Database user matched");
+ like($output, qr/Database Version : 10\.11\.4-MariaDB/, "Database version matched");
+ like($output, qr/System Physical RAM: 16\.0G/, "System RAM matched");
+ like($output, qr/System Swap Memory : 4\.0G/, "System Swap matched");
+ like($output, qr/Database Uptime : 2d 0h 0m 0s/, "Database uptime matched");
+};
+
+done_testing();