Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ The CI treats any **Error**-severity finding as a failure; warnings are logged b

- Framework: **Pester v5** (`#Requires -Modules @{ModuleName='Pester'; ModuleVersion='5.0.0'}` in every test file — a floor, not an exact pin)
- Test files: `Tests/*.Tests.ps1`, one per exported function plus 4 meta files (`Help.Tests.ps1`, `Module.Tests.ps1`, `Pester.Tests.ps1`, `ScriptAnalyzer.Tests.ps1`)
- Tags: `Build` (the 4 meta files), `UnitTests` (everything else — this module has no background jobs/network/filesystem I/O to integration-test)
- Tags: `Build` (the 4 meta files), `UnitTests` (mocked `Write-Progress`, precise parameter-shape assertions), `IntegrationTests` (real `Write-Progress`, no mocking at all)
- `IntegrationTests` (in `Write-xProgress.Tests.ps1` and `Write-xJobProgress.Tests.ps1`) run the module's real functions inside real `Start-Job` background jobs over a real temp file-tree fixture (`New-TestFileTree` in `ModuleUnderTest.ps1`, built under Pester's `$TestDrive`), then assert on the job's own real, unmocked `.Progress` collection of `ProgressRecord` objects. `Write-xJobProgress`'s integration test nests two real jobs (an inner one doing the real traversal, an outer one making the real, unmocked `Write-xJobProgress` calls) to capture its real mirrored output.
- Run all: `Invoke-Pester -Path ./Tests -Output Detailed`
- Run one function's tests: `Invoke-Pester -Path ./Tests/New-xProgress.Tests.ps1 -Output Detailed`
- Run just the fast suite: `Invoke-Pester -Path ./Tests -ExcludeTag IntegrationTests -Output Detailed`
- Each test file independently locates and imports the manifest (`Import-Module ...\xProgress.psd1 -Force`) in its own `BeforeAll`, so files can run standalone or in any order
- `Write-Progress`/`Write-Information` are mocked (`Mock -ModuleName xProgress Write-Progress { }`) where the test needs to assert *what* was passed to them (e.g. a non-null `-Id`); everywhere else tests assert on real return values/state
- `Write-Progress`/`Write-Information` are mocked (`Mock -ModuleName xProgress Write-Progress { }`) in `UnitTests` blocks where the test needs to assert *what* was passed to them (e.g. a non-null `-Id`); `IntegrationTests` blocks never mock `Write-Progress` — everywhere else tests assert on real return values/state
- Module-private/script-scoped state (`$script:ProgressTracker`, `$script:WriteProgressID`) can be inspected directly via `& (Get-Module xProgress) { $script:ProgressTracker }` if a future private helper needs it — not currently used since all functions are public and `Get-xProgress` already exposes instance state
- CI runs the suite on every push via the `test-with-pester` job in `.github/workflows/main.yml`

Expand All @@ -56,6 +58,8 @@ All progress instances live in two module-scoped variables in `xProgress.psm1`:

- `$script:ProgressTracker` — hashtable keyed by GUID string; each value is a `PSCustomObject` representing one progress instance.
- `$script:WriteProgressID` — integer counter starting at 628, auto-incremented to assign unique `Write-Progress -Id` values.
- `$script:JobProgressMap` — used only by `Write-xJobProgress`, entirely separate from `$script:ProgressTracker`. Nested hashtable keyed by `Job.InstanceId.Guid` -> `ChildJob.InstanceId.Guid` -> `ActivityId (int)` -> assigned `Write-Progress -Id` (drawn from the same `$script:WriteProgressID` counter, so job-mirrored bars can't collide with regular xProgress instance IDs).
- `$script:JobProgressRetired` — set (hashtable of `Job.InstanceId.Guid` -> `$true`) of jobs `Write-xJobProgress` has already completed/cleaned up, so leftover `.Progress` records on a finished job are never reprocessed.

### xProgress instance object shape

Expand Down Expand Up @@ -96,6 +100,7 @@ xParentIdentity # GUID of parent xProgress instance (if nested)
| `Start-xProgress` | Manually starts Stopwatch |
| `Suspend-xProgress` | Stops Stopwatch without resetting (to exclude wait time from elapsed) |
| `Resume-xProgress` | Restarts a suspended Stopwatch |
| `Write-xJobProgress` | Mirrors progress from a background job's `ChildJobs[*].Progress` (or the job's own `.Progress` if it has no ChildJobs) into `Write-Progress`, one bar per distinct `ActivityId`, preserving `ParentActivityId` nesting. Lightweight write-only passthrough - does not use `$script:ProgressTracker` |

`Initialize-xProgress` is an alias for `New-xProgress`.

Expand All @@ -106,6 +111,8 @@ Parent/child `Write-Progress` nesting is supported two ways:
- **Manual:** Pass `-Id` / `-ParentId` integers directly.
- **xProgress-managed:** Pass `-xParentIdentity` (alias `xPPID`) with the parent's GUID; the module resolves the integer IDs automatically.

**Fast-follow note:** `Write-xJobProgress`'s mirrored job bars are not yet nestable under a caller's own xProgress instance. A future `-xParentIdentity` parameter on `Write-xJobProgress` is planned - `$script:JobProgressMap` is already independent of `$script:ProgressTracker`, so adding it would only require resolving the parent's `Get-xProgress` `.ID` once and using it as the `ParentId` fallback for a job's top-level activities.

## CI/CD

- **On every push** → `.github/workflows/main.yml` runs PSScriptAnalyzer and the Pester test suite (two jobs) on ubuntu-latest.
Expand All @@ -118,4 +125,4 @@ Parent/child `Write-Progress` nesting is supported two ways:

## WIP

`WIP/JobProgress.ps1` is a stub for a future feature: displaying progress from PowerShell background jobs. It is not exported or functional yet.
The former `WIP/JobProgress.ps1` stub has graduated into `Write-xJobProgress` in `xProgress.psm1` and is exported. See the "Nesting" fast-follow note above for the one deliberately deferred piece (nesting job progress under a caller's own xProgress instance).
62 changes: 53 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
# xProgress Powershell Module

xProgress makes the complexity of using progress bars (including some of the more advanced features like time remaining) in Powershell simple while minimizing the performance impact of Write-Progress for processing of large numbers of actions when iterating through an array.
xProgress makes the complexity of using progress bars in Powershell simple: throttled updates, accurate time-remaining, nested parent/child bars, and, uniquely, progress reported from *inside a background job* — while minimizing the performance impact of calling Write-Progress on every iteration of a large loop.

Write-Progress is expensive to call on every iteration of a large loop and is complex to manage when fully using it's capabilities.
xProgress solves these problems.
Write-Progress is expensive to call on every iteration of a large loop, complex to manage when fully using its capabilities, and in the case of background jobs, invisible to the calling session.

Performance
xProgress throttles Write-Progress calls to configurable intervals (e.g. every 1%, every 10 items) while still calculating accurate percentage complete and estimated time remaining for every item processed.
xProgress solves all three problems.

Complexity
Managing progress bar calculations, parent/child relationships, and timer state is handled automatically by xProgress so you do not need to write custom tracking code for each scenario where progress output is needed.
## Performance

xProgress throttles Write-Progress calls to configurable intervals (e.g. every 1%, every 10 items) while still calculating accurate percentage complete and estimated time remaining for every item processed.

## Complexity

Managing progress bar calculations, parent/child relationships, and timer state is handled automatically by xProgress so you do not need to write custom tracking code for each scenario where progress output is desired.

A hand-rolled equivalent typically means: a counter and a modulus check to throttle calls, a stopwatch plus elapsed/remaining-time math, percent-complete capping so a rounding error never reports 101%, and — the moment a second progress bar nests under the first — a hand-maintained scheme for Id/ParentId bookkeeping. Roughly 40-80 lines of that plumbing per progress bar, copy-pasted and re-adapted at every callsite, versus one `New-xProgress` line plus a `Write-xProgress` call per iteration. It's also easy to get subtly wrong: a percent-complete that could exceed 100, a divide-by-zero on the first call, an auto-assigned Id that silently came out `$null`, an off-by-one in a batch-count message. All bugs you no longer have to worry about.

## Background Jobs

Write-Progress calls inside a `Start-Job` scriptblock only reach that job's own Progress stream — they are invisible in the calling session without tooling like xProgress. As far as we're aware, no other PowerShell module does this for you. Getting it right by hand (tracking each activity separately instead of just the last message received, preserving parent/child nesting reported inside the job, avoiding Id collisions across concurrent jobs, cleaning up once a job finishes) easily runs past 100 lines. This why most scripts and modules either skip job progress entirely or settle for a naive "show whatever came in last" readout. `Write-xJobProgress` reduces all of that to one cmdlet call in your own polling loop.

```Powershell
New-xProgress
Expand All @@ -20,6 +29,7 @@ Complete-xProgress
Start-xProgress
Suspend-xProgress
Resume-xProgress
Write-xJobProgress
```

## Examples
Expand Down Expand Up @@ -142,8 +152,42 @@ foreach ($i in $MyListOfItems)
Complete-xProgress -Identity $xProgressID
```

### Job Progress

`Write-xJobProgress` mirrors `Write-Progress` calls happening *inside* a background job's scriptblock into your own session. It's a lightweight, write-only passthrough — unlike the rest of xProgress, it does not register anything in xProgress's own tracker and has no throttling, Suspend/Resume, or timer semantics. Call it repeatedly from your own polling loop; it never blocks or polls on its own.

```powershell
$job = Start-Job -ScriptBlock {
1..10 | ForEach-Object {
Write-Progress -Activity 'Work' -PercentComplete ($_ * 10)
Start-Sleep -Milliseconds 200
}
}

while ($job.State -eq 'Running')
{
Write-xJobProgress -Job $job
Start-Sleep -Milliseconds 250
}
Write-xJobProgress -Job $job
# the final call shows the last update and completes/clears the progress bar

# Or, for every job in the session:
Get-Job | Write-xJobProgress
```

If the job's scriptblock reports more than one activity (including nested activities via `-ParentId`), `Write-xJobProgress` mirrors each one with a distinct, stable progress bar and preserves the parent/child nesting. Progress `-Id` values are drawn from the same pool xProgress itself uses, so mirrored job bars never collide with your own xProgress instances.

## Releases

1.1.0 New Functionality

- `Write-xJobProgress`: mirrors `Write-Progress` calls happening inside a
background job's scriptblock into the caller's session, one bar per
distinct activity, preserving parent/child nesting reported inside the
job. Lightweight write-only passthrough - no throttling/timer/tracker
integration (see the Job Progress section above).

1.0.1 Bug Fix

- `New-xProgress`: auto-assigned `-Id` (and any child's `ParentID` set via
Expand Down Expand Up @@ -197,5 +241,5 @@ Complete-xProgress -Identity $xProgressID

## Development Plans

- add/extend functions for Job Progress display
- possibly incorporate some gui progress bars like this: https://key2consulting.com/powershell-how-to-display-job-progress/ or https://github.com/Tiberriver256/PoshProgressBar
- `Write-xJobProgress`: add an `-xParentIdentity` parameter to nest mirrored job progress bars under a caller's own xProgress instance
- possibly incorporate new progress bar ideas such as this: https://github.com/Tiberriver256/PoshProgressBar or https://github.com/rsalmei/alive-progress or https://github.com/tqdm/tqdm?tab=readme-ov-file
24 changes: 24 additions & 0 deletions Tests/ModuleUnderTest.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,27 @@ if (-not $manifest)
$projectRoot = $manifest.DirectoryName
$moduleName = $manifest.BaseName
$manifestPath = $manifest.FullName

# Builds a small real directory/file tree under $Root and returns the created file paths.
# $Root must be a plain OS path (e.g. Pester's $TestDrive), not the TestDrive: PSDrive - a
# separate Start-Job process can't resolve the caller's PSDrives.
function New-TestFileTree
{
param(
[Parameter(Mandatory)]
[string]$Root
)

$files = foreach ($sub in 'Alpha', 'Beta', 'Gamma')
{
$dir = Join-Path -Path $Root -ChildPath $sub
New-Item -Path $dir -ItemType Directory -Force | Out-Null
foreach ($i in 1..4)
{
$filePath = Join-Path -Path $dir -ChildPath "file$i.txt"
Set-Content -Path $filePath -Value "$sub-$i-$(Get-Random)"
$filePath
}
}
return $files
}
Loading
Loading