git/orchard/main
tests/test_live.py
706 lines25 KB last change raw · history
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
"""Driver tests.

The valuable ones run a real child process and observe it through /proc, exactly
as the tool does in production. Nothing here needs a terminal or a network.
"""

from __future__ import annotations

import contextlib
import io
import os
import signal
import stat
import subprocess
import sys
import tempfile
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from rich.console import Console  # noqa: E402

from updater import icons, live  # noqa: E402
from updater.buildgraph import build_graph  # noqa: E402
from updater.live import BuildObserver, LiveRunner  # noqa: E402
from updater.render.build import FeedEntry  # noqa: E402
from updater.srcinfo import SrcInfo  # noqa: E402
from updater.theme import Theme  # noqa: E402

THEME = Theme.default()
GLYPHS = icons.glyphs("unicode")


def info(name: str, *deps: str) -> SrcInfo:
    return SrcInfo(pkgbase=name, pkgver="1", pkgrel="1", depends=list(deps))


def fake_tool(bindir: Path, name: str, body: str) -> Path:
    path = bindir / name
    path.write_text(f"#!/bin/bash\n{body}\n")
    path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
    return path


# --------------------------------------------------------------------------
# parallelism: the bug this exists to prevent
# --------------------------------------------------------------------------


def test_pacman_output_with_accents_does_not_re_read_lines():
    """`cut` indexed decoded characters while `len(chunk)` counted bytes.

    With any multi-byte output in the same chunk the offset landed behind the
    newline, so the next poll re-read consumed content and emitted a
    fabricated line into the feed.
    """
    import tempfile
    from pathlib import Path as P

    log = P(tempfile.mkdtemp()) / "x.log"
    log.write_bytes(":: Über Paket — läuft\n firefox-1.2-1 downloading...\n".encode() + b"part")
    obs = live.RepoObserver.__new__(live.RepoObserver)
    obs.log_path, obs._offset = log, 0
    assert obs._new_lines() == [":: Über Paket — läuft", " firefox-1.2-1 downloading..."]
    with open(log, "ab") as fh:
        fh.write(b"ial\n")
    # the leftover half-line, whole -- not a re-read of what came before
    assert obs._new_lines() == ["partial"]


def test_terminate_reports_whether_the_child_actually_died():
    """`returncode or 0` reported a child that survived SIGKILL as success.

    A process stuck in D state, or on a hung mount, outlives the signal; the
    phase was then called done and the next one started beside a live pacman
    transaction holding the database lock.
    """
    import subprocess

    proc = subprocess.Popen(["true"], process_group=0)
    proc.wait()
    assert live.terminate(proc) is True, "a dead child is gone"

    # one that ignores SIGINT is escalated to SIGTERM, which it accepts
    proc = subprocess.Popen(["sh", "-c", "trap '' INT; sleep 30"], process_group=0)
    try:
        assert live.terminate(proc, grace=0.3, term_grace=2.0) is True
    finally:
        if proc.poll() is None:
            proc.kill()
        proc.wait()


def test_a_phase_that_could_not_be_stopped_is_not_reported_as_success():
    """`returncode or 0` turned a surviving child into a successful phase.

    A process that outlives SIGKILL is rare and not worth faking a kernel for,
    so what is checked is the contract that made it possible to miss: the
    "could not stop it" code exists and is a failure.
    """
    from updater import stream

    assert stream.EXIT_STILL_RUNNING != 0, "a surviving child is a failure"
    assert stream.EXIT_STILL_RUNNING == live.EXIT_STILL_RUNNING, (
        "the two paths disagree about what it means"
    )


def test_a_successful_phase_still_reports_your_zero():
    from updater import stream

    obs = live.BuildObserver(build_graph({}, set()), Path("/tmp"), total=0)
    buf = io.StringIO()
    runner = stream.StreamingRunner(obs, stream.Emitter(buf), poll_hz=20)
    assert runner.run(["true"], "repo", Path(tempfile.mkdtemp()) / "x.log") == 0


def test_cores_are_divided_between_workers_not_multiplied():
    env = live.build_env(workers=8, nproc=16)
    assert env["MAKEFLAGS"] == "-j2", env["MAKEFLAGS"]
    assert env["CARGO_BUILD_JOBS"] == "2"
    assert env["NINJAFLAGS"] == "-j2"


def test_single_worker_gets_the_whole_machine():
    assert live.build_env(workers=1, nproc=16)["MAKEFLAGS"] == "-j16"


def test_an_inherited_makeflags_is_overridden_not_respected():
    """`-j15` inherited from a shell is wrong for any worker count."""
    env = live.build_env(workers=1, nproc=16, base={"MAKEFLAGS": "-j15"})
    assert env["MAKEFLAGS"] == "-j16"
    # and it must never be worse than one job on a machine with cores
    for workers in (1, 2, 3, 16, 64):
        env = live.build_env(workers=workers, nproc=16)
        assert int(env["MAKEFLAGS"][2:]) >= 1


def test_parallelism_label_is_honest():
    assert "8 workers" in live.parallelism_label(8, 16)
    assert "2 jobs" in live.parallelism_label(8, 16)
    assert "workers" not in live.parallelism_label(1, 16)


# --------------------------------------------------------------------------
# observer: feed integrity
# --------------------------------------------------------------------------


def graph_for(*names: str):
    infos = {n: info(n) for n in names}
    return build_graph(infos, set(infos))


def test_a_package_enters_the_feed_exactly_once():
    obs = BuildObserver(graph_for("a"), Path("/nonexistent"))
    obs._watcher.poll = lambda pid: []  # pretend it never ran
    obs._watcher.seen = {"a"}
    obs._watcher.finished = ["a"]
    first = obs.poll(None)
    assert [e.name for e in first] == ["a"]
    assert len(obs.feed) == 1
    # polling again must not duplicate or re-report it
    for _ in range(5):
        assert obs.poll(None) == []
    assert len(obs.feed) == 1
    assert "a" in obs.completed


def test_a_package_that_failed_is_not_also_reported_as_finished():
    obs = BuildObserver(graph_for("a"), Path("/nonexistent"))
    obs.fail("a", "check failed")
    obs._watcher.seen = {"a"}
    obs._watcher.finished = ["a"]
    assert obs.poll(None) == []
    assert [e.state for e in obs.feed] == ["fail"]
    assert obs.failed == {"a"}


def test_seeding_supports_resuming_a_partly_built_set():
    obs = BuildObserver(graph_for("a", "b", "c"), Path("/nonexistent"), total=3)
    obs.seed([FeedEntry("a", "1-1", 5.0), FeedEntry("b", "1-1", 0.0, state="fail")])
    assert obs.completed == {"a"}
    assert obs.failed == {"b"}
    assert len(obs.feed) == 2


def test_failure_blocks_dependents():
    graph = build_graph({"top": info("top", "base"), "base": info("base")}, {"top", "base"})
    obs = BuildObserver(graph, Path("/nonexistent"))
    obs.fail("base")
    assert obs.blocked == {"top"}


def test_totals_compute_an_eta_only_once_something_has_finished():
    obs = BuildObserver(graph_for("a", "b"), Path("/nonexistent"), total=2)
    assert obs.totals().eta is None
    obs.poll(None)
    obs.feed = [FeedEntry("a", "1", 2.0)]
    totals = obs.totals()
    assert totals.total == 2
    assert totals.eta is not None and totals.eta >= 0
    assert totals.elapsed >= 0


def test_download_rate_never_goes_negative_when_part_files_are_renamed():
    """`.part` files vanish on completion, so the raw total can drop."""
    with tempfile.TemporaryDirectory() as td:
        cache = Path(td)
        obs = BuildObserver(graph_for("a"), Path("/nonexistent"), cache_dir=cache)
        part = cache / "pkg-1.pkg.tar.zst.part"

        part.write_bytes(b"x" * 1000)
        obs._last_dl_at -= 1.0
        obs.poll(None)
        assert obs.download_rate > 0

        # the download completes and the file is renamed away
        part.unlink()
        obs._last_dl_at -= 1.0
        obs.poll(None)
        assert obs.download_rate >= 0, "rate went negative after a rename"
        assert obs.download_bytes == 0
        assert obs.download_peak == 1000, "high-water mark was lost"


def test_durations_are_captured_while_the_process_is_alive():
    """Once a build's processes exit there is nothing left to measure."""
    from updater.procwatch import BuildState

    obs = BuildObserver(graph_for("a"), Path("/nonexistent"))
    obs._watcher.poll = lambda pid: [BuildState("a", "cargo", 12.5)]
    obs._watcher.new_finishes = list
    obs.poll(1234)
    assert obs._durations["a"] == 12.5

    # the process goes away; the duration must survive into the feed entry
    obs._watcher.poll = lambda pid: []
    obs._watcher.new_finishes = lambda: ["a"]
    obs.poll(1234)
    assert obs.feed[0].elapsed == 12.5


def test_feed_line_is_a_single_permanent_line():
    done = BuildObserver.feed_line(FeedEntry("pkg", "1.0-1", 41.0))
    assert "\n" not in done and "pkg" in done and "41s" in done
    failed = BuildObserver.feed_line(FeedEntry("pkg", "1.0-1", 3.0, state="fail"))
    assert failed.startswith("x")
    shared = BuildObserver.feed_line(FeedEntry("pkg", "1.0-1", 3.0, shared=True))
    assert "shared" in shared


# --------------------------------------------------------------------------
# the real thing: run a child and observe it through /proc
# --------------------------------------------------------------------------


def test_runner_observes_a_real_child_process_end_to_end():
    with tempfile.TemporaryDirectory() as td:
        root = Path(td)
        clone = root / "clone"
        (clone / "pkgA").mkdir(parents=True)
        bindir = root / "bin"
        bindir.mkdir()
        # faithful to production: we spawn paru, and paru spawns a makepkg child
        # whose working directory is the package's clone dir. The launcher itself
        # is deliberately NOT in the clone dir, so it must not be mistaken for a
        # build.
        tool = fake_tool(bindir, "paru", f'( cd "{clone}/pkgA" && exec -a makepkg sleep 1.2 )')

        graph = graph_for("pkgA")
        obs = BuildObserver(graph, clone, total=1)
        console = Console(file=io.StringIO(), force_terminal=False, width=100)
        runner = LiveRunner(
            obs, theme=THEME, glyphs=GLYPHS, console=console, dashboard=False, workers=1, nproc=4
        )

        result = runner.run([str(tool)], root / "logs" / "build.log")

        assert result.returncode == 0
        assert result.log_path.exists()
        assert [e.name for e in result.feed] == ["pkgA"], result.feed
        assert result.feed[0].elapsed > 0, "no duration captured"
        assert "pkgA" in obs.completed
        assert result.elapsed >= 1.0


def test_the_launcher_itself_is_not_mistaken_for_a_build():
    """paru runs from wherever the user invoked us; that is not a package."""
    with tempfile.TemporaryDirectory() as td:
        root = Path(td)
        clone = root / "clone"
        (clone / "pkgA").mkdir(parents=True)
        obs = BuildObserver(graph_for("pkgA"), clone)
        # a launcher sitting directly in the clone dir must still not be read as
        # a build of that package
        proc = subprocess.Popen(
            [sys.executable, "-c", "import time; time.sleep(0.6)"],
            cwd=clone / "pkgA",
            start_new_session=True,
        )
        try:
            time.sleep(0.2)
            states = obs._watcher.poll(proc.pid)
            assert states == [], f"launcher misread as a build: {states}"
        finally:
            proc.wait(timeout=5)


def test_include_root_supports_driving_a_builder_directly():
    """The future parallel scheduler spawns makepkg itself, so the root is it."""
    from updater.procwatch import descendants, snapshot

    with tempfile.TemporaryDirectory() as td:
        clone = Path(td) / "clone"
        (clone / "pkgA").mkdir(parents=True)
        proc = subprocess.Popen(["sleep", "5"], cwd=clone / "pkgA", start_new_session=True)
        try:
            time.sleep(0.2)
            procs = snapshot()
            assert descendants(proc.pid, procs) == []
            included = descendants(proc.pid, procs, include_root=True)
            assert [p.pid for p in included] == [proc.pid]
        finally:
            proc.kill()
            proc.wait(timeout=5)


def test_final_drain_captures_a_package_still_running_when_the_launcher_exits():
    """Otherwise a build that ends mid-package silently loses it from the feed."""
    obs = BuildObserver(graph_for("pkgA"), Path("/nonexistent"))
    from updater.procwatch import BuildState

    obs._watcher.poll = lambda pid: [BuildState("pkgA", "cargo", 9.0)]
    obs._watcher.new_finishes = list
    obs.poll(4321)  # seen while alive
    assert "pkgA" not in obs.completed

    obs._watcher.poll = lambda pid: []  # the whole tree is gone now
    obs._watcher.new_finishes = lambda: ["pkgA"]
    drained = obs.poll(None)
    assert [e.name for e in drained] == ["pkgA"]
    assert obs.feed[0].elapsed == 9.0


def test_the_watcher_drains_when_the_root_pid_is_gone():
    from updater.procwatch import Watcher

    w = Watcher(Path("/nonexistent"), known={"pkgA"})
    w.seen = {"pkgA"}
    assert w.poll(None) == []
    assert w.new_finishes() == ["pkgA"]


def test_runner_logs_child_output_instead_of_surprising_the_user():
    with tempfile.TemporaryDirectory() as td:
        root = Path(td)
        clone = root / "clone"
        (clone / "pkgA").mkdir(parents=True)
        tool = fake_tool(root, "noisy", "echo 'wall of compiler text'; sleep 0.1")
        obs = BuildObserver(graph_for("pkgA"), clone)
        console = Console(file=io.StringIO(), force_terminal=False, width=100)
        runner = LiveRunner(obs, theme=THEME, glyphs=GLYPHS, console=console, dashboard=False)
        log = root / "b.log"
        runner.run([str(tool)], log)
        assert "wall of compiler text" in log.read_text()


def test_runner_reports_a_failing_child_without_raising():
    with tempfile.TemporaryDirectory() as td:
        root = Path(td)
        clone = root / "clone"
        clone.mkdir()
        tool = fake_tool(root, "boom", "exit 7")
        obs = BuildObserver(graph_for("a"), clone)
        console = Console(file=io.StringIO(), force_terminal=False, width=100)
        runner = LiveRunner(obs, theme=THEME, glyphs=GLYPHS, console=console, dashboard=False)
        result = runner.run([str(tool)], root / "b.log")
        assert result.returncode == 7
        assert not result.ok


def test_flush_feed_prints_each_entry_only_once():
    obs = BuildObserver(graph_for("a"), Path("/nonexistent"))
    console = Console(file=io.StringIO(), force_terminal=False, width=100)
    runner = LiveRunner(obs, theme=THEME, glyphs=GLYPHS, console=console)
    obs.feed = [FeedEntry("a", "1", 1.0)]
    runner._flush_feed()
    runner._flush_feed()
    out = console.file.getvalue()
    assert out.count("a") == 1, f"feed line printed more than once: {out!r}"


# --------------------------------------------------------------------------
# key handling: the runtime toggle
# --------------------------------------------------------------------------


def _runner(dashboard=True):
    obs = BuildObserver(graph_for("a"), Path("/nonexistent"))
    console = Console(file=io.StringIO(), force_terminal=False, width=100)
    return LiveRunner(obs, theme=THEME, glyphs=GLYPHS, console=console, dashboard=dashboard)


def test_v_toggles_the_dashboard_both_ways():
    runner = _runner(dashboard=True)
    runner._handle_keys("v")
    assert runner.dashboard is False
    runner._handle_keys("v")
    assert runner.dashboard is True
    runner._handle_keys(" ")
    assert runner.dashboard is False


def test_q_leaves_the_dashboard_without_killing_the_build():
    """Killing a mid-transaction build is what breaks a package database."""
    runner = _runner(dashboard=True)
    runner._handle_keys("q")
    assert runner.dashboard is False


def test_ctrl_c_raises_so_the_caller_can_unwind_politely():
    runner = _runner()
    try:
        runner._handle_keys("\x03")
    except KeyboardInterrupt:
        pass
    else:
        raise AssertionError("ctrl-c did not raise")


def test_unknown_keys_are_ignored():
    runner = _runner(dashboard=True)
    runner._handle_keys("xyz123")
    assert runner.dashboard is True


# --------------------------------------------------------------------------
# termination: polite before forceful
# --------------------------------------------------------------------------


def test_terminate_escalates_only_as_far_as_needed():
    with tempfile.TemporaryDirectory() as td:
        # a child that handles SIGINT and exits cleanly
        nice = fake_tool(Path(td), "nice", "trap 'exit 0' INT TERM; sleep 30")
        proc = subprocess.Popen([str(nice)], start_new_session=True)
        time.sleep(0.3)
        live.terminate(proc, grace=1.0)
        assert proc.poll() is not None, "polite child was not reaped"


def test_terminate_kills_a_child_that_ignores_everything():
    with tempfile.TemporaryDirectory() as td:
        stubborn = fake_tool(
            Path(td), "stubborn", "trap '' INT TERM; while true; do sleep 0.2; done"
        )
        proc = subprocess.Popen([str(stubborn)], start_new_session=True)
        time.sleep(0.4)
        live.terminate(proc, grace=0.3, term_grace=0.3)
        assert proc.poll() is not None, "stubborn child survived"


def test_terminate_on_an_already_dead_child_is_a_no_op():
    proc = subprocess.Popen(["true"], start_new_session=True)
    proc.wait(timeout=5)
    live.terminate(proc)  # must not raise
    assert proc.poll() == 0


def test_terminate_does_not_signal_an_unrelated_process_group():
    """It must target the child's group, never our own."""
    mine = os.getpgrp()
    proc = subprocess.Popen(["sleep", "30"], start_new_session=True)
    time.sleep(0.2)
    live.terminate(proc, grace=1.0)
    assert os.getpgrp() == mine
    assert proc.poll() is not None


def test_a_phase_keeps_its_controlling_terminal():
    """sudo keys its credential cache to the terminal.

    `start_new_session=True` called setsid(), which detaches the child from the
    controlling terminal. sudo then falls back to parent-PID keying, and the
    parent is the backend rather than the shell the user authenticated in -- so
    every privileged phase failed with "a terminal is required to read the
    password".
    """
    import io

    kwargs = live.spawn_kwargs({"PATH": "/usr/bin"}, io.BytesIO())
    assert "start_new_session" not in kwargs, kwargs
    assert kwargs["process_group"] == 0, kwargs


def test_a_phase_still_cannot_read_the_keyboard():
    """The new process group is what keeps Ctrl+C off pacman; stdin stays shut
    so a build cannot eat keystrokes meant for the interface."""
    import io

    kwargs = live.spawn_kwargs({}, io.BytesIO())
    assert kwargs["stdin"] == subprocess.DEVNULL
    assert kwargs["stderr"] == subprocess.STDOUT
    assert kwargs["process_group"] == 0


def test_can_sudo_reports_a_prompt_as_unavailable():
    """It must ask "would sudo succeed silently", not "is sudo installed"."""
    from updater import live as live_mod

    calls = {}

    class Done:
        returncode = 1

    def fake_run(argv, **kwargs):
        calls["argv"] = argv
        return Done()

    original = live_mod.subprocess.run
    live_mod.subprocess.run = fake_run
    try:
        assert live_mod.can_sudo() is False
    finally:
        live_mod.subprocess.run = original
    assert "-n" in calls["argv"], calls["argv"]


def test_can_sudo_is_false_when_sudo_is_missing():
    from updater import live as live_mod

    def boom(argv, **kwargs):
        raise FileNotFoundError("sudo")

    original = live_mod.subprocess.run
    live_mod.subprocess.run = boom
    try:
        assert live_mod.can_sudo() is False
    finally:
        live_mod.subprocess.run = original


def test_a_phase_is_its_own_process_group_leader():
    """The abort path signals the child's process group.

    It uses killpg(getpgid(pid)), which only reaches the child and everything it
    spawned if the child leads its own group. Moving off setsid must not have
    cost that, or an abort would leave makepkg running.
    """
    import os as _os
    import subprocess as _subprocess
    import time

    with open(_os.devnull, "wb") as log:
        proc = _subprocess.Popen(["sleep", "30"], **live.spawn_kwargs(dict(_os.environ), log))
        try:
            time.sleep(0.2)
            assert _os.getpgid(proc.pid) == proc.pid, "not a group leader"
            assert _os.getsid(proc.pid) == _os.getsid(0), "left the session"
        finally:
            with contextlib.suppress(ProcessLookupError):
                _os.killpg(_os.getpgid(proc.pid), signal.SIGTERM)
            proc.wait(timeout=5)
    assert proc.returncode != 0, "the group was not signalled"


# --------------------------------------------------------------------------
# progress for a pacman transaction
# --------------------------------------------------------------------------

PACMAN_OUT = """\
:: Starting full system upgrade...
Packages (3) git-2.56.0-1  firefox-157.0-1  zed-1.22.0-1

Total Download Size:    474.12 MiB
:: Retrieving packages...
 firefox-157.0-1-x86_64 downloading...
 zed-1.22.0-1-x86_64 downloading...
 git-2.56.0-1-x86_64 downloading...
:: Processing package changes...
upgrading firefox...
upgrading git...
upgrading zed...
:: Running post-transaction hooks...
(1/2) Updating the desktop file MIME type cache...
(2/2) Performing snapper post snapshots...
"""


def _replay(text, names=("git", "firefox", "zed")):
    """Feed the log a growing file at a time, never the whole thing at once."""
    import tempfile

    from updater import live

    directory = Path(tempfile.mkdtemp())
    log = directory / "pacman.log"
    log.write_bytes(b"")
    observer = live.RepoObserver(list(names), log_path=log, cache_dir=directory / "cache")
    data = text.encode()
    for i in range(0, len(data), 40):
        log.write_bytes(data[: i + 40])
        observer.poll()
    return observer


def test_pacman_progress_parses_the_expected_download_size():
    """pacman is one process, so /proc has nothing to attribute.

    Without this the repository phase -- the long, network-bound half of an
    update -- rendered an empty dashboard while hundreds of megabytes moved.
    """
    assert _replay(PACMAN_OUT).expected_bytes == int(474.12 * 2**20)


def test_every_pacman_package_reaches_the_feed():
    observer = _replay(PACMAN_OUT)
    assert set(observer.completed) == {"git", "firefox", "zed"}, observer.completed
    assert len(observer.feed) == 3


def test_pacman_packages_are_reported_in_the_order_they_finish():
    observer = _replay(PACMAN_OUT)
    # the download order this sample came from, which is what pacman reports
    assert [e.name for e in observer.feed] == ["firefox", "zed", "git"], [
        e.name for e in observer.feed
    ]


def test_a_package_outside_the_plan_is_ignored():
    """pacman may touch things the plan did not name."""
    observer = _replay(PACMAN_OUT.replace("upgrading git...", "upgrading elsewhere..."))
    assert "elsewhere" not in observer.completed


def test_pacman_hooks_are_shown_with_their_progress():
    observer = _replay(PACMAN_OUT)
    assert observer.phase == "hooks"
    assert any("hook 2/2" in name for name in observer.states), observer.states


def test_a_half_written_log_line_is_left_alone():
    """The log is being written while it is read."""
    import tempfile

    from updater import live

    directory = Path(tempfile.mkdtemp())
    log = directory / "pacman.log"
    log.write_bytes(b"upgrading fi")  # no newline, no trailing dots
    observer = live.RepoObserver(["firefox"], log_path=log, cache_dir=directory)
    observer.poll()
    assert observer.completed == set(), observer.completed


def test_a_missing_pacman_log_is_not_an_error():
    import tempfile

    from updater import live

    directory = Path(tempfile.mkdtemp())
    observer = live.RepoObserver(["git"], log_path=directory / "nope.log", cache_dir=directory)
    assert observer.poll() == []


def test_an_unreadable_cache_directory_is_not_an_error():
    import tempfile

    from updater import live

    directory = Path(tempfile.mkdtemp())
    observer = live.RepoObserver(
        ["git"], log_path=directory / "x.log", cache_dir=directory / "missing"
    )
    assert observer._cache_bytes() == 0


def test_sizes_accept_the_short_and_long_unit_spellings():
    from updater.live import _to_bytes

    assert _to_bytes("1", "MiB") == _to_bytes("1", "M") == 2**20
    assert _to_bytes("1", "GiB") == 2**30
    assert _to_bytes("nonsense", "MiB") == 0
    assert _to_bytes("5", "??") == 5


def main() -> int:
    tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
    failed = 0
    for name, fn in tests:
        try:
            fn()
        except Exception as exc:  # noqa: BLE001
            failed += 1
            print(f"FAIL  {name}\n        {type(exc).__name__}: {exc}")
        else:
            print(f"ok    {name}")
    print(f"\n{len(tests) - failed}/{len(tests)} passed")
    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())