Pārlūkot izejas kodu

feat: granular progress for all stages — tar extraction per-file, meson install per-line (ninjaStyleProgress), resolver per-package, grouped download bar, phase bars (white, no colours); 195 tests green

clanker 1 nedēļu atpakaļ
vecāks
revīzija
18d0af501b

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1 - 745
installer/install-usm.sh


+ 79 - 5
src/cli/Install.vala

@@ -64,14 +64,21 @@ private int install_main(string[] args) {
         // Resolve the full closure first: local → system packages → USM
         // repositories/cache, with candidate-group selection
         var spm = new Usm.SystemPackageManager(state.config);
+        progress.begin_phase("Resolving dependencies");
+        resolver.resolution_progress = (fraction) => {
+            progress.update_phase(fraction);
+        };
         Usm.ResolutionResult resolution;
         try {
             resolution = resolver.resolve(roots, spm);
         }
         catch(Usm.ResolverError e) {
+            progress.end_phase();
             printerr("%s\n".printf(e.message));
             return 240;
         }
+        // The bar erases here; the transaction plan is the next display
+        progress.end_phase();
 
         // Build the transaction set WITHOUT downloading: repository
         // packages join as their (possibly not-yet-existing) cache
@@ -143,7 +150,7 @@ private int install_main(string[] args) {
         }
 
         // Only now that the plan is confirmed do the packages transfer
-        install_download_packages(state, resolution, new HashSet<string>());
+        install_download_packages(state, resolution, new HashSet<string>(), progress);
 
         // Chosen system packages install first, as one transaction
         if(resolution.system_packages.any()) {
@@ -178,8 +185,29 @@ private int install_main(string[] args) {
  * dependencies already installed at the resolved version); a cached
  * package.usmc is never re-transferred. Called only after the plan is
  * confirmed, so a decline downloads nothing.
+ *
+ * With a {@link progress} bar every transfer shares one grouped
+ * display: overall progress is `(completed downloads + current
+ * download fraction) / total downloads`, the client's byte-level
+ * callback feeding the current fraction, and each finished package
+ * logs a `✓ Downloaded <name> (<size>)` line above the bar. Without
+ * one, each transfer prints its own byte-level line (the
+ * pre-grouping behaviour, kept for callers without a bar).
  */
-private void install_download_packages(Usm.SystemState state, Usm.ResolutionResult resolution, HashSet<string> skip_names) throws Error {
+private void install_download_packages(Usm.SystemState state, Usm.ResolutionResult resolution, HashSet<string> skip_names, Usm.Cli.ProgressBar? progress = null) throws Error {
+    uint total_downloads = 0;
+    if(progress != null) {
+        foreach(var package in resolution.install_order) {
+            if(install_needs_download(state, package, skip_names)) {
+                total_downloads++;
+            }
+        }
+        if(total_downloads > 0) {
+            progress.begin_phase("Downloading packages");
+        }
+    }
+
+    uint completed_downloads = 0;
     foreach(var package in resolution.install_order) {
         if(package.repository == null || skip_names.contains(package.manifest.name)) {
             continue;
@@ -194,10 +222,56 @@ private void install_download_packages(Usm.SystemState state, Usm.ResolutionResu
             cache_dir.make_directory();
         }
         var package_path = Path.build_filename(path, "package.usmc");
-        client.download_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Downloading $f $c/$t bytes\r"));
-        client.verify_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Verifying $f $c/$t bytes\r"));
-        printerr("\n");
+        if(progress == null) {
+            client.download_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Downloading $f $c/$t bytes\r"));
+            client.verify_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Verifying $f $c/$t bytes\r"));
+            printerr("\n");
+            continue;
+        }
+        client.download_package(package_path, package.repository_entry, (f, c, t) => {
+            var fraction = t > 0 ? (float)((double)c / (double)t) : 0.0f;
+            progress.update_phase(((float)completed_downloads + fraction) / (float)total_downloads);
+        });
+        client.verify_package(package_path, package.repository_entry);
+        completed_downloads++;
+        var size = File.new_for_path(package_path).query_info(FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE).get_size();
+        progress.phase_log(@"Downloaded $(Path.get_basename(package.repository_entry.path)) ($(install_format_size(size)))");
+    }
+
+    if(progress != null && total_downloads > 0) {
+        progress.end_phase();
+    }
+}
+
+/**
+ * Whether {@link package} still needs its .usmc transferred: a
+ * repository-backed package neither skipped by {@link skip_names} nor
+ * already sitting in its cache directory.
+ */
+private bool install_needs_download(Usm.SystemState state, Usm.AbstractPackage package, HashSet<string> skip_names) {
+    if(package.repository == null || skip_names.contains(package.manifest.name)) {
+        return false;
+    }
+    var path = state.generate_cache_path(package.manifest);
+    return !File.new_for_path(Path.build_filename(path, "package.usmc")).query_exists();
+}
+
+/**
+ * Human-readable byte count for download log lines — `388 B`,
+ * `1.2 MiB` — climbing binary multiples so the figure matches what
+ * ls/du report for the same file.
+ */
+private string install_format_size(int64 bytes) {
+    var size = (double)bytes;
+    var unit = "B";
+    foreach(var next in new string[] { "KiB", "MiB", "GiB", "TiB" }) {
+        if(size < 1024.0) {
+            break;
+        }
+        size /= 1024.0;
+        unit = next;
     }
+    return unit == "B" ? @"$bytes B" : "%.1f %s".printf(size, unit);
 }
 
 /**

+ 150 - 11
src/cli/ProgressBar.vala

@@ -36,6 +36,15 @@ namespace Usm.Cli {
      * — one per significant change, no ANSI — with the ASCII fallback
      * characters in non-Unicode locales.
      *
+     * Besides the transaction display, plain phase bars cover the
+     * stages around it — strategising, package downloads. A phase bar
+     * uses the same chunky cells and right-aligned percentage but no
+     * colours and no `[X/Y]` counter: {@link begin_phase} draws
+     * `Resolving dependencies ███░░░░░ 42%`, {@link update_phase}
+     * redraws it (monotonic within the phase), {@link phase_log}
+     * prints a completion line above the live bar and {@link end_phase}
+     * erases it for whatever displays next.
+     *
      * Connect {@link on_transaction_progress} to
      * {@link Usm.Transaction.progress_updated}:
      *
@@ -98,6 +107,12 @@ namespace Usm.Cli {
         private string plain_key = "";
         private int plain_decile = -1;
 
+        private string? phase_label = null;
+        private float phase_progress = 0.0f;
+        private bool phase_live = false;
+        private string phase_plain_key = "";
+        private int phase_plain_decile = -1;
+
         construct {
             // Charset probing needs the locale applied before it reflects
             // the environment; without this the C locale forces the ASCII
@@ -225,6 +240,85 @@ namespace Usm.Cli {
             }
         }
 
+        /**
+         * Starts a plain single-line bar for a non-transaction phase —
+         * strategising, package downloads — drawn without colours and
+         * without the `[X/Y]` action counter:
+         * `Resolving dependencies ███░░░░░ 0%`. A live transaction line
+         * is never touched; phases run before (or between) transaction
+         * displays.
+         */
+        public void begin_phase(string label) {
+            phase_label = label;
+            phase_progress = 0.0f;
+            phase_plain_key = "";
+            phase_plain_decile = -1;
+            if(interactive) {
+                draw_phase_line();
+            }
+        }
+
+        /**
+         * Redraws the phase bar at {@link progress} (clamped to 0..1).
+         * The value is monotonic within a phase — a report below the
+         * current fill never redraws the bar emptier — so callers may
+         * feed raw byte or count fractions without policing ordering
+         * themselves. Ignored when no phase is active. Non-interactive
+         * streams print one line per decile change, mirroring
+         * {@link update}'s plain mode.
+         */
+        public void update_phase(float progress) {
+            if(phase_label == null) {
+                return;
+            }
+            var clamped = progress < 0.0f ? 0.0f : progress > 1.0f ? 1.0f : progress;
+            phase_progress = clamped > phase_progress ? clamped : phase_progress;
+            if(interactive) {
+                draw_phase_line();
+            }
+            else {
+                print_phase_plain();
+            }
+        }
+
+        /**
+         * Erases the phase bar without leaving a log line — the space
+         * is handed to whatever displays next (the transaction plan,
+         * the transaction bar). Harmless when no phase is active.
+         */
+        public void end_phase() {
+            if(interactive && phase_live) {
+                stderr.printf("\r\033[K");
+            }
+            phase_live = false;
+            phase_label = null;
+            phase_progress = 0.0f;
+            phase_plain_key = "";
+            phase_plain_decile = -1;
+        }
+
+        /**
+         * Prints one completion line above the live phase bar —
+         * `✓ Downloaded invercargill-1.0.0.usmc (388 B)` — consuming
+         * the in-place line and redrawing the bar beneath it, so
+         * per-item logs scroll up like transaction action logs. Plain
+         * (no colours) like the phase bar itself.
+         */
+        public void phase_log(string message) {
+            if(interactive) {
+                if(phase_live) {
+                    stderr.printf("\r\033[K");
+                }
+                stderr.printf("%s %s\n", success_mark, message);
+                if(phase_label != null) {
+                    draw_phase_line();
+                }
+            }
+            else {
+                stderr.printf("%s %s\n", success_mark, message);
+            }
+        }
+
         /**
          * The persistent log for the current action, in the past tense;
          * printing it consumes the transient line, so the log scrolls
@@ -247,36 +341,69 @@ namespace Usm.Cli {
             line_live = true;
         }
 
+        private void draw_phase_line() {
+            stderr.printf("\r\033[K%s", phase_line_text());
+            phase_live = true;
+        }
+
         /**
          * One full terminal row: the action text space-padded to the
-         * left third, then the bar zone filling the right two thirds —
-         * gap, cells, gap and the overall percentage right-aligned in
-         * a `100%`-wide field. The width is re-queried on every call
-         * so a resized terminal re-proportions the layout immediately.
+         * left third, then the bar zone filling the right two thirds.
+         * The width is re-queried on every call so a resized terminal
+         * re-proportions the layout immediately.
          */
         private string line_text() {
             var width = detect_terminal_width();
             var text_zone = int.max(14, width / 3);
-            var bar_zone = width - text_zone;
 
             var cells = new StringBuilder();
             cells.append(zone_text(text_zone));
+            pad_to_zone(cells, text_zone);
+            cells.append(bar_text(width - text_zone, overall));
+            return cells.str;
+        }
+
+        /**
+         * The phase row: the phase label alone — no counter, no
+         * colours — ellipsised into the same left-third text zone,
+         * then the same bar zone at {@link phase_progress}.
+         */
+        private string phase_line_text() {
+            var width = detect_terminal_width();
+            var text_zone = int.max(14, width / 3);
+
+            var cells = new StringBuilder();
+            cells.append(ellipsise(phase_label ?? "", text_zone));
+            pad_to_zone(cells, text_zone);
+            cells.append(bar_text(width - text_zone, phase_progress));
+            return cells.str;
+        }
+
+        /** Space-pads {@link cells} out to exactly {@link text_zone} visible characters. */
+        private void pad_to_zone(StringBuilder cells, int text_zone) {
             var padding = text_zone - cells.str.char_count();
             for(var i = 0; i < padding; i++) {
                 cells.append(" ");
             }
+        }
 
-            // The percentage field is fixed at "100%" width, so the
-            // cell count stays constant from 0% to 100%
-            var percent = (int)(overall * 100);
+        /**
+         * The bar zone proper — gap, `█`/`░` cells (`[####    ]` in the
+         * ASCII fallback), gap and the fraction's percentage
+         * right-aligned in a `100%`-wide field. The fixed percentage
+         * field keeps the cell count constant from 0% to 100%, and the
+         * fill uses + 0.5 rounding in plain arithmetic because the CLI
+         * target does not link libm.
+         */
+        private string bar_text(int bar_zone, float fraction) {
+            var percent = (int)(fraction * 100);
             var capacity = bar_zone - 6 - (unicode ? 0 : 2);
-            // + 0.5 rounding in plain arithmetic: the CLI target does not
-            // link libm, so Math.roundf is unavailable here
-            var filled = (int)(overall * capacity + 0.5f);
+            var filled = (int)(fraction * capacity + 0.5f);
             if(filled > capacity) {
                 filled = capacity;
             }
 
+            var cells = new StringBuilder();
             cells.append(" ");
             if(!unicode) {
                 cells.append("[");
@@ -323,6 +450,18 @@ namespace Usm.Cli {
             stderr.printf("%s\n", line_text());
         }
 
+        /** Plain-mode stream for {@link update_phase}: one line per phase or decile change. */
+        private void print_phase_plain() {
+            var key = phase_label ?? "";
+            var decile = (int)(phase_progress * 10);
+            if(key == phase_plain_key && decile == phase_plain_decile) {
+                return;
+            }
+            phase_plain_key = key;
+            phase_plain_decile = decile;
+            stderr.printf("%s\n", phase_line_text());
+        }
+
         /**
          * Truncates {@link label} to {@link allowed} visible characters
          * with an ellipsis, char-aligned so a multi-byte name is never

+ 55 - 5
src/lib/Manifest.vala

@@ -382,7 +382,19 @@ namespace Usm {
             return proc;
         }
 
-        public Subprocess? run_install(string build_path, string install_path, Paths paths, InstallType type, SubprocessFlags flags) throws Error {
+        /**
+         * Spawns the manifest's install executable with
+         * {@link InstallType} appended to the build and install paths.
+         *
+         * When the manifest carries {@link ManifestFlag.NINJA_STYLE_PROGRESS}
+         * and a {@link progress_delegate} is supplied (and USM_VERBOSE is
+         * unset), stdout is piped and each meson-style `Installing X to Y`
+         * line reports `lines_parsed / estimated_total` — the estimate is
+         * the manifest's provides count doubled, since meson commonly
+         * installs several outputs per resource. Otherwise the flags pass
+         * through {@link verbose_flags} unchanged (silenced by default).
+         */
+        public Subprocess? run_install(string build_path, string install_path, Paths paths, InstallType type, SubprocessFlags flags, ProgressDelegate? progress_delegate = null) throws Error {
             if(executables.install == null) {
                 return null;
             }
@@ -396,15 +408,53 @@ namespace Usm {
             // Override destination environment variable
             var new_paths = paths.clone();
             new_paths.destination = install_path;
-            
+
             // Change to the working directory for subprocess execution
             Environment.set_current_dir(working_dir);
             new_paths.set_envs();
-            var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(build_path), Paths.ensure_trailing_slash(install_path), type.to_string() }, verbose_flags(flags));
-            
+
+            var parse_install_progress = progress_delegate != null
+                && this.flags != null
+                && this.flags.contains(ManifestFlag.NINJA_STYLE_PROGRESS)
+                && !verbose_requested();
+
+            var spawn_flags = flags;
+            if(parse_install_progress) {
+                if ((spawn_flags & SubprocessFlags.STDOUT_SILENCE) != 0) {
+                    spawn_flags = spawn_flags & ~SubprocessFlags.STDOUT_SILENCE;
+                }
+                spawn_flags = spawn_flags | SubprocessFlags.STDOUT_PIPE;
+            }
+            else {
+                spawn_flags = verbose_flags(flags);
+            }
+
+            var proc = new Subprocess.newv(new string[] { path, Paths.ensure_trailing_slash(build_path), Paths.ensure_trailing_slash(install_path), type.to_string() }, spawn_flags);
+
+            if(parse_install_progress) {
+                // Drain the pipe before returning: meson holds stdout
+                // open until it exits, so EOF aligns with the caller's
+                // wait_check
+                uint estimated_total = provides.count() * 2;
+                if(estimated_total < 1) {
+                    estimated_total = 1;
+                }
+                uint lines_parsed = 0;
+                var output = new DataInputStream(proc.get_stdout_pipe());
+                string line;
+                while((line = output.read_line(null)) != null) {
+                    if(!line.has_prefix("Installing ")) {
+                        continue;
+                    }
+                    lines_parsed++;
+                    var fraction = (float)lines_parsed / (float)estimated_total;
+                    progress_delegate(fraction > 1.0f ? 1.0f : fraction);
+                }
+            }
+
             // Restore original working directory after subprocess completes
             Environment.set_current_dir(original_working_dir);
-            
+
             return proc;
         }
 

+ 35 - 0
src/lib/Resolver.vala

@@ -126,6 +126,18 @@ namespace Usm {
         private Dictionary<string, bool> local_presence = new Dictionary<string, bool>();
         private bool spm_configured = false;
 
+        /**
+         * Optional progress callback fired once per resolved package as
+         * `resolved_count / estimated_total`, where the estimate is the
+         * number of roots times two (a rough dependency multiplier) and
+         * the final report is clamped to 1.0 when resolution completes.
+         * Null (the default) disables reporting.
+         */
+        public owned ProgressDelegate? resolution_progress { get; set; }
+
+        /** Estimated package count for {@link resolution_progress}: roots x 2, at least 1. */
+        private uint resolution_estimated_total = 1;
+
         /** The lifecycle phases resolution considers, in processing order. */
         private const string[] PHASES = { "manage", "build", "runtime" };
 
@@ -223,6 +235,10 @@ namespace Usm {
             spm_index = new Dictionary<string, Vector<SystemPackageCandidate>>();
             local_presence = new Dictionary<string, bool>();
             spm_configured = spm != null && spm.enabled;
+            resolution_estimated_total = roots.length * 2;
+            if(resolution_estimated_total < 1) {
+                resolution_estimated_total = 1;
+            }
 
             var ordered_roots = roots.sort(compare_packages).to_vector();
             foreach(var root in ordered_roots) {
@@ -252,6 +268,10 @@ namespace Usm {
             var packages = new PackageSet();
             packages.union_with(state.chosen);
 
+            if(resolution_progress != null) {
+                resolution_progress(1.0f);
+            }
+
             return new ResolutionResult() {
                 packages = packages,
                 system_packages = system_packages,
@@ -570,6 +590,7 @@ namespace Usm {
                 return;
             }
             state.processed.add(package);
+            report_resolution_progress();
 
             var manifest = package.manifest;
             for(int phase_index = 0; phase_index < PHASES.length; phase_index++) {
@@ -602,6 +623,20 @@ namespace Usm {
             }
         }
 
+        /**
+         * Fires {@link resolution_progress} for the packages processed
+         * so far, clamped to 1.0; the estimate deliberately
+         * over-counts shallow closures so the bar never sits full
+         * before resolution is done.
+         */
+        private void report_resolution_progress() {
+            if(resolution_progress == null) {
+                return;
+            }
+            var fraction = (float)state.processed.count() / (float)resolution_estimated_total;
+            resolution_progress(fraction > 1.0f ? 1.0f : fraction);
+        }
+
         private static DependencyPhase manifest_phase(Manifest manifest, string phase_name) {
             switch(phase_name) {
                 case "manage":

+ 9 - 2
src/lib/State/CachedPackage.vala

@@ -131,13 +131,20 @@ namespace Usm {
             }
         }
 
-        public string get_source_directory() throws Error {
+        /**
+         * Extracts the cached .usmc into the package directory,
+         * forwarding extraction progress (see
+         * {@link Util.unarchive_with_progress}) to {@link progress}
+         * when supplied. A directory that already exists is returned
+         * as-is without extracting.
+         */
+        public string get_source_directory(Usm.ProgressDelegate? progress = null) throws Error {
             var path = source_directory_path();
             if(File.new_for_path(path).query_exists()) {
                 return path;
             }
 
-            Util.unarchive(package_path, path);
+            Util.unarchive_with_progress(package_path, path, progress);
             return path;
         }
 

+ 13 - 2
src/lib/Transaction.vala

@@ -515,7 +515,9 @@ namespace Usm {
             // to exist (see {@link cleanup_package})
             package.clean_source();
             report_progress(current_task_type, 0.5f);
-            package.get_source_directory();
+            package.get_source_directory((fraction) => {
+                report_progress(current_task_type, 0.5f + (fraction * 0.5f));
+            });
             report_progress(current_task_type, 1.0f);
         }
 
@@ -659,7 +661,16 @@ namespace Usm {
             // Run install process if present
             if(manifest.executables.install != null) {
                 install_dir = package.create_install_directory();
-                var install_proc = manifest.run_install(build_dir, install_dir, paths, install_type, SubprocessFlags.STDOUT_SILENCE);
+                // Only a ninjaStyleProgress install script streams
+                // parseable Installing lines; everything else stays
+                // silenced (see {@link Manifest.run_install})
+                ProgressDelegate? install_progress = null;
+                if(manifest.flags != null && manifest.flags.has(ManifestFlag.NINJA_STYLE_PROGRESS)) {
+                    install_progress = (fraction) => {
+                        report_progress(current_task_type, fraction);
+                    };
+                }
+                var install_proc = manifest.run_install(build_dir, install_dir, paths, install_type, SubprocessFlags.STDOUT_SILENCE, install_progress);
                 install_proc.wait_check();
             }
 

+ 55 - 0
src/lib/Util.vala

@@ -42,6 +42,61 @@ namespace Usm.Util {
         }
     }
 
+    /**
+     * Extracts `archive` (tar.xz) into `destination`, reporting
+     * extraction progress by counting tar's verbose stdout lines. The
+     * member total comes from a `tar -tf <archive> | wc -l` pass first;
+     * the extraction pass (`tar -xvf`) then emits one line per member,
+     * so the callback receives `lines_read / total_members` as a 0..1
+     * fraction. A null {@link progress} delegates to
+     * {@link unarchive} unchanged.
+     */
+    public static void unarchive_with_progress(string archive, string destination, Usm.ProgressDelegate? progress) throws Error {
+        if(progress == null) {
+            unarchive(archive, destination);
+            return;
+        }
+
+        var dest = File.new_for_path(destination);
+        if(!dest.query_exists()) {
+            dest.make_directory();
+        }
+
+        var counter = new Subprocess.newv(
+            new string[] { "bash", "-c", @"tar -tf '$archive' | wc -l" },
+            SubprocessFlags.STDOUT_PIPE);
+        uint64 total_members = 0;
+        var counter_output = new DataInputStream(counter.get_stdout_pipe());
+        var count_line = counter_output.read_line(null);
+        if(count_line != null) {
+            total_members = uint64.parse(count_line.strip());
+        }
+        counter.wait_check();
+
+        var proc = new Subprocess.newv(
+            new string[] { "tar", "-xvf", archive, "-C", destination },
+            SubprocessFlags.STDOUT_PIPE);
+
+        var output = new DataInputStream(proc.get_stdout_pipe());
+        uint64 extracted = 0;
+        string line;
+        while((line = output.read_line(null)) != null) {
+            extracted++;
+            if(total_members > 0) {
+                var fraction = (float)((double)extracted / (double)total_members);
+                progress(fraction > 1.0f ? 1.0f : fraction);
+            }
+        }
+        if(!proc.wait_check()) {
+            throw new IOError.FAILED(@"Could not extract \"$archive\": tar returned non-zero exit status $(proc.get_status())");
+        }
+        // An archive whose listing came back empty never reported per
+        // member; completion is all the caller can be told
+        if(total_members == 0) {
+            progress(1.0f);
+        }
+    }
+
     public static void archive(string source, string archive) throws Error {
         var proc = new Subprocess.newv(new string[] { "tar", "-cJf", archive, "--directory", source, "." }, SubprocessFlags.INHERIT_FDS);
         if(!proc.wait_check()) {

+ 160 - 0
src/tests/TestMain.vala

@@ -1537,6 +1537,155 @@ exit 0
         Usm.Util.delete_tree(scratch);
     }
 
+    // ---- Granular progress reporting ----------------------------------------------
+
+    /** Whether the reports climb monotonically from start to end. */
+    bool monotonic(float[] fractions) {
+        for(int i = 1; i < fractions.length; i++) {
+            if(fractions[i] < fractions[i - 1]) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    void test_unarchive_with_progress() throws Error {
+        var scratch = make_scratch();
+        var source = Path.build_filename(scratch, "src");
+        DirUtils.create(source, 0755);
+        for(int i = 0; i < 5; i++) {
+            FileUtils.set_contents(Path.build_filename(source, @"file$i.txt"), @"content $i");
+        }
+        var archive = Path.build_filename(scratch, "pkg.usmc");
+        Usm.Util.archive(source, archive);
+
+        float[] fractions = {};
+        var destination = Path.build_filename(scratch, "out");
+        Usm.Util.unarchive_with_progress(archive, destination, f => { fractions += f; });
+        check(fractions.length == 6, "one progress report fires per extracted archive member (including the root)");
+        check(fractions[0] > 0.0f, "the first member already reports progress");
+        check(fractions[fractions.length - 1] == 1.0f, "the last member reports full progress");
+        check(monotonic(fractions), "extraction progress never regresses");
+        check(File.new_for_path(Path.build_filename(destination, "file3.txt")).query_exists(), "the archive extracts fully");
+
+        var bare = Path.build_filename(scratch, "out-bare");
+        Usm.Util.unarchive_with_progress(archive, bare, null);
+        check(File.new_for_path(Path.build_filename(bare, "file0.txt")).query_exists(), "a null delegate extracts without progress reporting");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_resolver_resolution_progress() throws Error {
+        var scratch = make_scratch();
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        resolver.supply_package(make_package_archive(scratch, "pa", {}));
+        resolver.supply_package(make_package_archive(scratch, "pb", { "bin:pkg-pa" }));
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        roots.add(((!)resolver.find_package("pkg-pb")));
+
+        float[] fractions = {};
+        resolver.resolution_progress = f => { fractions += f; };
+        var result = resolver.resolve(roots, null);
+
+        check(result.packages.count() == 2, "resolution still closes the closure with reporting enabled");
+        check(fractions.length == 3, "one report fires per resolved package plus a completion clamp");
+        check(fractions[0] == 0.5f, "the first package reports against the roots-x-2 estimate");
+        check(fractions[fractions.length - 1] == 1.0f, "completion clamps to full progress");
+        check(monotonic(fractions), "resolution progress never regresses");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_install_script_progress_parsing() throws Error {
+        var scratch = make_scratch();
+        var original_dir = Environment.get_current_dir();
+        var source = Path.build_filename(scratch, "src");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"), """
+        {
+          "name": "meson-pkg",
+          "version": "1.0.0",
+          "summary": "install progress fixture",
+          "licences": [],
+          "flags": ["ninjaStyleProgress"],
+          "provides": { "bin:meson-pkg": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": { "install": "install.sh" }
+        }
+        """);
+        FileUtils.set_contents(Path.build_filename(source, "install.sh"), """#!/bin/bash
+echo "Installing meson-pkg to /usr/bin"
+echo "noise that is not an install line"
+echo "Installing libmeson-pkg.so to /usr/lib"
+""");
+        FileUtils.chmod(Path.build_filename(source, "install.sh"), 0755);
+
+        Environment.set_current_dir(source);
+        var manifest = new Usm.Manifest.from_file("MANIFEST.usm");
+        float[] fractions = {};
+        var install_dir = Path.build_filename(scratch, "install");
+        DirUtils.create(install_dir, 0755);
+        var proc = manifest.run_install(Path.build_filename(scratch, "build"), install_dir, new Usm.Paths(),
+            Usm.InstallType.FRESH, SubprocessFlags.STDOUT_SILENCE, f => { fractions += f; });
+        check(proc != null, "a ninjaStyleProgress install script spawns through the parsing path");
+        ((!)proc).wait_check();
+
+        check(fractions.length == 2, "each Installing line reports exactly once and noise is skipped");
+        check(fractions[0] == 0.5f, "reports run against the provides-count-x-2 estimate");
+        check(fractions[1] == 1.0f, "the last Installing line reaches full progress");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_unpack_progress() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+
+        var cache_path = Path.build_filename(scratch, "state", "packages", "unpack-pkg-1.0.0");
+        DirUtils.create_with_parents(cache_path, 0755);
+        var source = Path.build_filename(scratch, "src-unpack-pkg");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"), BUILD_MANIFEST.printf("unpack-pkg", "1.0.0"));
+        FileUtils.set_contents(Path.build_filename(source, "build.sh"), NOOP_BUILD_SCRIPT);
+        FileUtils.chmod(Path.build_filename(source, "build.sh"), 0755);
+        for(int i = 0; i < 4; i++) {
+            FileUtils.set_contents(Path.build_filename(source, @"payload$i.txt"), "data");
+        }
+        Usm.Util.archive(source, Path.build_filename(cache_path, "package.usmc"));
+        Usm.Util.delete_tree(source);
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        to_install.add(new Usm.CachedPackage(cache_path));
+
+        var transaction = new Usm.Transaction() {
+            paths = scratch_paths(scratch),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state
+        };
+
+        float[] unpack_fractions = {};
+        transaction.progress_updated.connect((task_type, subject, current_task, total_tasks, task_progress) => {
+            if(task_type == Usm.TransactionTask.UNPACKING) {
+                unpack_fractions += task_progress;
+            }
+        });
+        transaction.run();
+
+        check(unpack_fractions.length >= 6, "unpacking reports the clean, every extracted member and completion");
+        check(unpack_fractions[0] == 0.0f, "the task opens at zero before the source clean");
+        check(unpack_fractions[1] == 0.5f, "the source clean lands at the half-way report");
+        check(unpack_fractions[unpack_fractions.length - 1] == 1.0f, "unpacking completes at full progress");
+        check(monotonic(unpack_fractions), "unpack progress never regresses");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
     int main() {
         test_default_ignore();
         try {
@@ -1656,6 +1805,17 @@ exit 0
             print("FAIL transaction rollback test threw: %s\n", e.message);
         }
 
+        try {
+            test_unarchive_with_progress();
+            test_resolver_resolution_progress();
+            test_install_script_progress_parsing();
+            test_transaction_unpack_progress();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL progress reporting test threw: %s\n", e.message);
+        }
+
         print("%d passed, %d failed\n", passes, failures);
         return failures == 0 ? 0 : 1;
     }

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels