Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

testers.testEqualArrayOrMap: init #383214

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions doc/build-helpers/testers.chapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,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 to 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"
];
script = ''
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 marshalling between Nix expressions and shell variables.
This imposes the restriction that arrays and "maps" have values which are string-like.

NOTE: At least one of `expectedArray` and `expectedMap` must be provided.

`name` (string)

: The name of the test.

`script` (string)

: The singular task of `script` is to populate `actualArray` or `actualMap` (it may populate both).
To do this, `script` may access the following shell variables:

- `valuesArray` (available when `valuesArray` is provided to the tester)
- `valuesMap` (available when `valuesMap` is provided to the tester)
- `actualArray` (available when `expectedArray` is provided to the tester)
- `actualMap` (available when `expectedMap` is provided to the tester)

While both `expectedArray` and `expectedMap` are in scope during the execution of `script`, they *must not* be accessed or modified from within `script`.

`valuesArray` (array of string-like values, optional)

: An array of string-like values.
This array may be used within `script`.

`valuesMap` (attribute set of string-like values, optional)

: An attribute set of string-like values.
This attribute set may be used within `script`.

`expectedArray` (array of string-like values, optional)

: An array of string-like values.
This array *must not* be accessed or modified from within `script`.
When provided, `script` is expected to populate `actualArray`.

`expectedMap` (attribute set of string-like values, optional)

: An attribute set of string-like values.
This attribute set *must not* be accessed or modified from within `script`.
When provided, `script` 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.
Expand Down
12 changes: 12 additions & 0 deletions doc/redirects.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"ex-build-helpers-extendMkDerivation": [
"index.html#ex-build-helpers-extendMkDerivation"
],
"ex-testEqualArrayOrMap-test-function-add-cowbell": [
"index.html#ex-testEqualArrayOrMap-test-function-add-cowbell"
],
"neovim": [
"index.html#neovim"
],
Expand Down Expand Up @@ -332,6 +335,15 @@
"footnote-stdenv-find-inputs-location.__back.0": [
"index.html#footnote-stdenv-find-inputs-location.__back.0"
],
"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"
],
Expand Down
5 changes: 5 additions & 0 deletions pkgs/build-support/testers/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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 { inherit lib stdenvNoCC; };

# See https://nixos.org/manual/nixpkgs/unstable/#tester-testVersion
# or doc/build-helpers/testers.chapter.md
testVersion =
Expand Down
2 changes: 2 additions & 0 deletions pkgs/build-support/testers/test/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -352,4 +352,6 @@ lib.recurseIntoAttrs {
touch -- "$out"
'';
};

testEqualArrayOrMap = pkgs.callPackages ../testEqualArrayOrMap/tests.nix { };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# shellcheck shell=bash

# Tests if an array is declared.
isDeclaredArray() {
# shellcheck disable=SC2034
local -nr arrayRef="$1" && [[ ${!arrayRef@a} =~ a ]]
}

# 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 ! isDeclaredArray expectedArrayRef; then
nixErrorLog "first arugment expectedArrayRef must be an array reference to a declared array"
exit 1
fi

if ! isDeclaredArray actualArrayRef; then
nixErrorLog "second arugment actualArrayRef must be an array reference to a declared array"
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
}
102 changes: 102 additions & 0 deletions pkgs/build-support/testers/testEqualArrayOrMap/assert-equal-map.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# shellcheck shell=bash

# Tests if a map is declared.
isDeclaredMap() {
# shellcheck disable=SC2034
local -nr mapRef="$1" && [[ ${!mapRef@a} =~ A ]]
}

# 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 ! isDeclaredMap expectedMapRef; then
nixErrorLog "first arugment expectedMapRef must be an associative array reference to a declared associative array"
exit 1
fi

if ! isDeclaredMap actualMapRef; then
nixErrorLog "second arugment actualMapRef must be an associative array reference to a declared associative array"
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
}
61 changes: 61 additions & 0 deletions pkgs/build-support/testers/testEqualArrayOrMap/build-command.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# shellcheck shell=bash

set -eu

# NOTE: If neither expectedArray nor expectedMap are declared, the test is meaningless.
# This precondition is checked in the Nix expression through an assert.

preScript() {
if isDeclaredArray valuesArray; then
# shellcheck disable=SC2154
nixLog "using valuesArray: $(declare -p valuesArray)"
fi

if isDeclaredMap valuesMap; then
# shellcheck disable=SC2154
nixLog "using valuesMap: $(declare -p valuesMap)"
fi

if isDeclaredArray expectedArray; then
# shellcheck disable=SC2154
nixLog "using expectedArray: $(declare -p expectedArray)"
declare -ag actualArray=()
fi

if isDeclaredMap expectedMap; then
# shellcheck disable=SC2154
nixLog "using expectedMap: $(declare -p expectedMap)"
declare -Ag actualMap=()
fi

return 0
}

scriptPhase() {
runHook preScript

runHook script

runHook postScript
}

postScript() {
if isDeclaredArray expectedArray; then
nixLog "using actualArray: $(declare -p actualArray)"
nixLog "comparing actualArray against expectedArray"
assertEqualArray expectedArray actualArray
nixLog "actualArray matches expectedArray"
fi

if isDeclaredMap expectedMap; then
nixLog "using actualMap: $(declare -p actualMap)"
nixLog "comparing actualMap against expectedMap"
assertEqualMap expectedMap actualMap
nixLog "actualMap matches expectedMap"
fi

return 0
}

runHook scriptPhase
touch "${out:?}"
Loading