From 2c95853337dad4d9917aa6b41ee11a22e23275ee Mon Sep 17 00:00:00 2001 From: JesusValera Date: Sat, 20 Dec 2025 15:25:28 +0100 Subject: [PATCH 1/6] feat(coverage): redesign HTML coverage report with modern UI Add dark theme with animated stat cards, SVG gauge chart, progress bars, hit count badges, and responsive design for improved developer experience. --- src/coverage.sh | 659 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 481 insertions(+), 178 deletions(-) diff --git a/src/coverage.sh b/src/coverage.sh index 545522ad..83d6abff 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -632,13 +632,12 @@ function bashunit::coverage::generate_index_html() { shift 4 local file_data=("$@") - # Determine total color class - local total_class="low" - if [[ $total_pct -ge ${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80} ]]; then - total_class="high" - elif [[ $total_pct -ge ${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50} ]]; then - total_class="medium" - fi + # Calculate uncovered lines and file count + local total_uncovered=$((total_executable - total_hit)) + local file_count=${#file_data[@]} + + # Calculate gauge stroke offset (440 is full circle circumference) + local gauge_offset=$((440 - (440 * total_pct / 100))) { cat << 'EOF' @@ -647,86 +646,274 @@ function bashunit::coverage::generate_index_html() { - Coverage Report + Coverage Report | bashunit + + + -
-

Coverage Report

-
-
Total Coverage
+
+
+
+ +EOF + echo "
v${BASHUNIT_VERSION:-0.0.0}
" + cat << 'EOF' +
+

Code Coverage Report

+

Comprehensive line-by-line coverage analysis for your bash scripts

+
+
+
+
+
๐Ÿ“Š
+EOF + echo "
${total_pct}%
" + cat << 'EOF' +
Total Coverage
+
+
+
๐Ÿ“
+EOF + echo "
${total_executable}
" + cat << 'EOF' +
Total Lines
+
+
+
โœ…
+EOF + echo "
${total_hit}
" + cat << 'EOF' +
Lines Covered
+
+
+
โŒ
EOF - echo "
${total_pct}%
" - echo "
${total_hit} of ${total_executable} lines covered
" + echo "
${total_uncovered}
" cat << 'EOF' +
Lines Uncovered
- - - - - - - - - - +
+
๐Ÿ“
+EOF + echo "
${file_count}
" + cat << 'EOF' +
Source Files
+
+ +
+
+
+ + + + + + + + +EOF + echo " " + cat << 'EOF' + +
+EOF + echo "
${total_pct}%
" + cat << 'EOF' +
Coverage
+
+
+
+

Overall Code Coverage

+EOF + echo "

Your test suite executes ${total_hit} out of ${total_executable} executable lines across ${file_count} source files. Coverage measures which lines of your source code are executed when running tests, helping identify untested code paths.

" + cat << 'EOF' +
+
+ + Covered: +EOF + echo " ${total_hit} lines" + cat << 'EOF' +
+
+ + Uncovered: +EOF + echo " ${total_uncovered} lines" + cat << 'EOF' +
+
+ + Non-executable: + comments, declarations +
+
+
+
+
+
+

File Coverage Details

+
+
+ +EOF + echo " โ‰ฅ${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% High" + cat << 'EOF' +
+
+ +EOF + echo " ${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}-${BASHUNIT_COVERAGE_THRESHOLD_HIGH:-80}% Medium" + cat << 'EOF' +
+
+ +EOF + echo " <${BASHUNIT_COVERAGE_THRESHOLD_LOW:-50}% Low" + cat << 'EOF' +
+
+
+
+
FileLinesCoverage
+ + + + + + + + + EOF for data in "${file_data[@]}"; do @@ -739,22 +926,51 @@ EOF class="medium" fi - echo " " - echo " " - echo " " - echo " " - echo " " - echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " done cat << 'EOF' - -
FileLinesCoverage
$display_file${hit}/${executable}${pct}%
" - echo "
" + echo "
" + echo "
๐Ÿ“„
" + echo "
" + echo " $(basename "$display_file")" + echo "
./${display_file}
" + echo "
" + echo "
" + echo "
" + echo "
" + echo "
${hit}
" + echo "
of ${executable} lines
" + echo "
" + echo "
" + echo "
" + echo "
" + echo "
" + echo "
" + echo " ${pct}%" + echo "
" + echo "
" + echo " View โ†’" + echo "
- + + +
+ EOF @@ -770,6 +986,7 @@ function bashunit::coverage::generate_file_html() { executable=$(bashunit::coverage::get_executable_lines "$file") local hit hit=$(bashunit::coverage::get_hit_lines "$file") + local uncovered=$((executable - hit)) local pct=0 if [[ $executable -gt 0 ]]; then @@ -790,6 +1007,11 @@ function bashunit::coverage::generate_file_html() { hits_by_line[_ln]=$_cnt done < <(bashunit::coverage::get_all_line_hits "$file") + # Count total lines and functions + local total_lines + total_lines=$(wc -l < "$file" | tr -d ' ') + local non_executable=$((total_lines - executable)) + { cat << 'EOF' @@ -798,92 +1020,170 @@ function bashunit::coverage::generate_file_html() { EOF - echo " Coverage: $display_file" + echo " $(basename "$display_file") | Coverage Report" cat << 'EOF' + + + -
- +
+
+ +
+
+EOF + echo " ${pct}%" + cat << 'EOF' + Coverage +
+
+EOF + echo " ${hit}/${executable}" + cat << 'EOF' + Lines +
+
+
+
+
+
+
+
+ Line Coverage Progress +EOF + echo " ${pct}%" + cat << 'EOF' +
+
+EOF + echo "
" + cat << 'EOF' +
+
+
+
+ +EOF + echo " ${hit} lines covered" + cat << 'EOF' +
+
+ +EOF + echo " ${uncovered} lines uncovered" + cat << 'EOF' +
+
+ +EOF + echo " ${non_executable} non-executable" + cat << 'EOF' +
+
+
+
+
+
+
+EOF + echo " ./${display_file}" + echo "
" + echo " ${total_lines} total lines" + echo "
" + cat << 'EOF' +
+
+ EOF - echo "

$display_file

" - echo "
" - echo "
${pct}%
" - echo "
${hit} of ${executable} lines covered
" - echo "
" - echo "
" - echo "
" local lineno=0 while IFS= read -r line || [[ -n "$line" ]]; do @@ -898,7 +1198,7 @@ EOF if bashunit::coverage::is_executable_line "$line" "$lineno"; then # O(1) lookup from pre-loaded array local hits=${hits_by_line[$lineno]:-0} - hits_display="$hits" + hits_display="${hits}ร—" if [[ $hits -gt 0 ]]; then row_class="covered" @@ -907,20 +1207,23 @@ EOF fi fi - echo " " - echo " " - echo " " - echo " " - echo " " + echo " " + echo " " + echo " " + echo " " + echo " " done < "$file" cat << 'EOF' -
$lineno$hits_display$escaped_line
$lineno$hits_display$escaped_line
-
-
+ EOF From 492cebe61d8ddc59cd6cdcde832e4b7669aaf3a4 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 20 Dec 2025 17:29:23 +0100 Subject: [PATCH 2/6] chore: add bin/create-pr:0.10 --- bin/create-pr | 559 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100755 bin/create-pr diff --git a/bin/create-pr b/bin/create-pr new file mode 100755 index 00000000..5560422e --- /dev/null +++ b/bin/create-pr @@ -0,0 +1,559 @@ +#!/bin/bash +# src/check_os.sh +set -o allexport + +# shellcheck disable=SC2034 +_OS="Unknown" + +if [[ "$(uname)" == "Linux" ]]; then + _OS="Linux" +elif [[ "$(uname)" == "Darwin" ]]; then + _OS="OSX" +elif [[ $(uname) == *"MINGW"* ]]; then + _OS="Windows" +fi + +# src/console_header.sh +set -o allexport + +function console_header::print_version() { + printf "%s\n" "$CREATE_PR_VERSION" +} + +function console_header::print_help() { + cat < + Load a custom env file overriding the .env environment variables + + -t|--title + Generate a branch name based on the PR title + "feat" by default + + -v|--version + Displays the current version + + -h|--help + This message + +See source code: https://github.com/Chemaclass/create-pr +EOF +} + +# src/env_configuration.sh +set -o allexport + +# shellcheck source=/dev/null +[[ -f ".env" ]] && source .env set +set +o allexport + +APP_CREATE_PR_ROOT_DIR=${APP_CREATE_PR_ROOT_DIR:-"$(git rev-parse --show-toplevel)"} \ + || error_and_exit "This directory is not a git repository" +PR_LINK_PREFIX_TEXT=${PR_LINK_PREFIX_TEXT:-""} +PR_TICKET_LINK_PREFIX=${PR_TICKET_LINK_PREFIX:-""} +PR_TEMPLATE_PATH=${PR_TEMPLATE_PATH:-".github/PULL_REQUEST_TEMPLATE.md"} +PR_TEMPLATE="$APP_CREATE_PR_ROOT_DIR/$PR_TEMPLATE_PATH" +PR_TITLE_TEMPLATE=${PR_TITLE_TEMPLATE:-"{{TICKET_KEY}}-{{TICKET_NUMBER}} {{PR_TITLE}}"} +PR_TITLE_REMOVE_PREFIX=${PR_TITLE_REMOVE_PREFIX:-"be,fe"} +PR_ASSIGNEE=${PR_ASSIGNEE:-${ASSIGNEE:-"@me"}} +PR_REVIEWER=${PR_REVIEWER:-${REVIEWER:-""}} +PR_RUN_AFTER_CREATION=${PR_RUN_AFTER_CREATION:-""} +TARGET_BRANCH=${TARGET_BRANCH:-"main"} +CURRENT_BRANCH=${CURRENT_BRANCH:-"$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"} \ + || error_and_exit "Failed to get the current branch name." + +REMOTE_URL=${REMOTE_URL:-"$(git config --get remote.origin.url)"} +if [[ "$REMOTE_URL" == *"github.com"* ]]; then + PR_USING_CLIENT="github" +elif [[ "$REMOTE_URL" == *"gitlab.com"* ]]; then + PR_USING_CLIENT="gitlab" +else + echo "Unsupported $REMOTE_URL. Please submit a PR or an issue - so we can work on it." + exit +fi + +export REMOTE_URL +export PR_USING_CLIENT +export PR_TITLE_TEMPLATE +export PR_TITLE_REMOVE_PREFIX +export PR_TEMPLATE +export PR_ASSIGNEE +export PR_REVIEWER +export PR_RUN_AFTER_CREATION +export TARGET_BRANCH +export CURRENT_BRANCH + +# src/helpers.sh +set -o allexport + +function helpers::generate_branch_name() { + local input="$1" + local prefix="${2:-feat}" + + local lowercase + lowercase=$(echo "$input" | tr '[:upper:]' '[:lower:]') + + local branch_name + branch_name=$(echo "$lowercase" | tr ' ' '-') + + echo "${prefix}/${branch_name}" +} + +# src/main.sh +set -euo pipefail + +function main::create_pr() { + validate::target_branch_exists + validate::branch_has_commits + validate::current_branch_is_not_target + + # Push the current branch + if ! git push -u origin "$CURRENT_BRANCH"; then + error_and_exit "Failed to push the current branch to the remote repository."\ + "Please check your git remote settings." + fi + + if [[ "$PR_USING_CLIENT" == "gitlab" ]]; then + main::create_pr_gitlab + else + main::create_pr_github + fi +} + +function main::create_pr_gitlab() { + validate::glab_cli_is_installed + + local glab_command=( + glab mr create + --title "$PR_TITLE" + --target-branch "$TARGET_BRANCH" + --source-branch "$CURRENT_BRANCH" + --assignee "$PR_ASSIGNEE" + --reviewer "$PR_REVIEWER" + --label "$PR_LABEL" + --description "$PR_BODY" + ) + + if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then + glab_command+=("${EXTRA_ARGS[@]}") + fi + + if ! "${glab_command[@]}"; then + error_and_exit "Failed to create the Merge Request." \ + "Ensure you have the correct permissions and the repository is properly configured." + fi + + main::run_after_creation_script +} + +function main::create_pr_github() { + validate::gh_cli_is_installed + + local gh_command=( + gh pr create + --title "$PR_TITLE" + --base "$TARGET_BRANCH" + --head "$CURRENT_BRANCH" + --assignee "$PR_ASSIGNEE" + --reviewer "$PR_REVIEWER" + --label "$PR_LABEL" + --body "$PR_BODY" + ) + + if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then + gh_command+=("${EXTRA_ARGS[@]}") + fi + + if ! "${gh_command[@]}"; then + error_and_exit "Failed to create the Pull Request." \ + "Ensure you have the correct permissions and the repository is properly configured." + fi + + main::run_after_creation_script +} + +function main::run_after_creation_script() { + # Skip if PR_RUN_AFTER_CREATION is not set or empty + if [[ -z "${PR_RUN_AFTER_CREATION:-}" ]]; then + return 0 + fi + + echo "Running post-creation script..." + + # Execute the command and capture exit status + local exit_code=0 + eval "$PR_RUN_AFTER_CREATION" || exit_code=$? + + # Report failure but don't fail the overall PR creation + if [[ $exit_code -ne 0 ]]; then + echo "Warning: Post-creation script exited with code $exit_code, but PR was created successfully." >&2 + return 0 + fi + + echo "Post-creation script completed successfully." + return 0 +} + +# src/pr_body.sh +set -o allexport + +_CURRENT_DIR="$(dirname "${BASH_SOURCE[0]}")" +# shellcheck disable=SC1091 +[ -f "$_CURRENT_DIR/pr_ticket.sh" ] && source "$_CURRENT_DIR/pr_ticket.sh" + +# shellcheck disable=SC2001 +function pr_body() { + local branch_name=$1 + local pr_template=$2 + + if [ -z "$pr_template" ]; then + echo "PR_TEMPLATE is empty; therefore not a valid path." + return + fi + + if [ ! -f "$pr_template" ]; then + echo "$pr_template is not a valid template path." + return + fi + + local ticket_key + ticket_key=$(pr_ticket::key "$branch_name") + local ticket_number + ticket_number=$(pr_ticket::number "$branch_name") + local with_link=false + if [[ -n "${PR_TICKET_LINK_PREFIX:-}" && -n "${ticket_number}" ]]; then + with_link=true + fi + + # {{TICKET_LINK}} + local ticket_link="Nope" + if [[ "$with_link" == true ]]; then + if [[ -z "$ticket_key" ]]; then + ticket_link="${PR_TICKET_LINK_PREFIX}${ticket_number}" + else + ticket_link="${PR_TICKET_LINK_PREFIX}${ticket_key}-${ticket_number}" + fi + ticket_link="${PR_LINK_PREFIX_TEXT}${ticket_link}" + fi + + local result + result=$(perl -pe 's//{{ $1 }}/g' "$pr_template") + result=$(echo "$result" | sed "s|{{[[:space:]]*TICKET_LINK[[:space:]]*}}|$ticket_link|g") + + # {{BACKGROUND}} + local background_text="Provide some context to the reviewer before jumping in the code." + if [[ "$with_link" == true ]]; then + background_text="Details in the ticket." + fi + result=$(echo "$result" | sed "s|{{[[:space:]]*BACKGROUND[[:space:]]*}}|$background_text|g") + + # Trim leading and trailing whitespace from result + result=$(echo "$result" | awk '{$1=$1};1') + + echo "${result:-Description is currently empty}" +} + +# src/pr_label.sh +set -o allexport + +# Find the default label based on the branch prefix +function pr_label() { + local branch_name=$1 + local mapping=${2:-"feat|feature:enhancement;\ + fix|bug|bugfix:bug;\ + docs|documentation:documentation;\ + default:enhancement"} + # Remove empty spaces due to indentation + mapping=${mapping// /} + # Extract the prefix (the part before the first slash or dash) + local prefix + prefix=$(echo "$branch_name" | sed -E 's@^([^/-]+).*@\1@') + # Default label + local default_label="enhancement" + + # Loop through the mapping string to find a match + IFS=';' # Split mapping entries by semicolon + for entry in $mapping; do + # Split each entry into keys and value + IFS=':' read -r keys value <<< "$entry" + + # Check if the prefix matches any of the keys + IFS='|' # Split keys by pipe symbol + for key in $keys; do + if [[ "$prefix" == "$key" ]]; then + echo "$value" + return + fi + done + + # Set the default label if found + if [[ "$keys" == "default" ]]; then + default_label="$value" + fi + done + + # Return the default label if no match is found + echo "$default_label" +} + +# src/pr_ticket.sh +set -o allexport + +# $1 = branch_name +function pr_ticket::number() { + branch_name=$1 + + # Remove optional prefix and split the branch name by hyphens + stripped_branch=${branch_name#*/} + # shellcheck disable=SC2206 + parts=(${stripped_branch//-/ }) + + # Check if the first or second part contains a number and print it; otherwise, print an empty string + if [[ ${parts[0]} =~ ^[0-9]+$ ]]; then + echo "${parts[0]}" + elif [[ ${parts[1]} =~ ^[0-9]+$ ]]; then + echo "${parts[1]}" + else + echo "" + fi +} + +# $1 = branch_name +function pr_ticket::key() { + branch_name=$1 + + # Check if the branch name contains a '/' + if [[ "$branch_name" == *"/"* ]]; then + # Extract the part after the first '/' and process it + branch_suffix="${branch_name#*/}" + # Try to extract the pattern "KEY-NUMBER" and stop after the first occurrence + ticket_key=$(echo "$branch_suffix" | grep -oE "[A-Za-z]+-[0-9]+" | head -n 1 | sed 's/-[0-9]*$//') + + # If no ticket key is found, ensure there's no ticket-like pattern and use the prefix if it's uppercase + if [[ -z "$ticket_key" ]]; then + first_part=$(echo "$branch_name" | cut -d'/' -f2 | grep -oE "^[A-Z]+") + if [[ -n "$first_part" ]]; then + ticket_key="$first_part" + fi + fi + else + # For branch names without '/' + ticket_key=$(echo "$branch_name" | grep -oE "^[A-Za-z]+" | head -n 1) + fi + + # If no ticket key is found, ensure there's no ticket-like pattern and return empty + if [[ -z "$ticket_key" ]]; then + if ! echo "$branch_name" | grep -qE "[A-Za-z]+-[0-9]+"; then + echo "" + return + fi + fi + + echo "$ticket_key" | tr '[:lower:]' '[:upper:]' +} + +# src/pr_title.sh +set -o allexport + +_CURRENT_DIR="$(dirname "${BASH_SOURCE[0]}")" +# shellcheck disable=SC1091 +[ -f "$_CURRENT_DIR/pr_ticket.sh" ] && source "$_CURRENT_DIR/pr_ticket.sh" + +function pr_title() { + local branch_name="$1" + branch_name="${branch_name#*/}" + # Trim any Unicode characters from the branch name + branch_name=$(printf '%s' "$branch_name" | LC_ALL=C tr -cd "$(printf '\t\n\r') -~") + local ticket_key + ticket_key=$(pr_ticket::key "$branch_name") + + local ticket_number + ticket_number=$(pr_ticket::number "$branch_name") + + if [[ -z "$ticket_key" || -z "$ticket_number" ]]; then + pr_title::without_ticket "$branch_name" + return + fi + + local title + title=$(echo "$branch_name" | cut -d'-' -f3- | tr '-' ' '| tr '_' ' ') + title="$(echo "${title:0:1}" | tr '[:lower:]' '[:upper:]')${title:1}" + + # Normalize the template by removing spaces around placeholders + local normalized_template + normalized_template=$(echo "$PR_TITLE_TEMPLATE" | sed -E 's/\{\{[[:space:]]*([^[:space:]]+)[[:space:]]*\}\}/{{\1}}/g') + + # Replace placeholders with actual values + local formatted + formatted="${normalized_template//\{\{TICKET_KEY\}\}/$ticket_key}" + formatted="${formatted//\{\{TICKET_NUMBER\}\}/$ticket_number}" + + local new_title="$title" + + if [[ -n "$PR_TITLE_REMOVE_PREFIX" ]]; then + # Split PR_TITLE_REMOVE_PREFIX into an array + IFS=',' read -ra prefixes <<< "$PR_TITLE_REMOVE_PREFIX" + # Loop through each prefix and remove it from the start if it matches + for prefix in "${prefixes[@]}"; do + # shellcheck disable=SC2001 + new_title="$(echo "$new_title" | sed -e "s/^${prefix}//I")" + done + + # Trim leading whitespace and capitalize the first letter + new_title=$(echo "$new_title" \ + | sed 's/^ *//' \ + | awk '{ print toupper(substr($0,1,1)) tolower(substr($0,2)) }') + fi + + formatted="${formatted//\{\{PR_TITLE\}\}/$new_title}" + + echo "$formatted" +} + +function pr_title::without_ticket() { + input="$1" + # Remove any Unicode characters from the input + input=$(printf '%s' "$input" | LC_ALL=C tr -cd "$(printf '\t\n\r') -~") + # Remove leading digits followed by a hyphen (e.g., "27-") + input="${input#[0-9]*-}" + + result=$(echo "$input" | awk ' + { + gsub(/_/, " ", $0) # Replace underscores with spaces + for (i = 1; i <= NF; i++) { + # Capitalize first letter and lowercase the rest + $i = toupper(substr($i, 1, 1)) tolower(substr($i, 2)) + } + gsub(/-/, " ", $0) # Replace hyphens with spaces + print + }' | sed 's/[[:space:]]*$//') + + echo "$result" +} + +# src/validate.sh +set -o allexport + +GH_CLI_INSTALLATION_URL="https://cli.github.com/" +GLAB_CLI_INSTALLATION_URL="https://gitlab.com/gitlab-org/cli/" + +function error_and_exit() { + echo "Error: $1" >&2 + exit 1 +} + +function validate::target_branch_exists() { + if ! git show-ref --verify --quiet "refs/heads/$TARGET_BRANCH"; then + error_and_exit "Base branch '$TARGET_BRANCH' does not exist. Check the base branch name or create it." + fi +} + +function validate::branch_has_commits() { + if [ "$(git rev-list --count "$CURRENT_BRANCH")" -eq 0 ]; then + error_and_exit "The current branch has no commits. Make sure the branch is not empty." + fi +} + +function validate::current_branch_is_not_target() { + if [ "$CURRENT_BRANCH" = "$TARGET_BRANCH" ]; then + error_and_exit "You are on the same branch as target -> $CURRENT_BRANCH" + fi +} + +function validate::gh_cli_is_installed() { + if ! command -v gh &> /dev/null; then + error_and_exit "gh CLI is not installed. Please install it from $GH_CLI_INSTALLATION_URL and try again." + fi +} + +function validate::glab_cli_is_installed() { + if ! command -v glab &> /dev/null; then + error_and_exit "glab CLI is not installed. Please install it from $GLAB_CLI_INSTALLATION_URL and try again." + fi +} + +#!/bin/bash +set -euo pipefail + +# shellcheck disable=SC2034 +declare -r CREATE_PR_VERSION="0.10.0" + +CREATE_PR_ROOT_DIR="$(dirname "${BASH_SOURCE[0]}")" +export CREATE_PR_ROOT_DIR + + +DRY_RUN=${DRY_RUN:-false} +EXTRA_ARGS=() + +while [[ $# -gt 0 ]]; do + argument="$1" + case $argument in + --debug) + set -x + ;; + --dry-run) + DRY_RUN=true + ;; + -e|--env) + # shellcheck disable=SC1090 + source "$2" + shift + ;; + -t|--title) + helpers::generate_branch_name "$2" "${3:-}" + trap '' EXIT && exit 0 + ;; + -h|--help) + console_header::print_help + trap '' EXIT && exit 0 + ;; + -v|--version) + console_header::print_version + trap '' EXIT && exit 0 + ;; + *) + EXTRA_ARGS+=("$argument") + esac + shift +done + +PR_LABEL=${PR_LABEL:-${LABEL:-$(pr_label "$CURRENT_BRANCH" "${PR_LABEL_MAPPING:-}")}} +PR_TITLE=$(pr_title "$CURRENT_BRANCH") +PR_BODY=$(pr_body "$CURRENT_BRANCH" "$PR_TEMPLATE") + +if [[ "$DRY_RUN" == true ]]; then + if [ ${#EXTRA_ARGS[@]} -gt 0 ]; then + printf "EXTRA_ARGS: %s\n" "${EXTRA_ARGS[@]}" + else + printf "EXTRA_ARGS: empty\n" + fi + printf "REMOTE_URL: %s\n" "$REMOTE_URL" + printf "TARGET_BRANCH: %s\n" "$TARGET_BRANCH" + printf "CURRENT_BRANCH: %s\n" "$CURRENT_BRANCH" + printf "PR_USING_CLIENT: %s\n" "$PR_USING_CLIENT" + printf "PR_TEMPLATE: %s\n" "$PR_TEMPLATE" + printf "PR_LABEL: %s\n" "$PR_LABEL" + printf "PR_TITLE: %s\n" "$PR_TITLE" + printf "PR_BODY:\n%s\n" "$PR_BODY" + exit 0 +fi + +export PR_LABEL +export PR_TITLE +export PR_BODY + +main::create_pr + +echo "Script finished successfully." From c1b01355572d4a7b507c5e3558c99e21daf8533a Mon Sep 17 00:00:00 2001 From: JesusValera Date: Sat, 20 Dec 2025 17:27:11 +0100 Subject: [PATCH 3/6] Use light mode color and improve design --- src/coverage.sh | 397 ++++++++++++++++++++++++++++++------------------ src/runner.sh | 3 + 2 files changed, 249 insertions(+), 151 deletions(-) diff --git a/src/coverage.sh b/src/coverage.sh index 83d6abff..e74cc274 100644 --- a/src/coverage.sh +++ b/src/coverage.sh @@ -10,6 +10,9 @@ _BASHUNIT_COVERAGE_TRACKED_FILES="${_BASHUNIT_COVERAGE_TRACKED_FILES:-}" # The tracked cache file stores files that have already been processed _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE:-}" +# File to store which tests hit each line (for detailed coverage tooltips) +_BASHUNIT_COVERAGE_TEST_HITS_FILE="${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" + # Store the subshell level when coverage trap is enabled # Used to skip recording in nested subshells (command substitution) # Uses $BASH_SUBSHELL which is Bash 3.2 compatible (unlike $BASHPID) @@ -36,15 +39,18 @@ function bashunit::coverage::init() { _BASHUNIT_COVERAGE_DATA_FILE="${coverage_dir}/hits.dat" _BASHUNIT_COVERAGE_TRACKED_FILES="${coverage_dir}/files.dat" _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${coverage_dir}/cache.dat" + _BASHUNIT_COVERAGE_TEST_HITS_FILE="${coverage_dir}/test_hits.dat" # Initialize empty files : > "$_BASHUNIT_COVERAGE_DATA_FILE" : > "$_BASHUNIT_COVERAGE_TRACKED_FILES" : > "$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE" + : > "$_BASHUNIT_COVERAGE_TEST_HITS_FILE" export _BASHUNIT_COVERAGE_DATA_FILE export _BASHUNIT_COVERAGE_TRACKED_FILES export _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE + export _BASHUNIT_COVERAGE_TEST_HITS_FILE } function bashunit::coverage::enable_trap() { @@ -107,12 +113,22 @@ function bashunit::coverage::record_line() { # In parallel mode, use a per-process file to avoid race conditions local data_file="$_BASHUNIT_COVERAGE_DATA_FILE" + local test_hits_file="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" if bashunit::parallel::is_enabled; then data_file="${_BASHUNIT_COVERAGE_DATA_FILE}.$$" + test_hits_file="${_BASHUNIT_COVERAGE_TEST_HITS_FILE}.$$" fi # Record the hit (only if parent directory exists) - [[ -d "$(dirname "$data_file")" ]] && echo "${normalized_file}:${lineno}" >> "$data_file" + if [[ -d "$(dirname "$data_file")" ]]; then + echo "${normalized_file}:${lineno}" >> "$data_file" + + # Also record which test caused this hit (if we're in a test context) + if [[ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" && -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]]; then + # Format: source_file:line|test_file:test_function + echo "${normalized_file}:${lineno}|${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN}" >> "$test_hits_file" + fi + fi } function bashunit::coverage::should_track() { @@ -213,6 +229,7 @@ function bashunit::coverage::aggregate_parallel() { # Aggregate per-process coverage files created during parallel execution local base_file="$_BASHUNIT_COVERAGE_DATA_FILE" local tracked_base="$_BASHUNIT_COVERAGE_TRACKED_FILES" + local test_hits_base="$_BASHUNIT_COVERAGE_TEST_HITS_FILE" # Find and merge all per-process coverage data files # Use nullglob to handle case when no files match @@ -236,6 +253,18 @@ function bashunit::coverage::aggregate_parallel() { done <<< "$pid_files" fi + # Find and merge all per-process test hits files + if [[ -n "$test_hits_base" ]]; then + pid_files=$(ls -1 "${test_hits_base}."* 2>/dev/null) || true + if [[ -n "$pid_files" ]]; then + while IFS= read -r pid_file; do + [[ -f "$pid_file" ]] || continue + cat "$pid_file" >> "$test_hits_base" + rm -f "$pid_file" + done <<< "$pid_files" + fi + fi + # Deduplicate tracked files if [[ -f "$tracked_base" ]]; then sort -u "$tracked_base" -o "$tracked_base" @@ -350,6 +379,36 @@ function bashunit::coverage::get_all_line_hits() { done } +# Get the tests that hit a specific line +# Output format: list of "test_file:test_function" (unique, sorted) +function bashunit::coverage::get_line_tests() { + local file="$1" + local lineno="$2" + + if [[ ! -f "${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" ]]; then + return + fi + + # Format in file: source_file:line|test_file:test_function + grep "^${file}:${lineno}|" "$_BASHUNIT_COVERAGE_TEST_HITS_FILE" 2>/dev/null | \ + cut -d'|' -f2 | sort -u +} + +# Get all test hits for a file in one pass (performance optimization) +# Output format: lineno|test_file:test_function (may have duplicates, one per hit) +function bashunit::coverage::get_all_line_tests() { + local file="$1" + + if [[ ! -f "${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}" ]]; then + return + fi + + # Format in file: source_file:line|test_file:test_function + # Output: lineno|test_file:test_function + grep "^${file}:" "$_BASHUNIT_COVERAGE_TEST_HITS_FILE" 2>/dev/null | \ + sed "s|^${file}:||" | sort -u +} + function bashunit::coverage::get_percentage() { local total_executable=0 local total_hit=0 @@ -617,9 +676,15 @@ function bashunit::coverage::report_html() { total_pct=$((total_hit * 100 / total_executable)) fi + # Get test results + local tests_passed=$(bashunit::state::get_tests_passed) + local tests_failed=$(bashunit::state::get_tests_failed) + local tests_total=$((tests_passed + tests_failed)) + # Generate index.html bashunit::coverage::generate_index_html \ - "$output_dir/index.html" "$total_hit" "$total_executable" "$total_pct" "${file_data[@]}" + "$output_dir/index.html" "$total_hit" "$total_executable" "$total_pct" \ + "$tests_total" "$tests_passed" "$tests_failed" "${file_data[@]}" echo "Coverage HTML report written to: $output_dir/index.html" } @@ -629,7 +694,10 @@ function bashunit::coverage::generate_index_html() { local total_hit="$2" local total_executable="$3" local total_pct="$4" - shift 4 + local tests_total="$5" + local tests_passed="$6" + local tests_failed="$7" + shift 7 local file_data=("$@") # Calculate uncovered lines and file count @@ -656,86 +724,69 @@ function bashunit::coverage::generate_index_html() { --success: #10b981; --success-light: #34d399; --warning: #f59e0b; --warning-light: #fbbf24; --danger: #ef4444; --danger-light: #f87171; - --bg-dark: #0f172a; --bg-card: #1e293b; --bg-hover: #334155; - --text-primary: #f8fafc; --text-secondary: #94a3b8; --text-muted: #64748b; - --border: #334155; + --bg-light: #ffffff; --bg-card: #f8fafc; --bg-hover: #f1f5f9; + --text-primary: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8; + --border: #e2e8f0; } * { margin: 0; padding: 0; box-sizing: border-box; } - body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg-dark); color: var(--text-primary); min-height: 100vh; line-height: 1.6; } - .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 0; position: relative; overflow: hidden; } - .header::before { content: ''; position: absolute; inset: 0; background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); opacity: 0.5; } + body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg-light); color: var(--text-primary); min-height: 100vh; line-height: 1.6; } + .header { background: var(--bg-card); padding: 0; position: relative; overflow: hidden; border-bottom: 1px solid var(--border); } .header-content { position: relative; z-index: 1; max-width: 1400px; margin: 0 auto; padding: 40px 30px; } .header-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; } .logo { display: flex; align-items: center; gap: 12px; } - .logo-icon { width: 48px; height: 48px; background: rgba(255,255,255,0.2); border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 24px; backdrop-filter: blur(10px); } - .logo-text { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.5px; } - .logo-text span { opacity: 0.7; font-weight: 400; } - .header-badge { background: rgba(255,255,255,0.2); padding: 8px 16px; border-radius: 20px; font-size: 0.85rem; font-weight: 500; backdrop-filter: blur(10px); } - .header-title { font-size: 2.5rem; font-weight: 800; margin-bottom: 8px; letter-spacing: -1px; } - .header-subtitle { font-size: 1.1rem; opacity: 0.9; } - .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; max-width: 1400px; margin: -50px auto 0; padding: 0 30px; position: relative; z-index: 10; } - .stat-card { background: var(--bg-card); border-radius: 16px; padding: 24px; border: 1px solid var(--border); transition: all 0.3s ease; position: relative; overflow: hidden; animation: fadeInUp 0.5s ease-out forwards; opacity: 0; } - .stat-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 4px; } - .stat-card.coverage::before { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } - .stat-card.lines::before { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); } - .stat-card.covered::before { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); } - .stat-card.uncovered::before { background: linear-gradient(135deg, #cb2d3e 0%, #ef473a 100%); } - .stat-card.files::before { background: linear-gradient(135deg, #f7971e 0%, #ffd200 100%); } - .stat-card:hover { transform: translateY(-4px); border-color: var(--primary); box-shadow: 0 20px 40px rgba(0,0,0,0.3); } - .stat-card:nth-child(1) { animation-delay: 0.1s; } .stat-card:nth-child(2) { animation-delay: 0.2s; } .stat-card:nth-child(3) { animation-delay: 0.3s; } .stat-card:nth-child(4) { animation-delay: 0.4s; } .stat-card:nth-child(5) { animation-delay: 0.5s; } - .stat-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 24px; margin-bottom: 16px; } - .stat-card.coverage .stat-icon { background: rgba(99, 102, 241, 0.2); } - .stat-card.lines .stat-icon { background: rgba(79, 209, 254, 0.2); } - .stat-card.covered .stat-icon { background: rgba(16, 185, 129, 0.2); } - .stat-card.uncovered .stat-icon { background: rgba(239, 68, 68, 0.2); } - .stat-card.files .stat-icon { background: rgba(245, 158, 11, 0.2); } - .stat-value { font-size: 2.5rem; font-weight: 800; letter-spacing: -1px; margin-bottom: 4px; } - .stat-card.coverage .stat-value { color: var(--primary-light); } - .stat-card.lines .stat-value { color: #4facfe; } - .stat-card.covered .stat-value { color: var(--success); } - .stat-card.uncovered .stat-value { color: var(--danger); } - .stat-card.files .stat-value { color: var(--warning); } - .stat-label { color: var(--text-secondary); font-size: 0.9rem; font-weight: 500; text-transform: uppercase; letter-spacing: 1px; } + .logo img { width: 40px; height: 40px; } + .logo-text { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.5px; color: var(--text-primary); } + .logo-text span { opacity: 0.6; font-weight: 400; } + .header-badge { background: var(--bg-hover); padding: 8px 16px; border-radius: 20px; font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); } + .header-title { font-size: 2.5rem; font-weight: 800; margin-bottom: 8px; letter-spacing: -1px; color: var(--text-primary); } + .header-subtitle { font-size: 1.1rem; opacity: 0.7; color: var(--text-secondary); } .main { max-width: 1400px; margin: 0 auto; padding: 40px 30px; } - .gauge-section { background: var(--bg-card); border-radius: 20px; padding: 40px; margin-bottom: 30px; border: 1px solid var(--border); display: flex; align-items: center; gap: 60px; } + .gauge-section { background: var(--bg-card); border-radius: 20px; padding: 40px; margin-bottom: 30px; border: 1px solid var(--border); display: flex; align-items: center; gap: 60px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .gauge-container { position: relative; width: 200px; height: 200px; flex-shrink: 0; } - .gauge-bg { fill: none; stroke: var(--bg-hover); stroke-width: 20; } + .gauge-bg { fill: none; stroke: #e5e7eb; stroke-width: 20; } .gauge-fill { fill: none; stroke: url(#gaugeGradient); stroke-width: 20; stroke-linecap: round; transform: rotate(-90deg); transform-origin: center; animation: gaugeAnimation 1.5s ease-out forwards; } @keyframes gaugeAnimation { from { stroke-dashoffset: 440; } } - @keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } - .gauge-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; } - .gauge-percent { font-size: 3.5rem; font-weight: 800; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } - .gauge-label { color: var(--text-secondary); font-size: 0.9rem; text-transform: uppercase; letter-spacing: 2px; } + @keyframes fadeInUp { from { opacity: 0; } to { opacity: 1 } } + .gauge-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; width: 100%; } + .gauge-percent { font-size: 3.5rem; font-weight: 800; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; line-height: 1; margin: 0; display: block; } + .gauge-label { color: var(--text-secondary); font-size: 0.9rem; text-transform: uppercase; letter-spacing: 2px; margin: 0; display: block; } .gauge-info { flex: 1; } .gauge-title { font-size: 1.8rem; font-weight: 700; margin-bottom: 12px; } .gauge-description { color: var(--text-secondary); font-size: 1.05rem; margin-bottom: 24px; line-height: 1.7; } - .gauge-breakdown { display: flex; gap: 30px; flex-wrap: wrap; } - .breakdown-item { display: flex; align-items: center; gap: 10px; } + .breakdown-item { display: flex; align-items: center; gap: 6px; white-space: nowrap; } .breakdown-dot { width: 12px; height: 12px; border-radius: 50%; } + .breakdown-dot.total { background: #94a3b8; } .breakdown-dot.covered { background: var(--success); } .breakdown-dot.uncovered { background: var(--danger); } - .breakdown-dot.ignored { background: var(--text-muted); } + .breakdown-dot.files { background: var(--warning); } + .breakdown-dot.tests { background: #a78bfa; } + .breakdown-dot.tests-passed { background: var(--success); } + .breakdown-dot.tests-failed { background: var(--danger); } .breakdown-label { color: var(--text-secondary); font-size: 0.9rem; } .breakdown-value { font-weight: 600; color: var(--text-primary); } + .compact-metrics { display: flex; flex-direction: column; gap: 10px; } + .metrics-group { background: var(--bg-hover); padding: 12px 16px; border-radius: 8px; border-left: 3px solid var(--primary); } + .metrics-group.coverage-group { border-left-color: var(--success); } + .metrics-group.test-group { border-left-color: #a78bfa; } + .metrics-group-title { font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; } + .metrics-inline { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; font-size: 0.9rem; } + .metrics-inline .breakdown-item { margin: 0; } .section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 16px; } .section-title { font-size: 1.5rem; font-weight: 700; display: flex; align-items: center; gap: 12px; } - .section-title::before { content: ''; width: 4px; height: 24px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 2px; } - .legend { display: flex; gap: 20px; background: var(--bg-hover); padding: 12px 20px; border-radius: 10px; } - .legend-item { display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: var(--text-secondary); } + .legend { display: flex; gap: 20px; background: #f1f5f9; padding: 12px 20px; border-radius: 10px; } + .legend-item { display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: var(--text-secondary); pointer-events: none; } .legend-color { width: 16px; height: 16px; border-radius: 4px; } .legend-color.high { background: var(--success); } .legend-color.medium { background: var(--warning); } .legend-color.low { background: var(--danger); } - .files-table { background: var(--bg-card); border-radius: 16px; overflow: hidden; border: 1px solid var(--border); } + .files-table { background: var(--bg-card); border-radius: 16px; overflow: hidden; border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .files-table table { width: 100%; border-collapse: collapse; } - .files-table th { background: var(--bg-hover); padding: 16px 24px; text-align: left; font-weight: 600; color: var(--text-secondary); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid var(--border); } + .files-table th { background: #f8fafc; padding: 16px 24px; text-align: left; font-weight: 600; color: var(--text-secondary); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid var(--border); } .files-table td { padding: 20px 24px; border-bottom: 1px solid var(--border); vertical-align: middle; } .files-table tr:last-child td { border-bottom: none; } - .files-table tbody tr { transition: all 0.2s ease; animation: fadeInUp 0.5s ease-out forwards; opacity: 0; } - .files-table tbody tr:nth-child(1) { animation-delay: 0.6s; } .files-table tbody tr:nth-child(2) { animation-delay: 0.7s; } .files-table tbody tr:nth-child(3) { animation-delay: 0.8s; } .files-table tbody tr:nth-child(4) { animation-delay: 0.9s; } .files-table tbody tr:nth-child(5) { animation-delay: 1.0s; } + .files-table tbody tr { transition: all 0.2s ease; animation: fadeInUp 0.5s ease-out forwards; opacity: 0; cursor: pointer; } .files-table tbody tr:hover { background: var(--bg-hover); } - .file-info { display: flex; align-items: center; gap: 16px; } - .file-icon { width: 44px; height: 44px; background: var(--bg-hover); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; } + .file-info { display: flex; flex-direction: column; gap: 4px; } .file-name { font-weight: 600; color: var(--text-primary); text-decoration: none; font-size: 1rem; transition: color 0.2s; } .file-name:hover { color: var(--primary-light); } .file-path { color: var(--text-muted); font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; } @@ -754,21 +805,22 @@ function bashunit::coverage::generate_index_html() { .coverage-percent.medium { color: var(--warning); } .coverage-percent.low { color: var(--danger); } .view-btn { display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; background: var(--bg-hover); border: 1px solid var(--border); border-radius: 8px; color: var(--text-primary); text-decoration: none; font-size: 0.9rem; font-weight: 500; transition: all 0.2s; } - .view-btn:hover { background: var(--primary); border-color: var(--primary); transform: translateX(4px); } + .view-btn:hover { background: var(--primary); border-color: var(--primary); } .footer { max-width: 1400px; margin: 0 auto; padding: 40px 30px; text-align: center; border-top: 1px solid var(--border); } - .footer-content { display: flex; justify-content: center; align-items: center; gap: 20px; flex-wrap: wrap; } + .footer-content { display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap; } .footer-text { color: var(--text-muted); font-size: 0.9rem; } .footer-link { color: var(--primary-light); text-decoration: none; font-weight: 500; transition: color 0.2s; } .footer-link:hover { color: var(--primary); } .footer-divider { width: 4px; height: 4px; background: var(--text-muted); border-radius: 50%; } @media (max-width: 768px) { .header-content { padding: 30px 20px; } .header-title { font-size: 1.8rem; } - .stats-grid { padding: 0 20px; gap: 15px; margin-top: -30px; } - .stat-card { padding: 20px; } .stat-value { font-size: 2rem; } .main { padding: 30px 20px; } .gauge-section { flex-direction: column; padding: 30px; gap: 30px; } - .gauge-container { width: 160px; height: 160px; } .gauge-percent { font-size: 2.5rem; } - .gauge-breakdown { flex-direction: column; gap: 15px; } + .gauge-container { width: 160px; height: 160px; } + .gauge-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; } + .gauge-percent { font-size: 2.5rem; line-height: 1; margin: 0; } + .gauge-label { font-size: 0.75rem; letter-spacing: 1.5px; margin: 0; } + .metrics-inline { flex-direction: column; gap: 10px; align-items: flex-start; } .files-table th, .files-table td { padding: 15px; } .coverage-cell { width: auto; } .coverage-bar-container { flex-direction: column; align-items: flex-start; gap: 8px; } @@ -781,7 +833,7 @@ function bashunit::coverage::generate_index_html() {
EOF @@ -792,43 +844,6 @@ EOF

Comprehensive line-by-line coverage analysis for your bash scripts

-
-
-
๐Ÿ“Š
-EOF - echo "
${total_pct}%
" - cat << 'EOF' -
Total Coverage
-
-
-
๐Ÿ“
-EOF - echo "
${total_executable}
" - cat << 'EOF' -
Total Lines
-
-
-
โœ…
-EOF - echo "
${total_hit}
" - cat << 'EOF' -
Lines Covered
-
-
-
โŒ
-EOF - echo "
${total_uncovered}
" - cat << 'EOF' -
Lines Uncovered
-
-
-
๐Ÿ“
-EOF - echo "
${file_count}
" - cat << 'EOF' -
Source Files
-
-
@@ -854,27 +869,68 @@ EOF

Overall Code Coverage

EOF - echo "

Your test suite executes ${total_hit} out of ${total_executable} executable lines across ${file_count} source files. Coverage measures which lines of your source code are executed when running tests, helping identify untested code paths.

" + echo "

${total_hit} of ${total_executable} executable lines covered across ${file_count} files.

" cat << 'EOF' -
-
- - Covered: + +
+
+
Coverage Metrics
+
+
+ + Total: EOF - echo " ${total_hit} lines" + echo " ${total_executable} lines" cat << 'EOF' -
-
- - Uncovered: +
+
+ + Covered: EOF - echo " ${total_uncovered} lines" + echo " ${total_hit} lines" cat << 'EOF' +
+
+ + Uncovered: +EOF + echo " ${total_uncovered} lines" + cat << 'EOF' +
+
-
- - Non-executable: - comments, declarations +
+
Test Results
+
+
+ + Files: +EOF + echo " ${file_count}" + cat << 'EOF' +
+
+ + Tests: +EOF + echo " ${tests_total} total" + cat << 'EOF' +
+
+ + Passed: +EOF + echo " ${tests_passed}" + cat << 'EOF' +
+
+ + Failed: +EOF + echo " ${tests_failed}" + cat << 'EOF' +
+
@@ -910,7 +966,6 @@ EOF File Lines Coverage - @@ -926,14 +981,11 @@ EOF class="medium" fi - echo " " + echo " " echo " " echo "
" - echo "
๐Ÿ“„
" - echo "
" - echo " $(basename "$display_file")" - echo "
./${display_file}
" - echo "
" + echo " $(basename "$display_file")" + echo "
./${display_file}
" echo "
" echo " " echo " " @@ -950,9 +1002,6 @@ EOF echo " ${pct}%" echo "
" echo " " - echo " " - echo " View โ†’" - echo " " echo " " done @@ -1007,6 +1056,24 @@ function bashunit::coverage::generate_file_html() { hits_by_line[_ln]=$_cnt done < <(bashunit::coverage::get_all_line_hits "$file") + # Pre-load test hits data into associative array (for tooltips) + # Key: line number, Value: newline-separated list of "test_file:test_function" + declare -A tests_by_line + local _line_and_test + while IFS= read -r _line_and_test; do + [[ -z "$_line_and_test" ]] && continue + local _tln="${_line_and_test%%|*}" + local _tinfo="${_line_and_test#*|}" + if [[ -n "${tests_by_line[$_tln]:-}" ]]; then + # Append only if not already present (avoid duplicates) + if [[ "${tests_by_line[$_tln]}" != *"$_tinfo"* ]]; then + tests_by_line[$_tln]="${tests_by_line[$_tln]}"$'\n'"${_tinfo}" + fi + else + tests_by_line[$_tln]="$_tinfo" + fi + done < <(bashunit::coverage::get_all_line_tests "$file") + # Count total lines and functions local total_lines total_lines=$(wc -l < "$file" | tr -d ' ') @@ -1028,22 +1095,21 @@ EOF