diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5aa18e..6fdd8263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- `assert_is_symlink`, `assert_is_not_symlink` and `assert_symlink_to` assert on a symbolic link itself. Every other filesystem assertion follows the link, so a link and its target were indistinguishable and a dangling link read as "does not exist" (#981) + ### Added - Named snapshot assertions support multiple snapshots per test; mismatches show the resolved path and `--snapshot-update` hint (#986) diff --git a/completions/_bashunit b/completions/_bashunit index a162e56d..a3cf0205 100644 --- a/completions/_bashunit +++ b/completions/_bashunit @@ -29,7 +29,7 @@ _bashunit() { assert_greater_than assert_is_directory assert_is_directory_empty assert_is_directory_not_empty assert_is_directory_not_readable assert_is_directory_not_writable assert_is_directory_readable - assert_is_directory_writable assert_is_file assert_is_file_empty + assert_is_directory_writable assert_is_file assert_is_file_empty assert_is_not_symlink assert_is_symlink assert_json_contains assert_json_equals assert_json_key_exists assert_less_or_equal_than assert_less_than assert_line_count assert_match_named_snapshot assert_match_named_snapshot_ignore_colors @@ -38,7 +38,7 @@ _bashunit() { assert_not_same assert_same assert_string_ends_with assert_string_matches_format assert_string_not_ends_with assert_string_not_matches_format assert_string_not_starts_with - assert_string_starts_with assert_successful_code assert_true + assert_string_starts_with assert_successful_code assert_symlink_to assert_true assert_unsuccessful_code assert_within_delta ) compadd -a assert_fns diff --git a/completions/bashunit.bash b/completions/bashunit.bash index d894761e..4d5d4704 100644 --- a/completions/bashunit.bash +++ b/completions/bashunit.bash @@ -42,7 +42,7 @@ assert_files_not_equals assert_general_error assert_greater_or_equal_than \ assert_greater_than assert_is_directory assert_is_directory_empty \ assert_is_directory_not_empty assert_is_directory_not_readable \ assert_is_directory_not_writable assert_is_directory_readable \ -assert_is_directory_writable assert_is_file assert_is_file_empty \ +assert_is_directory_writable assert_is_file assert_is_file_empty assert_is_not_symlink assert_is_symlink \ assert_json_contains assert_json_equals assert_json_key_exists \ assert_less_or_equal_than assert_less_than assert_line_count \ assert_match_named_snapshot assert_match_named_snapshot_ignore_colors \ @@ -51,7 +51,7 @@ assert_not_contains assert_not_empty assert_not_equals assert_not_matches \ assert_not_same assert_same assert_string_ends_with \ assert_string_matches_format assert_string_not_ends_with \ assert_string_not_matches_format assert_string_not_starts_with \ -assert_string_starts_with assert_successful_code assert_true \ +assert_string_starts_with assert_successful_code assert_symlink_to assert_true \ assert_unsuccessful_code assert_within_delta" # compgen output is split into COMPREPLY words on purpose; mapfile/read -a would need bash 4+. diff --git a/docs/assertions.md b/docs/assertions.md index 783edbb8..c3f16f10 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -23,7 +23,7 @@ to narrow it (`bashunit doc json`). | **Numbers** | [assert_less_than](#assert-less-than) · [assert_less_or_equal_than](#assert-less-or-equal-than) · [assert_greater_than](#assert-greater-than) · [assert_greater_or_equal_than](#assert-greater-or-equal-than) · [assert_within_delta](#assert-within-delta) | | **Dates** | [assert_date_equals](#assert-date-equals) · [assert_date_before](#assert-date-before) · [assert_date_after](#assert-date-after) · [assert_date_within_range](#assert-date-within-range) · [assert_date_within_delta](#assert-date-within-delta) | | **Exit codes and commands** | [assert_exit_code](#assert-exit-code) · [assert_successful_code](#assert-successful-code) · [assert_unsuccessful_code](#assert-unsuccessful-code) · [assert_general_error](#assert-general-error) · [assert_command_not_found](#assert-command-not-found) · [assert_exec](#assert-exec) | -| **Files** | [assert_file_exists](#assert-file-exists) · [assert_file_not_exists](#assert-file-not-exists) · [assert_file_contains](#assert-file-contains) · [assert_file_not_contains](#assert-file-not-contains) · [assert_is_file](#assert-is-file) · [assert_is_file_empty](#assert-is-file-empty) · [assert_file_permissions](#assert-file-permissions) · [assert_files_equals](#assert-files-equals) · [assert_files_not_equals](#assert-files-not-equals) | +| **Files** | [assert_file_exists](#assert-file-exists) · [assert_file_not_exists](#assert-file-not-exists) · [assert_file_contains](#assert-file-contains) · [assert_file_not_contains](#assert-file-not-contains) · [assert_is_file](#assert-is-file) · [assert_is_file_empty](#assert-is-file-empty) · [assert_is_symlink](#assert-is-symlink) · [assert_is_not_symlink](#assert-is-not-symlink) · [assert_symlink_to](#assert-symlink-to) · [assert_file_permissions](#assert-file-permissions) · [assert_files_equals](#assert-files-equals) · [assert_files_not_equals](#assert-files-not-equals) | | **Directories** | [assert_directory_exists](#assert-directory-exists) · [assert_directory_not_exists](#assert-directory-not-exists) · [assert_is_directory](#assert-is-directory) · [assert_is_directory_empty](#assert-is-directory-empty) · [assert_is_directory_not_empty](#assert-is-directory-not-empty) · [assert_is_directory_readable](#assert-is-directory-readable) · [assert_is_directory_not_readable](#assert-is-directory-not-readable) · [assert_is_directory_writable](#assert-is-directory-writable) · [assert_is_directory_not_writable](#assert-is-directory-not-writable) | | **Arrays** | [assert_arrays_equal](#assert-arrays-equal) · [assert_array_contains](#assert-array-contains) · [assert_array_not_contains](#assert-array-not-contains) · [assert_array_length](#assert-array-length) | | **JSON** | [assert_json_equals](#assert-json-equals) · [assert_json_contains](#assert-json-contains) · [assert_json_key_exists](#assert-json-key-exists) | @@ -911,6 +911,51 @@ function test_failure() { ``` ::: +## assert_is_symlink +> `assert_is_symlink "path"` + +Reports an error if `path` is not a symbolic link. + +Every other filesystem assertion follows the link — `assert_is_file` and +`assert_file_exists` report on the *target*, so a link and the file it points at +look identical, and a dangling link reads as "does not exist". This is the +assertion that tells them apart, and it passes for a link whose target is gone. + +::: code-group +```bash [Example] +function test_success() { + ln -s /etc/hosts ./hosts_link + + assert_is_symlink "./hosts_link" +} +``` +::: + +## assert_is_not_symlink +> `assert_is_not_symlink "path"` + +Reports an error if `path` is a symbolic link. + +## assert_symlink_to +> `assert_symlink_to "expected_target" "path"` + +Reports an error if `path` is not a symbolic link, or if it points somewhere +other than `expected_target`. + +The target is compared **as written**, via `readlink`, not fully resolved: that +is what the test author wrote, and `readlink -f` is GNU-only. A relative link +therefore compares as the relative string it is. + +::: code-group +```bash [Example] +function test_success() { + ln -s ./releases/42 ./current + + assert_symlink_to "./releases/42" "./current" +} +``` +::: + ## assert_file_permissions > `assert_file_permissions "mode" "file"` diff --git a/src/assert/files.sh b/src/assert/files.sh index 04ac6332..059f2347 100644 --- a/src/assert/files.sh +++ b/src/assert/files.sh @@ -154,3 +154,82 @@ function assert_file_permissions() { bashunit::state::add_assertions_passed } + +## +# Whether $1 is a symbolic link, regardless of whether its target resolves. +# +# Every other filesystem assertion here follows the link: -f and -e report on +# the target, so a link and the file it points at are indistinguishable, and a +# dangling link reads as "does not exist" -- the same answer as a path that was +# never created. This is the one that can tell them apart. +# +# Arguments: $1 - path, $2 - unused, $3 - optional label override +## +function assert_is_symlink() { + bashunit::assert::should_skip && return 0 + + local expected="$1" + + if [ ! -L "$expected" ]; then + bashunit::assert::fail_with "${3:-}" "${expected}" "to be a symlink" "but is not a symlink" + return + fi + + bashunit::state::add_assertions_passed +} + +## +# Whether $1 exists and is not a symbolic link. +# Arguments: $1 - path, $2 - unused, $3 - optional label override +## +function assert_is_not_symlink() { + bashunit::assert::should_skip && return 0 + + local expected="$1" + + if [ -L "$expected" ]; then + bashunit::assert::fail_with "${3:-}" "${expected}" "not to be a symlink" "but is a symlink" + return + fi + + bashunit::state::add_assertions_passed +} + +## +# Whether $2 is a symlink pointing at $1. +# +# Compares the target as written, via `readlink`, not the fully resolved path. +# Two reasons: it is what the test author wrote in the first place, so a failure +# names something they recognise; and `readlink -f` is GNU-only, so resolving +# would need a second implementation for BSD/macOS. A relative link therefore +# compares as the relative string it is. +# +# Arguments: $1 - expected target, $2 - path, $3 - optional label override +## +function assert_symlink_to() { + bashunit::assert::should_skip && return 0 + + if [ $# -lt 2 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 2 "expected_target, path" "$#" + return 2 + fi + + local expected="$1" + local path="$2" + + if [ ! -L "$path" ]; then + bashunit::assert::fail_with "${3:-}" "${path}" "to be a symlink" "but is not a symlink" + return + fi + + local actual + actual=$(readlink "$path") + + if [ "$actual" != "$expected" ]; then + bashunit::assert::fail_with "${3:-}" "${expected}" \ + "to be the target of ${path}, but got " "${actual}" + return + fi + + bashunit::state::add_assertions_passed +} diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot index 2139b6f3..8b113545 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot @@ -359,6 +359,31 @@ Reports an error if `file` does not contains the search string. - assert_file_not_contains is the inverse of this assertion and takes the same arguments. +## assert_is_symlink +-------------- +> `assert_is_symlink "path"` + +Reports an error if `path` is not a symbolic link. + +Every other filesystem assertion follows the link — `assert_is_file` and +`assert_file_exists` report on the *target*, so a link and the file it points at +look identical, and a dangling link reads as "does not exist". This is the +assertion that tells them apart, and it passes for a link whose target is gone. + + +## assert_is_not_symlink +## assert_symlink_to +-------------- +> `assert_symlink_to "expected_target" "path"` + +Reports an error if `path` is not a symbolic link, or if it points somewhere +other than `expected_target`. + +The target is compared **as written**, via `readlink`, not fully resolved: that +is what the test author wrote, and `readlink -f` is GNU-only. A relative link +therefore compares as the relative string it is. + + ## assert_file_permissions -------------- > `assert_file_permissions "mode" "file"` diff --git a/tests/unit/assert/files_test.sh b/tests/unit/assert/files_test.sh index 9fc424af..5930f6e8 100644 --- a/tests/unit/assert/files_test.sh +++ b/tests/unit/assert/files_test.sh @@ -249,3 +249,119 @@ function test_unsuccessful_assert_file_permissions_when_file_does_not_exist() { "$file" "to have permissions 644" "but the file does not exist")" \ "$(assert_file_permissions "644" "$file")" } + +# Symlinks are invisible to every other filesystem assertion: -f and -e follow +# the link, so a link and its target look identical, and a dangling link reads +# as "does not exist" -- the same as a path that was never created. +function symlink_fixture_dir() { + bashunit::temp_dir +} + +function test_successful_assert_is_symlink() { + if bashunit::check_os::is_windows; then + bashunit::skip && return + fi + local dir + dir=$(symlink_fixture_dir) + : >"$dir/target" + ln -s "$dir/target" "$dir/link" + + assert_empty "$(assert_is_symlink "$dir/link")" +} + +# The case nothing covers today: a link whose target is gone is still a link. +function test_successful_assert_is_symlink_when_the_target_is_missing() { + if bashunit::check_os::is_windows; then + bashunit::skip && return + fi + local dir + dir=$(symlink_fixture_dir) + ln -s "$dir/never_created" "$dir/dangling" + + assert_empty "$(assert_is_symlink "$dir/dangling")" +} + +function test_unsuccessful_assert_is_symlink_on_a_regular_file() { + if bashunit::check_os::is_windows; then + bashunit::skip && return + fi + local dir + dir=$(symlink_fixture_dir) + : >"$dir/plain" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert is symlink on a regular file" \ + "$dir/plain" "to be a symlink" "but is not a symlink")" \ + "$(assert_is_symlink "$dir/plain")" +} + +function test_successful_assert_is_not_symlink() { + if bashunit::check_os::is_windows; then + bashunit::skip && return + fi + local dir + dir=$(symlink_fixture_dir) + : >"$dir/plain" + + assert_empty "$(assert_is_not_symlink "$dir/plain")" +} + +function test_unsuccessful_assert_is_not_symlink() { + if bashunit::check_os::is_windows; then + bashunit::skip && return + fi + local dir + dir=$(symlink_fixture_dir) + : >"$dir/target" + ln -s "$dir/target" "$dir/link" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert is not symlink" \ + "$dir/link" "not to be a symlink" "but is a symlink")" \ + "$(assert_is_not_symlink "$dir/link")" +} + +function test_successful_assert_symlink_to() { + if bashunit::check_os::is_windows; then + bashunit::skip && return + fi + local dir + dir=$(symlink_fixture_dir) + : >"$dir/target" + ln -s "$dir/target" "$dir/link" + + assert_empty "$(assert_symlink_to "$dir/target" "$dir/link")" +} + +function test_unsuccessful_assert_symlink_to_reports_both_targets() { + if bashunit::check_os::is_windows; then + bashunit::skip && return + fi + local dir + dir=$(symlink_fixture_dir) + : >"$dir/target" + ln -s "$dir/target" "$dir/link" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert symlink to reports both targets" \ + "$dir/other" "to be the target of ${dir}/link, but got " "$dir/target")" \ + "$(assert_symlink_to "$dir/other" "$dir/link")" +} + +function test_unsuccessful_assert_symlink_to_on_a_regular_file() { + if bashunit::check_os::is_windows; then + bashunit::skip && return + fi + local dir + dir=$(symlink_fixture_dir) + : >"$dir/plain" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert symlink to on a regular file" \ + "$dir/plain" "to be a symlink" "but is not a symlink")" \ + "$(assert_symlink_to "$dir/whatever" "$dir/plain")" +}