diff --git a/doc/build-helpers/testers.chapter.md b/doc/build-helpers/testers.chapter.md index 74e70162ed3d..904ee76a0d95 100644 --- a/doc/build-helpers/testers.chapter.md +++ b/doc/build-helpers/testers.chapter.md @@ -347,6 +347,97 @@ testers.testEqualContents { ::: +## `testEqualArrayOrMap` {#tester-testEqualArrayOrMap} + +Check that bash arrays (including associative arrays, referred to as "maps") are populated correctly. + +This can be used to ensure setup hooks are registered in a certain order, or write unit tests for shell functions which transform arrays. + +:::{.example #ex-testEqualArrayOrMap-test-function-add-cowbell} + +# Test a function which appends a value to an array + +```nix +testers.testEqualArrayOrMap { + name = "test-function-add-cowbell"; + valuesArray = [ + "cowbell" + "cowbell" + ]; + expectedArray = [ + "cowbell" + "cowbell" + "cowbell" + ]; + checkSetupScript = '' + addCowbell() { + local -rn arrayNameRef="$1" + arrayNameRef+=( "cowbell" ) + } + + nixLog "appending all values in valuesArray to actualArray" + for value in "''${valuesArray[@]}"; do + actualArray+=( "$value" ) + done + + nixLog "applying addCowbell" + addCowbell actualArray + ''; +} +``` + +::: + +### Inputs {#tester-testEqualArrayOrMap-inputs} + +NOTE: Internally, this tester uses `__structuredAttrs` to handle marhsalling between Nix expressions and shell variables. +This imposes the restriction that arrays and "maps" have values which are string-coercible. + +NOTE: At least one of `expectedArray` and `expectedMap` must be provided. + +`name` (string) + +: The name of the test. + +`checkSetupScript` (string) + +: The singular task of `checkSetupScript` is to populate `actualArray` or `actualMap` (it may populate both). + To do this, checkSetupScript may access the following shell variables: + + - `valuesArray` + - `valuesMap` + - `actualArray` + - `actualMap` + + While both `expectedArray` and `expectedMap` are in scope during the execution of `checkSetupScript`, they *must not* be accessed or modified from within `checkSetupScript`. + +`valuesArray` (array of string-like values, optional) + +: An array of string-coercible values. + This array may be used within `checkSetupScript`. + +`valuesMap` (attribute set of string-like values, optional) + +: An attribute set of string-coercible values. + This attribute set may be used within `checkSetupScript`. + +`expectedArray` (array of string-like values, optional) + +: An array of string-coercible values. + This array *must not* be accessed or modified from within `checkSetupScript`. + When provided, `checkSetupScript` is expected to populate `actualArray`. + +`expectedMap` (attribute set of string-like values, optional) + +: An attribute set of string-coercible values. + This attribute set *must not* be accessed or modified from within `checkSetupScript`. + When provided, `checkSetupScript` is expected to populate `actualMap`. + +### Return value {#tester-testEqualArrayOrMap-return} + +The tester produces an empty output and only succeeds when `expectedArray` and `expectedMap` match `actualArray` and `actualMap`, respectively, when non-null. +The build log will contain differences encountered. + ## `testEqualDerivation` {#tester-testEqualDerivation} Checks that two packages produce the exact same build instructions. diff --git a/doc/redirects.json b/doc/redirects.json index fe713d03384a..c72770cb50c1 100644 --- a/doc/redirects.json +++ b/doc/redirects.json @@ -11,6 +11,9 @@ "ex-testBuildFailurePrime-doc-example": [ "index.html#ex-testBuildFailurePrime-doc-example" ], + "ex-testEqualArrayOrMap-test-function-add-cowbell": [ + "index.html#ex-testEqualArrayOrMap-test-function-add-cowbell" + ], "neovim": [ "index.html#neovim" ], @@ -344,6 +347,15 @@ "tester-testBuildFailurePrime-return": [ "index.html#tester-testBuildFailurePrime-return" ], + "tester-testEqualArrayOrMap": [ + "index.html#tester-testEqualArrayOrMap" + ], + "tester-testEqualArrayOrMap-inputs": [ + "index.html#tester-testEqualArrayOrMap-inputs" + ], + "tester-testEqualArrayOrMap-return": [ + "index.html#tester-testEqualArrayOrMap-return" + ], "variables-specifying-dependencies": [ "index.html#variables-specifying-dependencies" ], diff --git a/pkgs/build-support/testers/default.nix b/pkgs/build-support/testers/default.nix index 9934e84d7b5e..bdb1726e37ee 100644 --- a/pkgs/build-support/testers/default.nix +++ b/pkgs/build-support/testers/default.nix @@ -69,6 +69,11 @@ fi ''; + # See https://nixos.org/manual/nixpkgs/unstable/#tester-testEqualArrayOrMap + # or doc/build-helpers/testers.chapter.md + # NOTE: Must be `import`-ed rather than `callPackage`-d to preserve the `override` attribute. + testEqualArrayOrMap = import ./testEqualArrayOrMap/tester.nix { inherit lib runCommand; }; + # See https://nixos.org/manual/nixpkgs/unstable/#tester-testVersion # or doc/build-helpers/testers.chapter.md testVersion = diff --git a/pkgs/build-support/testers/test/default.nix b/pkgs/build-support/testers/test/default.nix index 7e4df128391d..2bd3d1247817 100644 --- a/pkgs/build-support/testers/test/default.nix +++ b/pkgs/build-support/testers/test/default.nix @@ -356,4 +356,6 @@ lib.recurseIntoAttrs { touch -- "$out" ''; }; + + testEqualArrayOrMap = lib.recurseIntoAttrs (pkgs.callPackages ../testEqualArrayOrMap/tests.nix { }); } diff --git a/pkgs/build-support/testers/testEqualArrayOrMap/assert-equal-array.sh b/pkgs/build-support/testers/testEqualArrayOrMap/assert-equal-array.sh new file mode 100644 index 000000000000..4e68b3b1488c --- /dev/null +++ b/pkgs/build-support/testers/testEqualArrayOrMap/assert-equal-array.sh @@ -0,0 +1,65 @@ +# shellcheck shell=bash + +# Asserts that two arrays are equal, printing out differences if they are not. +# Does not short circuit on the first difference. +assertEqualArray() { + if (($# != 2)); then + nixErrorLog "expected two arguments!" + nixErrorLog "usage: assertEqualArray expectedArrayRef actualArrayRef" + exit 1 + fi + + local -nr expectedArrayRef="$1" + local -nr actualArrayRef="$2" + + if [[ ! ${expectedArrayRef@a} =~ a ]]; then + nixErrorLog "first arugment expectedArrayRef must be an array reference" + exit 1 + fi + + if [[ ! ${actualArrayRef@a} =~ a ]]; then + nixErrorLog "second arugment actualArrayRef must be an array reference" + exit 1 + fi + + local -ir expectedLength=${#expectedArrayRef[@]} + local -ir actualLength=${#actualArrayRef[@]} + + local -i hasDiff=0 + + if ((expectedLength != actualLength)); then + nixErrorLog "arrays differ in length: expectedArray has length $expectedLength but actualArray has length $actualLength" + hasDiff=1 + fi + + local -i idx=0 + local expectedValue + local actualValue + + # We iterate so long as at least one array has indices we've not considered. + # This means that `idx` is a valid index to *at least one* of the arrays. + for ((idx = 0; idx < expectedLength || idx < actualLength; idx++)); do + # Update values for variables which are still in range/valid. + if ((idx < expectedLength)); then + expectedValue="${expectedArrayRef[idx]}" + fi + + if ((idx < actualLength)); then + actualValue="${actualArrayRef[idx]}" + fi + + # Handle comparisons. + if ((idx >= expectedLength)); then + nixErrorLog "arrays differ at index $idx: expectedArray has no such index but actualArray has value ${actualValue@Q}" + hasDiff=1 + elif ((idx >= actualLength)); then + nixErrorLog "arrays differ at index $idx: expectedArray has value ${expectedValue@Q} but actualArray has no such index" + hasDiff=1 + elif [[ $expectedValue != "$actualValue" ]]; then + nixErrorLog "arrays differ at index $idx: expectedArray has value ${expectedValue@Q} but actualArray has value ${actualValue@Q}" + hasDiff=1 + fi + done + + ((hasDiff)) && exit 1 || return 0 +} diff --git a/pkgs/build-support/testers/testEqualArrayOrMap/assert-equal-map.sh b/pkgs/build-support/testers/testEqualArrayOrMap/assert-equal-map.sh new file mode 100644 index 000000000000..1e07b3aaff02 --- /dev/null +++ b/pkgs/build-support/testers/testEqualArrayOrMap/assert-equal-map.sh @@ -0,0 +1,96 @@ +# shellcheck shell=bash + +# Asserts that two maps are equal, printing out differences if they are not. +# Does not short circuit on the first difference. +assertEqualMap() { + if (($# != 2)); then + nixErrorLog "expected two arguments!" + nixErrorLog "usage: assertEqualMap expectedMapRef actualMapRef" + exit 1 + fi + + local -nr expectedMapRef="$1" + local -nr actualMapRef="$2" + + if [[ ! ${expectedMapRef@a} =~ A ]]; then + nixErrorLog "first arugment expectedMapRef must be an associative array reference" + exit 1 + fi + + if [[ ! ${actualMapRef@a} =~ A ]]; then + nixErrorLog "second arugment actualMapRef must be an associative array reference" + exit 1 + fi + + # NOTE: + # From the `sort` manpage: "The locale specified by the environment affects sort order. Set LC_ALL=C to get the + # traditional sort order that uses native byte values." + # We specify the environment variable in a subshell to avoid polluting the caller's environment. + + local -a sortedExpectedKeys + mapfile -d '' -t sortedExpectedKeys < <(printf '%s\0' "${!expectedMapRef[@]}" | LC_ALL=C sort --stable --zero-terminated) + + local -a sortedActualKeys + mapfile -d '' -t sortedActualKeys < <(printf '%s\0' "${!actualMapRef[@]}" | LC_ALL=C sort --stable --zero-terminated) + + local -ir expectedLength=${#expectedMapRef[@]} + local -ir actualLength=${#actualMapRef[@]} + + local -i hasDiff=0 + + if ((expectedLength != actualLength)); then + nixErrorLog "maps differ in length: expectedMap has length $expectedLength but actualMap has length $actualLength" + hasDiff=1 + fi + + local -i expectedKeyIdx=0 + local expectedKey + local expectedValue + local -i actualKeyIdx=0 + local actualKey + local actualValue + + # We iterate so long as at least one map has keys we've not considered. + while ((expectedKeyIdx < expectedLength || actualKeyIdx < actualLength)); do + # Update values for variables which are still in range/valid. + if ((expectedKeyIdx < expectedLength)); then + expectedKey="${sortedExpectedKeys["$expectedKeyIdx"]}" + expectedValue="${expectedMapRef["$expectedKey"]}" + fi + + if ((actualKeyIdx < actualLength)); then + actualKey="${sortedActualKeys["$actualKeyIdx"]}" + actualValue="${actualMapRef["$actualKey"]}" + fi + + # In the case actualKeyIdx is valid and expectedKey comes after actualKey or expectedKeyIdx is invalid, actualMap + # has an extra key relative to expectedMap. + # NOTE: In Bash, && and || have the same precedence, so use the fact they're left-associative to enforce groups. + if ((actualKeyIdx < actualLength)) && [[ $expectedKey > $actualKey ]] || ((expectedKeyIdx >= expectedLength)); then + nixErrorLog "maps differ at key ${actualKey@Q}: expectedMap has no such key but actualMap has value ${actualValue@Q}" + hasDiff=1 + actualKeyIdx+=1 + + # In the case actualKeyIdx is invalid or expectedKey comes before actualKey, expectedMap has an extra key relative + # to actualMap. + # NOTE: By virtue of the previous condition being false, we know the negation is true. Namely, expectedKeyIdx is + # valid AND (actualKeyIdx is invalid OR expectedKey <= actualKey). + elif ((actualKeyIdx >= actualLength)) || [[ $expectedKey < $actualKey ]]; then + nixErrorLog "maps differ at key ${expectedKey@Q}: expectedMap has value ${expectedValue@Q} but actualMap has no such key" + hasDiff=1 + expectedKeyIdx+=1 + + # In the case where both key indices are valid and the keys are equal. + else + if [[ $expectedValue != "$actualValue" ]]; then + nixErrorLog "maps differ at key ${expectedKey@Q}: expectedMap has value ${expectedValue@Q} but actualMap has value ${actualValue@Q}" + hasDiff=1 + fi + + expectedKeyIdx+=1 + actualKeyIdx+=1 + fi + done + + ((hasDiff)) && exit 1 || return 0 +} diff --git a/pkgs/build-support/testers/testEqualArrayOrMap/tester.nix b/pkgs/build-support/testers/testEqualArrayOrMap/tester.nix new file mode 100644 index 000000000000..73f8972c0eb6 --- /dev/null +++ b/pkgs/build-support/testers/testEqualArrayOrMap/tester.nix @@ -0,0 +1,92 @@ +# NOTE: Must be `import`-ed rather than `callPackage`-d to preserve the `override` attribute. +# NOTE: We must use `pkgs.runCommand` instead of `testers.runCommand` to build `testers.testEqualArrayOrMap`, or else +# our negative tests will not work. See ./tests.nix for more information. +{ + lib, + runCommand, +}: +let + inherit (lib) maintainers; + inherit (lib.customisation) makeOverridable; + inherit (lib.strings) optionalString; + + testEqualArrayOrMap = + { + name, + valuesArray ? null, + valuesMap ? null, + expectedArray ? null, + expectedMap ? null, + checkSetupScript ? '' + nixErrorLog "no checkSetupScript provided!" + exit 1 + '', + }: + runCommand name + { + __structuredAttrs = true; + strictDeps = true; + + nativeBuildInputs = [ + ./assert-equal-array.sh + ./assert-equal-map.sh + ]; + + inherit valuesArray valuesMap; + inherit expectedArray expectedMap; + + preCheckSetupScript = + optionalString (expectedArray == null && expectedMap == null) '' + nixErrorLog "neither expectedArray nor expectedMap were set, so test is meaningless!" + exit 1 + '' + + optionalString (valuesArray != null) '' + nixLog "using valuesArray: $(declare -p valuesArray)" + '' + + optionalString (valuesMap != null) '' + nixLog "using valuesMap: $(declare -p valuesMap)" + '' + + optionalString (expectedArray != null) '' + nixLog "using expectedArray: $(declare -p expectedArray)" + declare -ag actualArray + '' + + optionalString (expectedMap != null) '' + nixLog "using expectedMap: $(declare -p expectedMap)" + declare -Ag actualMap + ''; + + # NOTE: + # The singular task of checkSetupScript is to populate actualArray or actualMap. To do this, checkSetupScript + # may access valuesArray, valuesMap, actualArray, and actualMap, but should *never* access or modify expectedArray, + # or expectedMap. + inherit checkSetupScript; + + postCheckSetupScript = + optionalString (expectedArray != null) '' + nixLog "using actualArray: $(declare -p actualArray)" + nixLog "comparing actualArray against expectedArray" + assertEqualArray expectedArray actualArray + nixLog "actualArray matches expectedArray" + '' + + optionalString (expectedMap != null) '' + nixLog "using actualMap: $(declare -p actualMap)" + nixLog "comparing actualMap against expectedMap" + assertEqualMap expectedMap actualMap + nixLog "actualMap matches expectedMap" + ''; + } + '' + nixLog "running preCheckSetupScript" + runHook preCheckSetupScript + + nixLog "running checkSetupScript" + runHook checkSetupScript + + nixLog "running postCheckSetupScript" + runHook postCheckSetupScript + + nixLog "test passed" + touch "$out" + ''; +in +makeOverridable testEqualArrayOrMap diff --git a/pkgs/build-support/testers/testEqualArrayOrMap/tests.nix b/pkgs/build-support/testers/testEqualArrayOrMap/tests.nix new file mode 100644 index 000000000000..92888151d43e --- /dev/null +++ b/pkgs/build-support/testers/testEqualArrayOrMap/tests.nix @@ -0,0 +1,279 @@ +# NOTE: We must use `pkgs.runCommand` instead of `testers.runCommand` for negative tests -- those wrapped with +# `testers.testBuildFailure`. This is due to the fact that `testers.testBuildFailure` modifies the derivation such that +# it produces an output containing the exit code, logs, and other things. Since `testers.runCommand` expects the empty +# derivation, it produces a hash mismatch. +{ runCommand, testers, ... }: +let + inherit (testers) testEqualArrayOrMap testBuildFailure; + concatValuesArrayToActualArray = '' + nixLog "appending all values in valuesArray to actualArray" + for value in "''${valuesArray[@]}"; do + actualArray+=( "$value" ) + done + ''; + concatValuesMapToActualMap = '' + nixLog "adding all values in valuesMap to actualMap" + for key in "''${!valuesMap[@]}"; do + actualMap["$key"]="''${valuesMap["$key"]}" + done + ''; +in +{ + # NOTE: This particular test is used in the docs: + # See https://nixos.org/manual/nixpkgs/unstable/#tester-testEqualArrayOrMap + # or doc/build-helpers/testers.chapter.md + docs-test-function-add-cowbell = testEqualArrayOrMap { + name = "test-function-add-cowbell"; + valuesArray = [ + "cowbell" + "cowbell" + ]; + expectedArray = [ + "cowbell" + "cowbell" + "cowbell" + ]; + checkSetupScript = '' + addCowbell() { + local -rn arrayNameRef="$1" + arrayNameRef+=( "cowbell" ) + } + + nixLog "appending all values in valuesArray to actualArray" + for value in "''${valuesArray[@]}"; do + actualArray+=( "$value" ) + done + + nixLog "applying addCowbell" + addCowbell actualArray + ''; + }; + array-append = testEqualArrayOrMap { + name = "testEqualArrayOrMap-array-append"; + valuesArray = [ + "apple" + "bee" + "cat" + ]; + expectedArray = [ + "apple" + "bee" + "cat" + "dog" + ]; + checkSetupScript = '' + ${concatValuesArrayToActualArray} + actualArray+=( "dog" ) + ''; + }; + array-prepend = testEqualArrayOrMap { + name = "testEqualArrayOrMap-array-prepend"; + valuesArray = [ + "apple" + "bee" + "cat" + ]; + expectedArray = [ + "dog" + "apple" + "bee" + "cat" + ]; + checkSetupScript = '' + actualArray+=( "dog" ) + ${concatValuesArrayToActualArray} + ''; + }; + array-empty = testEqualArrayOrMap { + name = "testEqualArrayOrMap-array-empty"; + valuesArray = [ + "apple" + "bee" + "cat" + ]; + expectedArray = [ ]; + checkSetupScript = '' + # doing nothing + ''; + }; + array-missing-value = + let + name = "testEqualArrayOrMap-array-missing-value"; + failure = testEqualArrayOrMap { + name = "${name}-failure"; + valuesArray = [ "apple" ]; + expectedArray = [ ]; + checkSetupScript = concatValuesArrayToActualArray; + }; + in + runCommand name + { + failed = testBuildFailure failure; + passthru = { + inherit failure; + }; + } + '' + nixLog "Checking for exit code 1" + (( 1 == "$(cat "$failed/testBuildFailure.exit")" )) + nixLog "Checking for first error message" + grep -F \ + "ERROR: assertEqualArray: arrays differ in length: expectedArray has length 0 but actualArray has length 1" \ + "$failed/testBuildFailure.log" + nixLog "Checking for second error message" + grep -F \ + "ERROR: assertEqualArray: arrays differ at index 0: expectedArray has no such index but actualArray has value 'apple'" \ + "$failed/testBuildFailure.log" + nixLog "Test passed" + touch $out + ''; + map-insert = testEqualArrayOrMap { + name = "testEqualArrayOrMap-map-insert"; + valuesMap = { + apple = "0"; + bee = "1"; + cat = "2"; + }; + expectedMap = { + apple = "0"; + bee = "1"; + cat = "2"; + dog = "3"; + }; + checkSetupScript = '' + ${concatValuesMapToActualMap} + actualMap["dog"]="3" + ''; + }; + map-remove = testEqualArrayOrMap { + name = "testEqualArrayOrMap-map-remove"; + valuesMap = { + apple = "0"; + bee = "1"; + cat = "2"; + dog = "3"; + }; + expectedMap = { + apple = "0"; + cat = "2"; + dog = "3"; + }; + checkSetupScript = '' + ${concatValuesMapToActualMap} + unset 'actualMap[bee]' + ''; + }; + map-missing-key = + let + name = "testEqualArrayOrMap-map-missing-key"; + failure = testEqualArrayOrMap { + name = "${name}-failure"; + valuesMap = { + bee = "1"; + cat = "2"; + dog = "3"; + }; + expectedMap = { + apple = "0"; + bee = "1"; + cat = "2"; + dog = "3"; + }; + checkSetupScript = concatValuesMapToActualMap; + }; + in + runCommand name + { + failed = testBuildFailure failure; + passthru = { + inherit failure; + }; + } + '' + nixLog "Checking for exit code 1" + (( 1 == "$(cat "$failed/testBuildFailure.exit")" )) + nixLog "Checking for first error message" + grep -F \ + "ERROR: assertEqualMap: maps differ in length: expectedMap has length 4 but actualMap has length 3" \ + "$failed/testBuildFailure.log" + nixLog "Checking for second error message" + grep -F \ + "ERROR: assertEqualMap: maps differ at key 'apple': expectedMap has value '0' but actualMap has no such key" \ + "$failed/testBuildFailure.log" + nixLog "Test passed" + touch $out + ''; + map-missing-key-with-empty = + let + name = "map-missing-key-with-empty"; + failure = testEqualArrayOrMap { + name = "${name}-failure"; + valuesArray = [ ]; + expectedMap.apple = 1; + checkSetupScript = '' + nixLog "doing nothing in checkSetupScript" + ''; + }; + in + runCommand name + { + failed = testBuildFailure failure; + passthru = { + inherit failure; + }; + } + '' + nixLog "Checking for exit code 1" + (( 1 == "$(cat "$failed/testBuildFailure.exit")" )) + nixLog "Checking for first error message" + grep -F \ + "ERROR: assertEqualMap: maps differ in length: expectedMap has length 1 but actualMap has length 0" \ + "$failed/testBuildFailure.log" + nixLog "Checking for second error message" + grep -F \ + "ERROR: assertEqualMap: maps differ at key 'apple': expectedMap has value '1' but actualMap has no such key" \ + "$failed/testBuildFailure.log" + nixLog "Test passed" + touch $out + ''; + map-extra-key = + let + name = "testEqualArrayOrMap-map-extra-key"; + failure = testEqualArrayOrMap { + name = "${name}-failure"; + valuesMap = { + apple = "0"; + bee = "1"; + cat = "2"; + dog = "3"; + }; + expectedMap = { + apple = "0"; + bee = "1"; + dog = "3"; + }; + checkSetupScript = concatValuesMapToActualMap; + }; + in + runCommand + { + failed = testBuildFailure failure; + passthru = { + inherit failure; + }; + } + '' + nixLog "Checking for exit code 1" + (( 1 == "$(cat "$failed/testBuildFailure.exit")" )) + nixLog "Checking for first error message" + grep -F \ + "ERROR: assertEqualMap: maps differ in length: expectedMap has length 3 but actualMap has length 4" \ + "$failed/testBuildFailure.log" + nixLog "Checking for second error message" + grep -F \ + "ERROR: assertEqualMap: maps differ at key 'cat': expectedMap has no such key but actualMap has value '2'" \ + "$failed/testBuildFailure.log" + nixLog "Test passed" + touch $out + ''; +}