···165165A derivation that runs `shellcheck` on the given script(s).
166166The build will fail if `shellcheck` finds any issues.
167167168168+## `shfmt` {#tester-shfmt}
169169+170170+Run files through `shfmt`, a shell script formatter, failing if any files are reformatted.
171171+172172+:::{.example #ex-shfmt}
173173+# Run `testers.shfmt`
174174+175175+A single script
176176+177177+```nix
178178+testers.shfmt {
179179+ name = "script";
180180+ src = ./script.sh;
181181+}
182182+```
183183+184184+Multiple files
185185+186186+```nix
187187+let
188188+ inherit (lib) fileset;
189189+in
190190+testers.shfmt {
191191+ name = "nixbsd";
192192+ src = fileset.toSource {
193193+ root = ./.;
194194+ fileset = fileset.unions [
195195+ ./lib.sh
196196+ ./nixbsd-activate
197197+ ];
198198+ };
199199+}
200200+```
201201+202202+:::
203203+204204+### Inputs {#tester-shfmt-inputs}
205205+206206+`name` (string)
207207+: The name of the test.
208208+ `name` is required because it massively improves traceability of test failures.
209209+ The name of the derivation produced by the tester is `shfmt-${name}`.
210210+211211+`src` (path-like)
212212+: The path to the shell script(s) to check.
213213+ This can be a single file or a directory containing shell files.
214214+ All files in `src` will be checked, so you may want to provide `fileset`-based source instead of a whole directory.
215215+216216+`indent` (integer, optional)
217217+: The number of spaces to use for indentation.
218218+ Defaults to `2`.
219219+ A value of `0` indents with tabs.
220220+221221+### Return value {#tester-shfmt-return}
222222+223223+A derivation that runs `shfmt` on the given script(s), producing an empty output upon success.
224224+The build will fail if `shfmt` reformats anything.
225225+168226## `testVersion` {#tester-testVersion}
169227170228Checks that the output from running a command contains the specified version string in it as a whole word.
···346404```
347405348406:::
407407+408408+## `testEqualArrayOrMap` {#tester-testEqualArrayOrMap}
409409+410410+Check that bash arrays (including associative arrays, referred to as "maps") are populated correctly.
411411+412412+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.
413413+414414+:::{.example #ex-testEqualArrayOrMap-test-function-add-cowbell}
415415+416416+# Test a function which appends a value to an array
417417+418418+```nix
419419+testers.testEqualArrayOrMap {
420420+ name = "test-function-add-cowbell";
421421+ valuesArray = [
422422+ "cowbell"
423423+ "cowbell"
424424+ ];
425425+ expectedArray = [
426426+ "cowbell"
427427+ "cowbell"
428428+ "cowbell"
429429+ ];
430430+ script = ''
431431+ addCowbell() {
432432+ local -rn arrayNameRef="$1"
433433+ arrayNameRef+=( "cowbell" )
434434+ }
435435+436436+ nixLog "appending all values in valuesArray to actualArray"
437437+ for value in "''${valuesArray[@]}"; do
438438+ actualArray+=( "$value" )
439439+ done
440440+441441+ nixLog "applying addCowbell"
442442+ addCowbell actualArray
443443+ '';
444444+}
445445+```
446446+447447+:::
448448+449449+### Inputs {#tester-testEqualArrayOrMap-inputs}
450450+451451+NOTE: Internally, this tester uses `__structuredAttrs` to handle marshalling between Nix expressions and shell variables.
452452+This imposes the restriction that arrays and "maps" have values which are string-like.
453453+454454+NOTE: At least one of `expectedArray` and `expectedMap` must be provided.
455455+456456+`name` (string)
457457+458458+: The name of the test.
459459+460460+`script` (string)
461461+462462+: The singular task of `script` is to populate `actualArray` or `actualMap` (it may populate both).
463463+ To do this, `script` may access the following shell variables:
464464+465465+ - `valuesArray` (available when `valuesArray` is provided to the tester)
466466+ - `valuesMap` (available when `valuesMap` is provided to the tester)
467467+ - `actualArray` (available when `expectedArray` is provided to the tester)
468468+ - `actualMap` (available when `expectedMap` is provided to the tester)
469469+470470+ While both `expectedArray` and `expectedMap` are in scope during the execution of `script`, they *must not* be accessed or modified from within `script`.
471471+472472+`valuesArray` (array of string-like values, optional)
473473+474474+: An array of string-like values.
475475+ This array may be used within `script`.
476476+477477+`valuesMap` (attribute set of string-like values, optional)
478478+479479+: An attribute set of string-like values.
480480+ This attribute set may be used within `script`.
481481+482482+`expectedArray` (array of string-like values, optional)
483483+484484+: An array of string-like values.
485485+ This array *must not* be accessed or modified from within `script`.
486486+ When provided, `script` is expected to populate `actualArray`.
487487+488488+`expectedMap` (attribute set of string-like values, optional)
489489+490490+: An attribute set of string-like values.
491491+ This attribute set *must not* be accessed or modified from within `script`.
492492+ When provided, `script` is expected to populate `actualMap`.
493493+494494+### Return value {#tester-testEqualArrayOrMap-return}
495495+496496+The tester produces an empty output and only succeeds when `expectedArray` and `expectedMap` match `actualArray` and `actualMap`, respectively, when non-null.
497497+The build log will contain differences encountered.
349498350499## `testEqualDerivation` {#tester-testEqualDerivation}
351500
···11+# shellcheck shell=bash
22+33+# Tests if an array is declared.
44+isDeclaredArray() {
55+ # shellcheck disable=SC2034
66+ local -nr arrayRef="$1" && [[ ${!arrayRef@a} =~ a ]]
77+}
88+99+# Asserts that two arrays are equal, printing out differences if they are not.
1010+# Does not short circuit on the first difference.
1111+assertEqualArray() {
1212+ if (($# != 2)); then
1313+ nixErrorLog "expected two arguments!"
1414+ nixErrorLog "usage: assertEqualArray expectedArrayRef actualArrayRef"
1515+ exit 1
1616+ fi
1717+1818+ local -nr expectedArrayRef="$1"
1919+ local -nr actualArrayRef="$2"
2020+2121+ if ! isDeclaredArray "${!expectedArrayRef}"; then
2222+ nixErrorLog "first arugment expectedArrayRef must be an array reference to a declared array"
2323+ exit 1
2424+ fi
2525+2626+ if ! isDeclaredArray "${!actualArrayRef}"; then
2727+ nixErrorLog "second arugment actualArrayRef must be an array reference to a declared array"
2828+ exit 1
2929+ fi
3030+3131+ local -ir expectedLength=${#expectedArrayRef[@]}
3232+ local -ir actualLength=${#actualArrayRef[@]}
3333+3434+ local -i hasDiff=0
3535+3636+ if ((expectedLength != actualLength)); then
3737+ nixErrorLog "arrays differ in length: expectedArray has length $expectedLength but actualArray has length $actualLength"
3838+ hasDiff=1
3939+ fi
4040+4141+ local -i idx=0
4242+ local expectedValue
4343+ local actualValue
4444+4545+ # We iterate so long as at least one array has indices we've not considered.
4646+ # This means that `idx` is a valid index to *at least one* of the arrays.
4747+ for ((idx = 0; idx < expectedLength || idx < actualLength; idx++)); do
4848+ # Update values for variables which are still in range/valid.
4949+ if ((idx < expectedLength)); then
5050+ expectedValue="${expectedArrayRef[idx]}"
5151+ fi
5252+5353+ if ((idx < actualLength)); then
5454+ actualValue="${actualArrayRef[idx]}"
5555+ fi
5656+5757+ # Handle comparisons.
5858+ if ((idx >= expectedLength)); then
5959+ nixErrorLog "arrays differ at index $idx: expectedArray has no such index but actualArray has value ${actualValue@Q}"
6060+ hasDiff=1
6161+ elif ((idx >= actualLength)); then
6262+ nixErrorLog "arrays differ at index $idx: expectedArray has value ${expectedValue@Q} but actualArray has no such index"
6363+ hasDiff=1
6464+ elif [[ $expectedValue != "$actualValue" ]]; then
6565+ nixErrorLog "arrays differ at index $idx: expectedArray has value ${expectedValue@Q} but actualArray has value ${actualValue@Q}"
6666+ hasDiff=1
6767+ fi
6868+ done
6969+7070+ ((hasDiff)) && exit 1 || return 0
7171+}
···11+# shellcheck shell=bash
22+33+# Tests if a map is declared.
44+isDeclaredMap() {
55+ # shellcheck disable=SC2034
66+ local -nr mapRef="$1" && [[ ${!mapRef@a} =~ A ]]
77+}
88+99+# Asserts that two maps are equal, printing out differences if they are not.
1010+# Does not short circuit on the first difference.
1111+assertEqualMap() {
1212+ if (($# != 2)); then
1313+ nixErrorLog "expected two arguments!"
1414+ nixErrorLog "usage: assertEqualMap expectedMapRef actualMapRef"
1515+ exit 1
1616+ fi
1717+1818+ local -nr expectedMapRef="$1"
1919+ local -nr actualMapRef="$2"
2020+2121+ if ! isDeclaredMap "${!expectedMapRef}"; then
2222+ nixErrorLog "first arugment expectedMapRef must be an associative array reference to a declared associative array"
2323+ exit 1
2424+ fi
2525+2626+ if ! isDeclaredMap "${!actualMapRef}"; then
2727+ nixErrorLog "second arugment actualMapRef must be an associative array reference to a declared associative array"
2828+ exit 1
2929+ fi
3030+3131+ # NOTE:
3232+ # From the `sort` manpage: "The locale specified by the environment affects sort order. Set LC_ALL=C to get the
3333+ # traditional sort order that uses native byte values."
3434+ # We specify the environment variable in a subshell to avoid polluting the caller's environment.
3535+3636+ local -a sortedExpectedKeys
3737+ mapfile -d '' -t sortedExpectedKeys < <(printf '%s\0' "${!expectedMapRef[@]}" | LC_ALL=C sort --stable --zero-terminated)
3838+3939+ local -a sortedActualKeys
4040+ mapfile -d '' -t sortedActualKeys < <(printf '%s\0' "${!actualMapRef[@]}" | LC_ALL=C sort --stable --zero-terminated)
4141+4242+ local -ir expectedLength=${#expectedMapRef[@]}
4343+ local -ir actualLength=${#actualMapRef[@]}
4444+4545+ local -i hasDiff=0
4646+4747+ if ((expectedLength != actualLength)); then
4848+ nixErrorLog "maps differ in length: expectedMap has length $expectedLength but actualMap has length $actualLength"
4949+ hasDiff=1
5050+ fi
5151+5252+ local -i expectedKeyIdx=0
5353+ local expectedKey
5454+ local expectedValue
5555+ local -i actualKeyIdx=0
5656+ local actualKey
5757+ local actualValue
5858+5959+ # We iterate so long as at least one map has keys we've not considered.
6060+ while ((expectedKeyIdx < expectedLength || actualKeyIdx < actualLength)); do
6161+ # Update values for variables which are still in range/valid.
6262+ if ((expectedKeyIdx < expectedLength)); then
6363+ expectedKey="${sortedExpectedKeys["$expectedKeyIdx"]}"
6464+ expectedValue="${expectedMapRef["$expectedKey"]}"
6565+ fi
6666+6767+ if ((actualKeyIdx < actualLength)); then
6868+ actualKey="${sortedActualKeys["$actualKeyIdx"]}"
6969+ actualValue="${actualMapRef["$actualKey"]}"
7070+ fi
7171+7272+ # In the case actualKeyIdx is valid and expectedKey comes after actualKey or expectedKeyIdx is invalid, actualMap
7373+ # has an extra key relative to expectedMap.
7474+ # NOTE: In Bash, && and || have the same precedence, so use the fact they're left-associative to enforce groups.
7575+ if ((actualKeyIdx < actualLength)) && [[ $expectedKey > $actualKey ]] || ((expectedKeyIdx >= expectedLength)); then
7676+ nixErrorLog "maps differ at key ${actualKey@Q}: expectedMap has no such key but actualMap has value ${actualValue@Q}"
7777+ hasDiff=1
7878+ actualKeyIdx+=1
7979+8080+ # In the case actualKeyIdx is invalid or expectedKey comes before actualKey, expectedMap has an extra key relative
8181+ # to actualMap.
8282+ # NOTE: By virtue of the previous condition being false, we know the negation is true. Namely, expectedKeyIdx is
8383+ # valid AND (actualKeyIdx is invalid OR expectedKey <= actualKey).
8484+ elif ((actualKeyIdx >= actualLength)) || [[ $expectedKey < $actualKey ]]; then
8585+ nixErrorLog "maps differ at key ${expectedKey@Q}: expectedMap has value ${expectedValue@Q} but actualMap has no such key"
8686+ hasDiff=1
8787+ expectedKeyIdx+=1
8888+8989+ # In the case where both key indices are valid and the keys are equal.
9090+ else
9191+ if [[ $expectedValue != "$actualValue" ]]; then
9292+ nixErrorLog "maps differ at key ${expectedKey@Q}: expectedMap has value ${expectedValue@Q} but actualMap has value ${actualValue@Q}"
9393+ hasDiff=1
9494+ fi
9595+9696+ expectedKeyIdx+=1
9797+ actualKeyIdx+=1
9898+ fi
9999+ done
100100+101101+ ((hasDiff)) && exit 1 || return 0
102102+}