···165A derivation that runs `shellcheck` on the given script(s).166The build will fail if `shellcheck` finds any issues.1670000000000000000000000000000000000000000000000000000000000168## `testVersion` {#tester-testVersion}169170Checks that the output from running a command contains the specified version string in it as a whole word.···404```405406:::0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000407408## `testEqualDerivation` {#tester-testEqualDerivation}409
···165A derivation that runs `shellcheck` on the given script(s).166The build will fail if `shellcheck` finds any issues.167168+## `shfmt` {#tester-shfmt}169+170+Run files through `shfmt`, a shell script formatter, failing if any files are reformatted.171+172+:::{.example #ex-shfmt}173+# Run `testers.shfmt`174+175+A single script176+177+```nix178+testers.shfmt {179+ name = "script";180+ src = ./script.sh;181+}182+```183+184+Multiple files185+186+```nix187+let188+ inherit (lib) fileset;189+in190+testers.shfmt {191+ name = "nixbsd";192+ src = fileset.toSource {193+ root = ./.;194+ fileset = fileset.unions [195+ ./lib.sh196+ ./nixbsd-activate197+ ];198+ };199+}200+```201+202+:::203+204+### Inputs {#tester-shfmt-inputs}205+206+`name` (string)207+: The name of the test.208+ `name` is required because it massively improves traceability of test failures.209+ The name of the derivation produced by the tester is `shfmt-${name}`.210+211+`src` (path-like)212+: The path to the shell script(s) to check.213+ This can be a single file or a directory containing shell files.214+ All files in `src` will be checked, so you may want to provide `fileset`-based source instead of a whole directory.215+216+`indent` (integer, optional)217+: The number of spaces to use for indentation.218+ Defaults to `2`.219+ A value of `0` indents with tabs.220+221+### Return value {#tester-shfmt-return}222+223+A derivation that runs `shfmt` on the given script(s), producing an empty output upon success.224+The build will fail if `shfmt` reformats anything.225+226## `testVersion` {#tester-testVersion}227228Checks that the output from running a command contains the specified version string in it as a whole word.···346```347348:::349+350+## `testEqualArrayOrMap` {#tester-testEqualArrayOrMap}351+352+Check that bash arrays (including associative arrays, referred to as "maps") are populated correctly.353+354+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.355+356+:::{.example #ex-testEqualArrayOrMap-test-function-add-cowbell}357+358+# Test a function which appends a value to an array359+360+```nix361+testers.testEqualArrayOrMap {362+ name = "test-function-add-cowbell";363+ valuesArray = [364+ "cowbell"365+ "cowbell"366+ ];367+ expectedArray = [368+ "cowbell"369+ "cowbell"370+ "cowbell"371+ ];372+ script = ''373+ addCowbell() {374+ local -rn arrayNameRef="$1"375+ arrayNameRef+=( "cowbell" )376+ }377+378+ nixLog "appending all values in valuesArray to actualArray"379+ for value in "''${valuesArray[@]}"; do380+ actualArray+=( "$value" )381+ done382+383+ nixLog "applying addCowbell"384+ addCowbell actualArray385+ '';386+}387+```388+389+:::390+391+### Inputs {#tester-testEqualArrayOrMap-inputs}392+393+NOTE: Internally, this tester uses `__structuredAttrs` to handle marshalling between Nix expressions and shell variables.394+This imposes the restriction that arrays and "maps" have values which are string-like.395+396+NOTE: At least one of `expectedArray` and `expectedMap` must be provided.397+398+`name` (string)399+400+: The name of the test.401+402+`script` (string)403+404+: The singular task of `script` is to populate `actualArray` or `actualMap` (it may populate both).405+ To do this, `script` may access the following shell variables:406+407+ - `valuesArray` (available when `valuesArray` is provided to the tester)408+ - `valuesMap` (available when `valuesMap` is provided to the tester)409+ - `actualArray` (available when `expectedArray` is provided to the tester)410+ - `actualMap` (available when `expectedMap` is provided to the tester)411+412+ While both `expectedArray` and `expectedMap` are in scope during the execution of `script`, they *must not* be accessed or modified from within `script`.413+414+`valuesArray` (array of string-like values, optional)415+416+: An array of string-like values.417+ This array may be used within `script`.418+419+`valuesMap` (attribute set of string-like values, optional)420+421+: An attribute set of string-like values.422+ This attribute set may be used within `script`.423+424+`expectedArray` (array of string-like values, optional)425+426+: An array of string-like values.427+ This array *must not* be accessed or modified from within `script`.428+ When provided, `script` is expected to populate `actualArray`.429+430+`expectedMap` (attribute set of string-like values, optional)431+432+: An attribute set of string-like values.433+ This attribute set *must not* be accessed or modified from within `script`.434+ When provided, `script` is expected to populate `actualMap`.435+436+### Return value {#tester-testEqualArrayOrMap-return}437+438+The tester produces an empty output and only succeeds when `expectedArray` and `expectedMap` match `actualArray` and `actualMap`, respectively, when non-null.439+The build log will contain differences encountered.440441## `testEqualDerivation` {#tester-testEqualDerivation}442
···1+# shellcheck shell=bash2+3+# Tests if an array is declared.4+isDeclaredArray() {5+ # shellcheck disable=SC20346+ local -nr arrayRef="$1" && [[ ${!arrayRef@a} =~ a ]]7+}8+9+# Asserts that two arrays are equal, printing out differences if they are not.10+# Does not short circuit on the first difference.11+assertEqualArray() {12+ if (($# != 2)); then13+ nixErrorLog "expected two arguments!"14+ nixErrorLog "usage: assertEqualArray expectedArrayRef actualArrayRef"15+ exit 116+ fi17+18+ local -nr expectedArrayRef="$1"19+ local -nr actualArrayRef="$2"20+21+ if ! isDeclaredArray "${!expectedArrayRef}"; then22+ nixErrorLog "first arugment expectedArrayRef must be an array reference to a declared array"23+ exit 124+ fi25+26+ if ! isDeclaredArray "${!actualArrayRef}"; then27+ nixErrorLog "second arugment actualArrayRef must be an array reference to a declared array"28+ exit 129+ fi30+31+ local -ir expectedLength=${#expectedArrayRef[@]}32+ local -ir actualLength=${#actualArrayRef[@]}33+34+ local -i hasDiff=035+36+ if ((expectedLength != actualLength)); then37+ nixErrorLog "arrays differ in length: expectedArray has length $expectedLength but actualArray has length $actualLength"38+ hasDiff=139+ fi40+41+ local -i idx=042+ local expectedValue43+ local actualValue44+45+ # We iterate so long as at least one array has indices we've not considered.46+ # This means that `idx` is a valid index to *at least one* of the arrays.47+ for ((idx = 0; idx < expectedLength || idx < actualLength; idx++)); do48+ # Update values for variables which are still in range/valid.49+ if ((idx < expectedLength)); then50+ expectedValue="${expectedArrayRef[idx]}"51+ fi52+53+ if ((idx < actualLength)); then54+ actualValue="${actualArrayRef[idx]}"55+ fi56+57+ # Handle comparisons.58+ if ((idx >= expectedLength)); then59+ nixErrorLog "arrays differ at index $idx: expectedArray has no such index but actualArray has value ${actualValue@Q}"60+ hasDiff=161+ elif ((idx >= actualLength)); then62+ nixErrorLog "arrays differ at index $idx: expectedArray has value ${expectedValue@Q} but actualArray has no such index"63+ hasDiff=164+ elif [[ $expectedValue != "$actualValue" ]]; then65+ nixErrorLog "arrays differ at index $idx: expectedArray has value ${expectedValue@Q} but actualArray has value ${actualValue@Q}"66+ hasDiff=167+ fi68+ done69+70+ ((hasDiff)) && exit 1 || return 071+}
···1+# shellcheck shell=bash2+3+# Tests if a map is declared.4+isDeclaredMap() {5+ # shellcheck disable=SC20346+ local -nr mapRef="$1" && [[ ${!mapRef@a} =~ A ]]7+}8+9+# Asserts that two maps are equal, printing out differences if they are not.10+# Does not short circuit on the first difference.11+assertEqualMap() {12+ if (($# != 2)); then13+ nixErrorLog "expected two arguments!"14+ nixErrorLog "usage: assertEqualMap expectedMapRef actualMapRef"15+ exit 116+ fi17+18+ local -nr expectedMapRef="$1"19+ local -nr actualMapRef="$2"20+21+ if ! isDeclaredMap "${!expectedMapRef}"; then22+ nixErrorLog "first arugment expectedMapRef must be an associative array reference to a declared associative array"23+ exit 124+ fi25+26+ if ! isDeclaredMap "${!actualMapRef}"; then27+ nixErrorLog "second arugment actualMapRef must be an associative array reference to a declared associative array"28+ exit 129+ fi30+31+ # NOTE:32+ # From the `sort` manpage: "The locale specified by the environment affects sort order. Set LC_ALL=C to get the33+ # traditional sort order that uses native byte values."34+ # We specify the environment variable in a subshell to avoid polluting the caller's environment.35+36+ local -a sortedExpectedKeys37+ mapfile -d '' -t sortedExpectedKeys < <(printf '%s\0' "${!expectedMapRef[@]}" | LC_ALL=C sort --stable --zero-terminated)38+39+ local -a sortedActualKeys40+ mapfile -d '' -t sortedActualKeys < <(printf '%s\0' "${!actualMapRef[@]}" | LC_ALL=C sort --stable --zero-terminated)41+42+ local -ir expectedLength=${#expectedMapRef[@]}43+ local -ir actualLength=${#actualMapRef[@]}44+45+ local -i hasDiff=046+47+ if ((expectedLength != actualLength)); then48+ nixErrorLog "maps differ in length: expectedMap has length $expectedLength but actualMap has length $actualLength"49+ hasDiff=150+ fi51+52+ local -i expectedKeyIdx=053+ local expectedKey54+ local expectedValue55+ local -i actualKeyIdx=056+ local actualKey57+ local actualValue58+59+ # We iterate so long as at least one map has keys we've not considered.60+ while ((expectedKeyIdx < expectedLength || actualKeyIdx < actualLength)); do61+ # Update values for variables which are still in range/valid.62+ if ((expectedKeyIdx < expectedLength)); then63+ expectedKey="${sortedExpectedKeys["$expectedKeyIdx"]}"64+ expectedValue="${expectedMapRef["$expectedKey"]}"65+ fi66+67+ if ((actualKeyIdx < actualLength)); then68+ actualKey="${sortedActualKeys["$actualKeyIdx"]}"69+ actualValue="${actualMapRef["$actualKey"]}"70+ fi71+72+ # In the case actualKeyIdx is valid and expectedKey comes after actualKey or expectedKeyIdx is invalid, actualMap73+ # has an extra key relative to expectedMap.74+ # NOTE: In Bash, && and || have the same precedence, so use the fact they're left-associative to enforce groups.75+ if ((actualKeyIdx < actualLength)) && [[ $expectedKey > $actualKey ]] || ((expectedKeyIdx >= expectedLength)); then76+ nixErrorLog "maps differ at key ${actualKey@Q}: expectedMap has no such key but actualMap has value ${actualValue@Q}"77+ hasDiff=178+ actualKeyIdx+=179+80+ # In the case actualKeyIdx is invalid or expectedKey comes before actualKey, expectedMap has an extra key relative81+ # to actualMap.82+ # NOTE: By virtue of the previous condition being false, we know the negation is true. Namely, expectedKeyIdx is83+ # valid AND (actualKeyIdx is invalid OR expectedKey <= actualKey).84+ elif ((actualKeyIdx >= actualLength)) || [[ $expectedKey < $actualKey ]]; then85+ nixErrorLog "maps differ at key ${expectedKey@Q}: expectedMap has value ${expectedValue@Q} but actualMap has no such key"86+ hasDiff=187+ expectedKeyIdx+=188+89+ # In the case where both key indices are valid and the keys are equal.90+ else91+ if [[ $expectedValue != "$actualValue" ]]; then92+ nixErrorLog "maps differ at key ${expectedKey@Q}: expectedMap has value ${expectedValue@Q} but actualMap has value ${actualValue@Q}"93+ hasDiff=194+ fi95+96+ expectedKeyIdx+=197+ actualKeyIdx+=198+ fi99+ done100+101+ ((hasDiff)) && exit 1 || return 0102+}