tests/test_ssh_capture.cs
using System.Diagnostics;
using HumanoidMocapX.Core.Motion;
using HumanoidMocapX;
using HumanoidMocapX.EditorTools;

static class SshCaptureTests
{
    const string Awkward = "Breakin' - Fortnite Battle Royale (Break Dance Emote) - TY_ (720p, h264).mp4";

    // A stand-in ssh: logs its arguments, then runs the remote command here with sh, as sshd runs it
    // with the login shell. FAKE_SSH_FAIL makes it fail the way an unreachable machine does.
    const string FakeSsh = """
        #!/bin/sh
        printf '%s\n' "$@" >> "$FAKE_ROOT/ssh.log"
        [ -n "$FAKE_SSH_FAIL" ] && { echo "ssh: connect to host gpu port 22: Connection refused" >&2; exit 255; }
        for last; do :; done
        exec sh -c "$last"
        """;

    // A stand-in worker: its usage without arguments; otherwise it records its arguments and the
    // uploaded video, then fails, runs until stopped, or reports progress and writes the motion.
    const string FakeWorker = """
        #!/bin/sh
        [ $# -eq 0 ] && { echo "usage:" >&2; echo "  humanoid-mocap-worker capture <video> <output.hmotion>" >&2; exit 2; }
        printf '%s\n' "$@" > "$FAKE_ROOT/arguments.txt"
        cp "$2" "$FAKE_ROOT/uploaded"
        case $FAKE_WORKER in
        fail) echo "GPU: Fake" >&2; echo "error: System.InvalidOperationException: no GPU" >&2; echo "   at Worker.Run()" >&2; exit 1 ;;
        hang) trap 'echo stopped > "$FAKE_ROOT/stopped"; exit 130' TERM
              echo $$ > "$FAKE_ROOT/worker.pid"; echo '{"message":"Tracking person · 1/150","fraction":0}'
              while :; do sleep 0.1; done ;;
        esac
        echo 'a line that is not progress'
        echo '{"message":"Tracking person · 1/2","fraction":0.5}'
        cp "$FAKE_ROOT/motion.json" "$3"
        echo '{"message":"Motion is ready","fraction":1}'
        """;

    internal static async Task Run()
    {
        Parsing();
        // The fake ssh, worker and shells are Linux-only.
        if (!OperatingSystem.IsLinux()) { Console.WriteLine("PASS: SSH argument quoting and progress parsing (process checks run on Linux)"); return; }
        Quoting();
        var root = Path.Combine(Path.GetTempPath(), "mocap-ssh-" + Guid.NewGuid().ToString("N"));
        var bin = Path.Combine(root, "bin"); Directory.CreateDirectory(bin);
        var path = Environment.GetEnvironmentVariable("PATH");
        try
        {
            Script(Path.Combine(bin, "ssh"), FakeSsh); Script(Path.Combine(bin, "ffmpeg"), "#!/bin/sh"); Script(Path.Combine(bin, "ffprobe"), "#!/bin/sh");
            var worker = Path.Combine(root, "worker's dir", "humanoid-mocap-worker");
            Directory.CreateDirectory(Path.GetDirectoryName(worker)!); Script(worker, FakeWorker);
            Environment.SetEnvironmentVariable("FAKE_ROOT", root);
            var settings = new SshSettings { Enabled = true, Address = "me@gpu", Worker = worker };
            var seen = new List<(string Message, double? Fraction)>();
            var cache = Path.Combine(root, "captures");
            var capture = new SshCapture(settings, new CaptureOptions { Model = FlowModel.Base }, cache, (message, fraction) => seen.Add((message, fraction)));

            Environment.SetEnvironmentVariable("PATH", root);
            MotionTests.Equal(SshCapture.ClientMissing, capture.UnavailableReason);
            Environment.SetEnvironmentVariable("PATH", bin + Path.PathSeparator + path);
            MotionTests.Check(capture.UnavailableReason is null, "An address and an ssh client make the machine available");
            MotionTests.Check(new SshCapture(new SshSettings(), new CaptureOptions(), cache).UnavailableReason!.Contains("address"), "An unset address is explained");

            await SshCapture.CheckAsync(settings, default);
            var missing = await Throws<InvalidOperationException>(() => SshCapture.CheckAsync(new SshSettings { Address = "me@gpu", Worker = Path.Combine(root, "absent") }, default));
            MotionTests.Check(missing.Message.Contains("did not start"), "A missing worker fails the check: " + missing.Message);

            var video = Path.Combine(root, Awkward); var bytes = new byte[] { 1, 2, 3, 4, 5 };
            await File.WriteAllBytesAsync(video, bytes);
            var fixture = MotionTests.Fixture().ToDocument();
            await File.WriteAllTextAsync(Path.Combine(root, "motion.json"), fixture.ToJson());
            var messages = new List<string>();
            var output = await capture.CaptureAsync(video, 1.5, 3, messages.Add, default);
            var motion = MotionDocument.Parse(await File.ReadAllBytesAsync(output));
            MotionTests.Equal(cache, Path.GetDirectoryName(output));
            MotionTests.Equal(fixture.Frames.Count, motion.Frames.Count);
            MotionTests.Equal(video, motion.SourceVideo); MotionTests.Equal(Path.GetFileNameWithoutExtension(Awkward), motion.Name);
            var uploaded = await File.ReadAllBytesAsync(Path.Combine(root, "uploaded"));
            MotionTests.Check(bytes.SequenceEqual(uploaded), "The video arrives unchanged");
            var arguments = await File.ReadAllLinesAsync(Path.Combine(root, "arguments.txt"));
            var job = arguments[^3];
            MotionTests.Check(job.StartsWith(SshCapture.JobRoot, StringComparison.Ordinal) && !Directory.Exists(job), "The job directory is removed: " + job);
            MotionTests.Equal($"capture {job}/video.mp4 {job}/capture.hmotion --start 1.5 --model base --camera-interval 10 --cache {job} --end 3",
                string.Join(" ", arguments));
            MotionTests.Check(seen.Contains(("Tracking person · 1/2", 0.5)) && seen.Contains(("Motion is ready", 1))
                && !seen.Any(line => line.Message.Contains("not progress")), "Progress lines are forwarded: " + string.Join(" | ", seen));
            MotionTests.Check(messages.Contains("Uploading video to me@gpu…") && messages.Contains("Tracking person · 1/2"), "Progress reaches the capture callback");
            MotionTests.Check(!(await File.ReadAllTextAsync(Path.Combine(root, "ssh.log"))).Contains("Breakin"), "The user's file name never reaches the machine");

            Environment.SetEnvironmentVariable("FAKE_WORKER", "fail");
            var failure = await Throws<InvalidOperationException>(() => capture.CaptureAsync(video, 0, null, null, default));
            MotionTests.Check(failure.Message.Contains("error: System.InvalidOperationException: no GPU") && !failure.Message.Contains("Worker.Run"),
                "The worker's error, without its stack, fails the capture: " + failure.Message);
            MotionTests.Check(!Directory.Exists((await File.ReadAllLinesAsync(Path.Combine(root, "arguments.txt")))[^1]), "A failed job is removed");

            Environment.SetEnvironmentVariable("FAKE_WORKER", "hang");
            using (var cancellation = new CancellationTokenSource())
            {
                var running = capture.CaptureAsync(video, 0, null, null, cancellation.Token);
                var pid = Path.Combine(root, "worker.pid");
                for (var wait = 0; !File.Exists(pid) && wait < 200; wait++) await Task.Delay(50);
                var clock = Stopwatch.StartNew();
                cancellation.Cancel();
                await Throws<OperationCanceledException>(() => running);
                MotionTests.Check(File.Exists(Path.Combine(root, "stopped")), "Cancelling sends the remote worker TERM");
                MotionTests.Check(!Directory.Exists("/proc/" + (await File.ReadAllTextAsync(pid)).Trim()), "The remote worker has exited");
                MotionTests.Check(clock.Elapsed < TimeSpan.FromSeconds(10), "The worker stops through the session, before any kill fallback");
                MotionTests.Check(!Directory.Exists((await File.ReadAllLinesAsync(Path.Combine(root, "arguments.txt")))[^1]), "A cancelled job is removed");
            }

            Environment.SetEnvironmentVariable("FAKE_SSH_FAIL", "1");
            var unreachable = await Throws<InvalidOperationException>(() => SshCapture.CheckAsync(settings, default));
            MotionTests.Check(unreachable.Message.Contains("Connection refused"), "ssh's own error explains an unreachable machine: " + unreachable.Message);
        }
        finally
        {
            Environment.SetEnvironmentVariable("PATH", path);
            foreach (var name in new[] { "FAKE_ROOT", "FAKE_WORKER", "FAKE_SSH_FAIL" }) Environment.SetEnvironmentVariable(name, null);
            Directory.Delete(root, true);
        }
        Console.WriteLine("PASS: SSH capture quoting, upload, worker arguments, progress, download, failure output, cancellation and cleanup");
    }

    static void Parsing()
    {
        MotionTests.Equal(("Tracking person · 1/2", (double?)0.5), SshCapture.Progress("""{"message":"Tracking person · 1/2","fraction":0.5}"""));
        MotionTests.Equal(("Verifying…", (double?)null), SshCapture.Progress("""{"message":"Verifying…","fraction":null}"""));
        foreach (var other in new[] { "GPU: NVIDIA", "", "[1]", """{"fraction":1}""", """{"message":3}""", "{" })
            MotionTests.Check(SshCapture.Progress(other) is null, "Not a progress line: " + other);
        MotionTests.Equal("GPU: Fake\nerror: X: no GPU\nFile name: 'a'",
            SshCapture.Summary("GPU: Fake\nerror: X: no GPU\nFile name: 'a'\n   at A.B()\n--- End of stack trace from previous location ---\n   at C.D()\n"));
        MotionTests.Equal("capture /j/video.mp4 /j/capture.hmotion --start 1.5 --model base --camera-interval 30 --cache /j",
            string.Join(" ", SshCapture.WorkerArguments("/j", "video.mp4", new HostedCaptureOptions(FlowModel.Base, 30, 1.5, null))));
        MotionTests.Equal("video.mp4", SshCapture.VideoName("/home/a/" + Awkward.Replace(".mp4", ".MP4")));
        MotionTests.Equal("video", SshCapture.VideoName("/home/a/clip.we'rd"));
        MotionTests.Equal("./humanoid-mocap-worker/humanoid-mocap-worker", SshCapture.RemoteWorker(SshSettings.DefaultWorker));
        MotionTests.Equal("/opt/worker", SshCapture.RemoteWorker("/opt/worker"));
        var arguments = SshCapture.Arguments("me@gpu", "true");
        MotionTests.Check(arguments.Contains("BatchMode=yes") && arguments.Contains("-T") && arguments[^2] == "me@gpu" && arguments[^1] == "true",
            "ssh never prompts, allocates no terminal and takes one command");
        foreach (var address in new[] { "", "-oProxyCommand=touch x", "me@gpu host", "me@gpu\n" })
            MotionTests.Check(new SshSettings { Address = address }.Problem is not null, "Rejected address: " + address);
        MotionTests.Check(new SshSettings { Address = "[email protected]" }.Problem is null, "A user@host address is accepted");
        MotionTests.Check(new SshSettings { Address = "me@gpu", Worker = " " }.Problem is not null, "An empty worker path is rejected");
    }

    // The remote command line must reach the script unchanged whatever the login shell is.
    static void Quoting()
    {
        var values = new[] { Awkward, "a\\b \\\\ 'c' \"d\" $HOME `id` ;|&*? ~ \t", "" };
        var expected = string.Concat(values.Select(value => value + "|"));
        foreach (var shell in new[] { "/bin/sh", "/bin/bash", "/usr/bin/fish" }.Where(File.Exists))
        {
            var info = new ProcessStartInfo(shell) { RedirectStandardOutput = true, UseShellExecute = false };
            info.ArgumentList.Add("-c"); info.ArgumentList.Add(SshCapture.Command("printf '%s|' \"$@\"", values));
            using var process = Process.Start(info)!;
            var output = process.StandardOutput.ReadToEnd(); process.WaitForExit();
            MotionTests.Equal(expected, output);
        }
    }

    [System.Runtime.Versioning.SupportedOSPlatform("linux")]
    static void Script(string path, string text)
    {
        File.WriteAllText(path, text.Replace("\r\n", "\n") + "\n");
        File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
    }

    static async Task<T> Throws<T>(Func<Task> action) where T : Exception
    {
        try { await action(); } catch (T error) { return error; }
        throw new Exception("Expected " + typeof(T).Name);
    }
}