feat: harden dashcam transfer tegen flakke SD-lezer + stale-mount cleanup
- dashcam-transfer.py: log echte OSError (shutil.copy2 i.p.v. cp), pre-flight leesbaarheidscheck voor ruimte-wissen, retry (3x) + early-abort (5x), guards op bestandssysteem-calls, WhatsApp GESTOPT bij defecte kaart - dashcam-transfer-mount.sh: skip als service al draait, read-only fsck.exfat health check, %k device-naam doorgeven - dashcam-transfer-cleanup.sh: nieuw - ruimt stale mount op bij udev REMOVE - udev/99-dashcam-trigger.rules: ADD + REMOVE regels met %k - tests: 20 python + 7 mount + 12 cleanup checks - README: architectuur, robuustheid, deploy-instructies
This commit is contained in:
parent
8ef7078ba4
commit
8cc04ea409
9 changed files with 779 additions and 76 deletions
118
tests/test_cleanup_script.sh
Executable file
118
tests/test_cleanup_script.sh
Executable file
|
|
@ -0,0 +1,118 @@
|
|||
#!/bin/bash
|
||||
# Functional test for dashcam-transfer-cleanup.sh using fake tools on PATH.
|
||||
set -u
|
||||
|
||||
SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/dashcam-transfer-cleanup.sh"
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
FAKEBIN="$WORK/bin"
|
||||
mkdir -p "$FAKEBIN"
|
||||
|
||||
LOGGER_FILE="$WORK/logger.log"
|
||||
UMOUNT_MARKER="$WORK/umounted"
|
||||
LAZY_MARKER="$WORK/lazy"
|
||||
STOP_MARKER="$WORK/stopped"
|
||||
|
||||
export LOGGER_FILE UMOUNT_MARKER LAZY_MARKER STOP_MARKER FAKE_SRC FAKE_UMOUNT_FAIL
|
||||
|
||||
cat > "$FAKEBIN/logger" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "logger: $*" >> "$LOGGER_FILE"
|
||||
EOF
|
||||
|
||||
cat > "$FAKEBIN/findmnt" <<'EOF'
|
||||
#!/bin/bash
|
||||
if [ -z "$FAKE_SRC" ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "$FAKE_SRC"
|
||||
EOF
|
||||
|
||||
cat > "$FAKEBIN/systemctl" <<'EOF'
|
||||
#!/bin/bash
|
||||
if [ "$1" = "stop" ]; then
|
||||
touch "$STOP_MARKER"
|
||||
fi
|
||||
EOF
|
||||
|
||||
cat > "$FAKEBIN/umount" <<'EOF'
|
||||
#!/bin/bash
|
||||
if [ "${1:-}" = "-l" ]; then
|
||||
touch "$LAZY_MARKER"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$FAKE_UMOUNT_FAIL" = "1" ]; then
|
||||
exit 1
|
||||
fi
|
||||
touch "$UMOUNT_MARKER"
|
||||
EOF
|
||||
|
||||
chmod +x "$FAKEBIN"/*
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
check() { # $1=desc, $2=condition
|
||||
if eval "$2"; then
|
||||
echo "PASS: $1"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo "FAIL: $1"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
reset() {
|
||||
rm -f "$LOGGER_FILE" "$UMOUNT_MARKER" "$LAZY_MARKER" "$STOP_MARKER"
|
||||
unset FAKE_UMOUNT_FAIL
|
||||
}
|
||||
|
||||
# Test 1: niet gemount → niets te doen
|
||||
reset
|
||||
FAKE_SRC=""
|
||||
PATH="$FAKEBIN:$PATH" "$SCRIPT"
|
||||
check "niet gemount → geen umount" 'test ! -e "$UMOUNT_MARKER"'
|
||||
check "niet gemount → geen service stop" 'test ! -e "$STOP_MARKER"'
|
||||
|
||||
# Test 2: device nog aanwezig → mount blijft
|
||||
reset
|
||||
FAKE_SRC="$WORK/fake-device"
|
||||
touch "$FAKE_SRC"
|
||||
PATH="$FAKEBIN:$PATH" "$SCRIPT"
|
||||
check "device aanwezig → geen umount" 'test ! -e "$UMOUNT_MARKER"'
|
||||
check "device aanwezig → geen service stop" 'test ! -e "$STOP_MARKER"'
|
||||
|
||||
# Test 3: device verdwenen → stop + umount
|
||||
reset
|
||||
FAKE_SRC="$WORK/gone-device"
|
||||
PATH="$FAKEBIN:$PATH" "$SCRIPT"
|
||||
check "device weg → service gestopt" 'test -e "$STOP_MARKER"'
|
||||
check "device weg → umount uitgevoerd" 'test -e "$UMOUNT_MARKER"'
|
||||
check "device weg → geen lazy nodig" 'test ! -e "$LAZY_MARKER"'
|
||||
|
||||
# Test 4: umount mislukt → lazy umount
|
||||
reset
|
||||
FAKE_SRC="$WORK/gone-device"
|
||||
export FAKE_UMOUNT_FAIL=1
|
||||
PATH="$FAKEBIN:$PATH" "$SCRIPT"
|
||||
check "umount faalt → lazy umount" 'test -e "$LAZY_MARKER"'
|
||||
|
||||
# Test 5: node bestaat nog maar source = verwijderd device → stale
|
||||
reset
|
||||
mkdir -p "$WORK/dev"
|
||||
touch "$WORK/dev/sde1"
|
||||
FAKE_SRC="$WORK/dev/sde1"
|
||||
PATH="$FAKEBIN:$PATH" "$SCRIPT" sde1
|
||||
check "source matcht verwijderd device → umount" 'test -e "$UMOUNT_MARKER"'
|
||||
check "source matcht verwijderd device → service gestopt" 'test -e "$STOP_MARKER"'
|
||||
|
||||
# Test 6: ander device verwijdert, sdcard-healthy → niets doen
|
||||
reset
|
||||
touch "$WORK/dev/sdb2"
|
||||
FAKE_SRC="$WORK/dev/sde1"
|
||||
PATH="$FAKEBIN:$PATH" "$SCRIPT" sdb2
|
||||
check "ander device → geen umount" 'test ! -e "$UMOUNT_MARKER"'
|
||||
check "ander device → geen service stop" 'test ! -e "$STOP_MARKER"'
|
||||
|
||||
echo "=== $pass pass, $fail fail ==="
|
||||
[ "$fail" -eq 0 ]
|
||||
251
tests/test_dashcam_transfer.py
Normal file
251
tests/test_dashcam_transfer.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import errno
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
MODULE_PATH = os.path.join(REPO_ROOT, "dashcam-transfer.py")
|
||||
|
||||
spec = importlib.util.spec_from_file_location("dashcam_transfer", MODULE_PATH)
|
||||
dashcam = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(dashcam)
|
||||
sys.modules["dashcam_transfer"] = dashcam
|
||||
|
||||
logging.getLogger().addHandler(logging.NullHandler())
|
||||
|
||||
|
||||
def make_mp4(path, size):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"0" * size)
|
||||
|
||||
|
||||
class GetMp4FilesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_matches_front_and_back_files(self):
|
||||
src = self.tmp.name
|
||||
make_mp4(os.path.join(src, "20260622050346_0000001F.MP4"), 10)
|
||||
make_mp4(os.path.join(src, "20260622050346_0000002B.MP4"), 10)
|
||||
make_mp4(os.path.join(src, "negeer.txt"), 10)
|
||||
result = dashcam.get_mp4_files(src)
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertIn(("20260622050346_0000001F.MP4", "20260622", "F"), result)
|
||||
self.assertIn(("20260622050346_0000002B.MP4", "20260622", "B"), result)
|
||||
|
||||
def test_missing_dir_returns_empty_list(self):
|
||||
self.assertEqual(dashcam.get_mp4_files(os.path.join(self.tmp.name, "nope")), [])
|
||||
|
||||
def test_listdir_error_returns_none(self):
|
||||
with mock.patch.object(dashcam.os, "listdir", side_effect=OSError(errno.EIO, "I/O error")):
|
||||
self.assertIsNone(dashcam.get_mp4_files(self.tmp.name))
|
||||
|
||||
|
||||
class CheckSourceReadableTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.src = self.tmp.name
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_readable_dir_returns_true(self):
|
||||
fname = "20260622050346_0000001F.MP4"
|
||||
make_mp4(os.path.join(self.src, fname), 10)
|
||||
self.assertTrue(dashcam.check_source_readable(self.src, [(fname, "20260622", "F")]))
|
||||
|
||||
def test_io_error_on_read_returns_false(self):
|
||||
fname = "20260622050346_0000001F.MP4"
|
||||
make_mp4(os.path.join(self.src, fname), 10)
|
||||
with mock.patch("builtins.open", side_effect=OSError(errno.EIO, "I/O error")):
|
||||
self.assertFalse(dashcam.check_source_readable(self.src, [(fname, "20260622", "F")]))
|
||||
|
||||
|
||||
class CopyFileTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.src = os.path.join(self.tmp.name, "src.mp4")
|
||||
self.dst = os.path.join(self.tmp.name, "dst.mp4")
|
||||
make_mp4(self.src, 100)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_copies_file(self):
|
||||
dashcam.copy_file(self.src, self.dst, attempts=2, delay=0)
|
||||
self.assertEqual(os.path.getsize(self.dst), 100)
|
||||
|
||||
def test_retries_then_succeeds(self):
|
||||
real_copy2 = dashcam.shutil.copy2
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky(s, d):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3:
|
||||
raise OSError(errno.EIO, "I/O error")
|
||||
real_copy2(s, d)
|
||||
|
||||
with mock.patch.object(dashcam.shutil, "copy2", side_effect=flaky):
|
||||
dashcam.copy_file(self.src, self.dst, attempts=3, delay=0)
|
||||
self.assertEqual(calls["n"], 3)
|
||||
self.assertEqual(os.path.getsize(self.dst), 100)
|
||||
|
||||
def test_raises_after_exhausting_attempts(self):
|
||||
with mock.patch.object(dashcam.shutil, "copy2", side_effect=OSError(errno.EIO, "I/O error")):
|
||||
with self.assertRaises(OSError):
|
||||
dashcam.copy_file(self.src, self.dst, attempts=2, delay=0)
|
||||
|
||||
|
||||
class TransferFilesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.src = os.path.join(self.tmp.name, "src")
|
||||
self.base = os.path.join(self.tmp.name, "base")
|
||||
os.makedirs(self.src)
|
||||
os.makedirs(self.base)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _make(self, fname, size):
|
||||
make_mp4(os.path.join(self.src, fname), size)
|
||||
return (fname, fname[:8], fname[-5])
|
||||
|
||||
def test_transfers_files_to_camera_subdirs(self):
|
||||
files = [
|
||||
self._make("20260622050346_0000001F.MP4", 100),
|
||||
self._make("20260622050346_0000002B.MP4", 200),
|
||||
]
|
||||
ok, errors = dashcam.transfer_files(files, self.src, self.base)
|
||||
self.assertEqual((ok, errors), (2, 0))
|
||||
self.assertFalse(os.path.exists(os.path.join(self.src, files[0][0])))
|
||||
self.assertEqual(
|
||||
os.path.getsize(os.path.join(self.base, "20260622", "front", files[0][0])), 100
|
||||
)
|
||||
self.assertEqual(
|
||||
os.path.getsize(os.path.join(self.base, "20260622", "back", files[1][0])), 200
|
||||
)
|
||||
|
||||
def test_skips_existing_matching_destination(self):
|
||||
files = [self._make("20260622050346_0000001F.MP4", 100)]
|
||||
dst = os.path.join(self.base, "20260622", "front", files[0][0])
|
||||
make_mp4(dst, 100)
|
||||
ok, errors = dashcam.transfer_files(files, self.src, self.base)
|
||||
self.assertEqual((ok, errors), (1, 0))
|
||||
self.assertFalse(os.path.exists(os.path.join(self.src, files[0][0])))
|
||||
self.assertEqual(os.path.getsize(dst), 100)
|
||||
|
||||
def test_overwrites_existing_different_destination(self):
|
||||
files = [self._make("20260622050346_0000001F.MP4", 100)]
|
||||
dst = os.path.join(self.base, "20260622", "front", files[0][0])
|
||||
make_mp4(dst, 10)
|
||||
ok, errors = dashcam.transfer_files(files, self.src, self.base)
|
||||
self.assertEqual((ok, errors), (1, 0))
|
||||
self.assertEqual(os.path.getsize(dst), 100)
|
||||
|
||||
def test_verification_mismatch_counts_as_error_and_keeps_source(self):
|
||||
files = [self._make("20260622050346_0000001F.MP4", 100)]
|
||||
|
||||
def bad_copy2(s, d):
|
||||
with open(d, "wb") as f:
|
||||
f.write(b"0" * 50)
|
||||
|
||||
with mock.patch.object(dashcam.shutil, "copy2", side_effect=bad_copy2), mock.patch.object(
|
||||
dashcam.time, "sleep"
|
||||
):
|
||||
ok, errors = dashcam.transfer_files(files, self.src, self.base)
|
||||
self.assertEqual((ok, errors), (0, 1))
|
||||
self.assertTrue(os.path.exists(os.path.join(self.src, files[0][0])))
|
||||
|
||||
def test_aborts_after_max_consecutive_errors(self):
|
||||
files = [
|
||||
self._make("2026062205034%d_000000%dF.MP4" % (i, i + 1), 10)
|
||||
for i in range(dashcam.MAX_CONSECUTIVE_ERRORS + 3)
|
||||
]
|
||||
with mock.patch.object(
|
||||
dashcam.shutil, "copy2", side_effect=OSError(errno.EIO, "I/O error")
|
||||
), mock.patch.object(dashcam.time, "sleep"):
|
||||
with self.assertRaises(dashcam.TransferAborted) as ctx:
|
||||
dashcam.transfer_files(files, self.src, self.base)
|
||||
self.assertEqual(ctx.exception.consecutive, dashcam.MAX_CONSECUTIVE_ERRORS)
|
||||
self.assertEqual(ctx.exception.ok, 0)
|
||||
for fname, _, _ in files:
|
||||
self.assertTrue(os.path.exists(os.path.join(self.src, fname)))
|
||||
|
||||
|
||||
class EnsureFreeSpaceTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.base = self.tmp.name
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def _statvfs(free_bytes):
|
||||
frsize = 4096
|
||||
return mock.Mock(f_frsize=frsize, f_bavail=free_bytes // frsize)
|
||||
|
||||
def test_no_deletion_when_enough_space(self):
|
||||
with mock.patch.object(dashcam.os, "statvfs", return_value=self._statvfs(10_000_000)):
|
||||
dashcam.ensure_free_space(1000, self.base)
|
||||
|
||||
def test_deletes_oldest_date_dirs_until_enough(self):
|
||||
for name, fname in (("20260301", "a.mp4"), ("20260310", "b.mp4"), ("20260811", "c.mp4")):
|
||||
make_mp4(os.path.join(self.base, name, fname), 5000)
|
||||
with mock.patch.object(dashcam.os, "statvfs", return_value=self._statvfs(4096)):
|
||||
dashcam.ensure_free_space(5000, self.base)
|
||||
self.assertFalse(os.path.exists(os.path.join(self.base, "20260301")))
|
||||
self.assertTrue(os.path.exists(os.path.join(self.base, "20260310")))
|
||||
self.assertTrue(os.path.exists(os.path.join(self.base, "20260811")))
|
||||
|
||||
def test_exits_when_still_insufficient(self):
|
||||
make_mp4(os.path.join(self.base, "20260301", "a.mp4"), 100)
|
||||
with mock.patch.object(dashcam.os, "statvfs", return_value=self._statvfs(0)):
|
||||
with self.assertRaises(SystemExit):
|
||||
dashcam.ensure_free_space(10_000, self.base)
|
||||
|
||||
|
||||
class SendWhatsappTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
patcher = mock.patch.object(dashcam.urllib.request, "urlopen")
|
||||
self.mock_urlopen = patcher.start()
|
||||
self.mock_urlopen.return_value.__enter__.return_value = mock.Mock()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def _body(self):
|
||||
req = self.mock_urlopen.call_args[0][0]
|
||||
return json.loads(req.data)["content"]
|
||||
|
||||
def test_success_message(self):
|
||||
dashcam.send_whatsapp_notification(5, 0, 381_000_000)
|
||||
self.assertIn("5 bestanden", self._body())
|
||||
self.assertIn("geen fouten", self._body())
|
||||
|
||||
def test_errors_message(self):
|
||||
dashcam.send_whatsapp_notification(3, 2, 100_000_000)
|
||||
self.assertIn("2 fout(en)", self._body())
|
||||
|
||||
def test_aborted_message(self):
|
||||
dashcam.send_whatsapp_notification(
|
||||
1, 4, 100_000_000, aborted=True, reason="SD-kaart onleesbaar"
|
||||
)
|
||||
self.assertIn("GESTOPT", self._body())
|
||||
self.assertIn("SD-kaart onleesbaar", self._body())
|
||||
|
||||
def test_api_failure_is_swallowed(self):
|
||||
self.mock_urlopen.side_effect = Exception("boom")
|
||||
dashcam.send_whatsapp_notification(0, 0, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
98
tests/test_mount_script.sh
Executable file
98
tests/test_mount_script.sh
Executable file
|
|
@ -0,0 +1,98 @@
|
|||
#!/bin/bash
|
||||
# Functional test for dashcam-transfer-mount.sh using fake tools on PATH.
|
||||
set -u
|
||||
|
||||
SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/dashcam-transfer-mount.sh"
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
FAKEBIN="$WORK/bin"
|
||||
mkdir -p "$FAKEBIN"
|
||||
|
||||
LOGGER_FILE="$WORK/logger.log"
|
||||
MOUNT_LOG="$WORK/mount.log"
|
||||
FSCK_LOG="$WORK/fsck.log"
|
||||
MOUNT_MARKER="$WORK/mounted"
|
||||
START_MARKER="$WORK/started"
|
||||
|
||||
export LOGGER_FILE MOUNT_LOG FSCK_LOG MOUNT_MARKER START_MARKER
|
||||
|
||||
cat > "$FAKEBIN/logger" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "logger: $*" >> "$LOGGER_FILE"
|
||||
EOF
|
||||
|
||||
cat > "$FAKEBIN/systemctl" <<'EOF'
|
||||
#!/bin/bash
|
||||
if [ "$1" = "is-active" ]; then
|
||||
case "$2" in
|
||||
--quiet) ;;
|
||||
*) echo "$SYSTEMCTL_STATE" ;;
|
||||
esac
|
||||
[ "$SYSTEMCTL_STATE" = "active" ]
|
||||
elif [ "$1" = "start" ]; then
|
||||
touch "$START_MARKER"
|
||||
fi
|
||||
EOF
|
||||
|
||||
cat > "$FAKEBIN/mount" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "mount $*" >> "$MOUNT_LOG"
|
||||
touch "$MOUNT_MARKER"
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat > "$FAKEBIN/umount" <<'EOF'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat > "$FAKEBIN/mountpoint" <<'EOF'
|
||||
#!/bin/bash
|
||||
exit 1
|
||||
EOF
|
||||
|
||||
cat > "$FAKEBIN/fsck.exfat" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "fsck $*" >> "$FSCK_LOG"
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat > "$FAKEBIN/lsblk" <<'EOF'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod +x "$FAKEBIN"/*
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
check() { # $1=desc, $2=condition
|
||||
if eval "$2"; then
|
||||
echo "PASS: $1"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo "FAIL: $1"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 1: service already active → skip, never unmount/mount/start
|
||||
export SYSTEMCTL_STATE=active
|
||||
rm -f "$MOUNT_MARKER" "$START_MARKER" "$LOGGER_FILE" "$MOUNT_LOG" "$FSCK_LOG"
|
||||
PATH="$FAKEBIN:$PATH" DASHCAM_MAX_WAIT=0 "$SCRIPT" sde1
|
||||
check "service actief → exit zonder mount" 'test ! -e "$MOUNT_MARKER"'
|
||||
check "service actief → geen service start" 'test ! -e "$START_MARKER"'
|
||||
check "service actief → skip gelogd" 'grep -q "al bezig" "$LOGGER_FILE"'
|
||||
|
||||
# Test 2: service inactive → mount, fsck, start
|
||||
export SYSTEMCTL_STATE=inactive
|
||||
rm -f "$MOUNT_MARKER" "$START_MARKER" "$LOGGER_FILE" "$MOUNT_LOG" "$FSCK_LOG"
|
||||
PATH="$FAKEBIN:$PATH" DASHCAM_MAX_WAIT=0 "$SCRIPT" sde1
|
||||
check "service inactief → gemount" 'test -e "$MOUNT_MARKER"'
|
||||
check "service inactief → service gestart" 'test -e "$START_MARKER"'
|
||||
check "fsck.exfat uitgevoerd" 'test -s "$FSCK_LOG"'
|
||||
check "mount gelogd" 'grep -q "mounted, starting transfer" "$LOGGER_FILE"'
|
||||
|
||||
echo "=== $pass pass, $fail fail ==="
|
||||
[ "$fail" -eq 0 ]
|
||||
Loading…
Reference in a new issue