Files
OrcaSlicer/scripts/test_build_win.ps1
2026-09-09 14:18:21 -03:00

970 lines
58 KiB
PowerShell

<#
.SYNOPSIS
Tests build_win.bat's option handling and the commands it generates.
.DESCRIPTION
Cases run the script with --dry-run, so nothing is configured, built or
deleted and the suite finishes in seconds. Each case asserts on the exit
code and on the command lines the script echoes.
Adding a case means adding one row to $cases. A bare string starts a new
group. Defaults: ExpectExit is 0 and --dry-run is appended, so a row only
states what is unusual about it.
Name what the case proves, in words
Args arguments, as an array
ExpectExit expected exit code (default 0)
DryRun append --dry-run (default $true)
First regex the first output line must match
Env environment for this case only
Contains literal strings the output must have
NotContains literal strings it must not have
Match regexes; each must match at least one output line
NotMatch regexes; none may match any output line
NotExists paths that must not exist after the case runs
DateStampedZip require a bundle date from during this case's invocation
.PARAMETER Name
Run only the cases whose name matches this regex. Headings with no
matching case are not printed, and a pattern that matches nothing is a
failure rather than an empty pass.
.EXAMPLE
powershell -File scripts/test_build_win.ps1
.EXAMPLE
powershell -File scripts/test_build_win.ps1 -Name solution
#>
[CmdletBinding()]
param(
[string] $Script,
[string] $Name
)
$ErrorActionPreference = 'Stop'
if (-not $Script) {
$here = $PSScriptRoot
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
$Script = Join-Path (Split-Path -Parent $here) 'build_win.bat'
}
if (-not (Test-Path $Script)) { throw "build_win.bat not found at $Script" }
# cmd resumes a batch file by byte offset after `call :label`, and with LF
# endings that offset lands wrong and the label lookup fails. .gitattributes
# pins CRLF; this catches a checkout or an editor that did not honour it.
if ((Get-Content -Raw $Script) -match "(?<!`r)`n") {
throw "$Script has LF line endings; cmd needs CRLF to resume after call :label"
}
$Script = (Resolve-Path $Script).Path
# Read the long options out of the script itself, so this cannot go stale when
# an option is added. Field order is: add_arg <var> <type> <short> <long>.
$longFlags = @(
Select-String -Path $Script -Pattern '^call :add_arg \S+ \S+ \S+ (\S+) ' |
ForEach-Object { '--' + $_.Matches[0].Groups[1].Value }
)
if ($longFlags.Count -lt 20) { throw "only found $($longFlags.Count) options in $Script; the parser above is wrong" }
# A winget that always fails, so the prerequisite failure path runs without
# touching the machine. It has to be an .exe: a .bat invoked without `call`
# transfers control and never comes back, which would end the script instead.
# where.exe returns 1 when its patterns match nothing and never prompts.
$fixtures = Join-Path ([IO.Path]::GetTempPath()) 'build_win_test_fixtures'
New-Item -ItemType Directory -Force -Path $fixtures | Out-Null
Copy-Item "$env:SystemRoot\System32\where.exe" (Join-Path $fixtures 'winget.exe') -Force
$stubPath = "$fixtures;C:\Windows\system32;C:\Windows"
# Stand-in ninjas that only report a version, so the 1.12 boundary in the
# progress format can be exercised on a machine whose real ninja is newer.
# A dry run skips the dev shell, so PATH here is what the script sees.
$ninjaPaths = @{}
foreach ($v in @{ old = '1.11.1'; new = '1.12.0' }.GetEnumerator()) {
$d = Join-Path $fixtures "ninja-$($v.Key)"
New-Item -ItemType Directory -Force -Path $d | Out-Null
Set-Content -Path (Join-Path $d 'ninja.bat') -Encoding ascii -Value @('@echo off', "echo $($v.Value)")
$ninjaPaths[$v.Key] = "$d;$env:PATH"
}
# A clang-cl earlier on PATH than the Visual Studio one, which is what the
# compiler used to resolve to. Nothing runs it; the script only locates it.
$clangDir = Join-Path $fixtures 'clang'
New-Item -ItemType Directory -Force -Path $clangDir | Out-Null
Copy-Item "$env:SystemRoot\System32\where.exe" (Join-Path $clangDir 'clang-cl.exe') -Force
$clangOnPath = "$clangDir;$env:PATH"
# A ccache that only has to exist. Nothing runs it; the script only locates it.
$cacheDir = Join-Path $fixtures 'cache'
New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null
Copy-Item "$env:SystemRoot\System32\where.exe" (Join-Path $cacheDir 'ccache.exe') -Force
$ccacheOnPath = "$cacheDir;$env:PATH"
# ProgramFiles(x86) is where the script looks for vswhere, so an empty one
# stands in for a machine whose Visual Studio has no clang toolset.
$noVs = Join-Path $fixtures 'no-vs'
New-Item -ItemType Directory -Force -Path $noVs | Out-Null
# A build directory that already holds a classic solution, for the case where
# what is on disk disagrees with what the generator would write.
$slnDir = Join-Path $fixtures 'sln'
New-Item -ItemType Directory -Force -Path $slnDir | Out-Null
Set-Content -Path (Join-Path $slnDir 'OrcaSlicer.sln') -Value '' -Encoding ascii
$cases = @(
'argument handling'
@{ Name = 'no arguments prints help'; Args = @(); DryRun = $false
Contains = @('Usage: build_win.bat [options]', '--clang-cl') }
@{ Name = "--help lists all $($longFlags.Count) options the script defines"; Args = @('--help'); DryRun = $false
Contains = $longFlags }
@{ Name = 'help is grouped and shows usage, examples and environment'; Args = @('--help'); DryRun = $false
Contains = @('Usage: build_win.bat [options]', 'Actions:', 'Build configuration:', 'Toolchain:',
'How much gets rebuilt:', 'Paths and extra arguments:', 'Diagnostics:',
'Examples:', 'Environment:') }
@{ Name = 'the environment section shows what to set'; Args = @('--help'); DryRun = $false
Contains = @('ORCA_DEPS_CMAKE_ARGS', 'ORCA_SLICER_CMAKE_ARGS', 'ORCA_UPDATER_SIG_KEY', 'NINJA_STATUS',
'set ORCA_SLICER_CMAKE_ARGS=-DSLIC3R_BUILD_SANDBOXES=ON', '(PowerShell)', 'debugscript') }
@{ Name = 'section headers do not widen the flag column'; Args = @('--help'); DryRun = $false
Match = @('^ -d, --deps +Download') }
# Windows Terminal opens at 120 columns and wraps at 120, so 119 is the
# limit. Anyone still on the old conhost gets 80 and will see wrapping.
@{ Name = 'every help line fits a 120 column console'; Args = @('--help'); DryRun = $false
NotMatch = @('^.{120,}$') }
@{ Name = 'an unknown long option is rejected'; Args = @('--nonsense'); ExpectExit = 1
Contains = @('Failed to find arg') }
@{ Name = 'an unknown short option is rejected'; Args = @('-Z'); ExpectExit = 1
Contains = @('Failed to find arg') }
@{ Name = 'a bare argument is rejected'; Args = @('deps'); ExpectExit = 1
Contains = @('Unknown argument') }
@{ Name = 'an unknown architecture is rejected'; Args = @('-d', '--arch', 'sparc'); ExpectExit = 1
Contains = @('Unknown architecture') }
@{ Name = 'a string option without a value is rejected'; Args = @('-d', '--arch'); ExpectExit = 1
Contains = @('requires a value') }
@{ Name = 'short options can be bundled'; Args = @('-dx')
Contains = @('-G "Ninja Multi-Config"', '--target deps') }
'generator and compiler selection'
@{ Name = 'deps default to the Visual Studio generator'; Args = @('-d')
Contains = @('-G "Visual Studio', '-A x64', '--target deps')
NotContains = @('Ninja', 'clang-cl') }
@{ Name = '-x selects Ninja without changing compiler'; Args = @('-d', '-x')
Contains = @('-G "Ninja Multi-Config"')
NotContains = @('clang-cl', '-A x64') }
@{ Name = '-l -x builds with clang-cl under Ninja'; Args = @('-d', '-l', '-x')
Contains = @('-G "Ninja Multi-Config"')
Match = @('-DCMAKE_C_COMPILER="[^"]+/clang-cl\.exe"', '-DCMAKE_CXX_COMPILER="[^"]+/clang-cl\.exe"') }
# PATH order used to decide the compiler. VsDevCmd appends the Visual
# Studio LLVM directory to the end of PATH, so a standalone LLVM already
# there was resolved instead, and an old one failed the compiler check.
@{ Name = 'the compiler is resolved from Visual Studio, not PATH'; Args = @('-s', '-l', '-x')
Env = @{ PATH = $clangOnPath }
Match = @('^Compiler: .*/VC/Tools/Llvm/[^/]+/bin/clang-cl\.exe$') }
@{ Name = 'msvc names no compiler, having resolved none'; Args = @('-s')
NotContains = @('Compiler: ') }
@{ Name = '-l without -x names none either, the toolset picks it'; Args = @('-s', '-l')
NotContains = @('Compiler: ') }
# An empty ProgramFiles(x86) puts vswhere out of reach, which is a machine
# whose Visual Studio has no clang toolset.
@{ Name = 'without a Visual Studio clang the one on PATH is used and named'; Args = @('-s', '-l', '-x')
Env = @{ 'ProgramFiles(x86)' = $noVs; PATH = $clangOnPath }
Contains = @('Visual Studio has no clang-cl')
Match = @('^Compiler: .*/clang/clang-cl\.exe$') }
@{ Name = 'no clang-cl anywhere stops before configuring'; Args = @('-s', '-l', '-x'); ExpectExit = 1
Env = @{ 'ProgramFiles(x86)' = $noVs; PATH = 'C:\Windows\system32;C:\Windows' }
Contains = @('No clang-cl found', '--install-vs ide -l')
NotContains = @('cmake -B') }
# Only a configure passes the compiler to CMake, so an action that does
# not configure resolves none, and cannot start needing one installed.
@{ Name = 'packing resolves no compiler'; Args = @('-p', '-l', '-x')
Contains = @('Packing the dependencies')
NotContains = @('Compiler: ') }
@{ Name = '--no-configure resolves none either'; Args = @('-s', '-l', '-x', '--no-configure')
Contains = @('cmake --build "build-clang"')
NotContains = @('Compiler: ') }
@{ Name = '-l alone uses the ClangCL toolset on the VS generator'; Args = @('-d', '-l')
Contains = @('-G "Visual Studio', '-T ClangCL')
NotContains = @('-DCMAKE_C_COMPILER') }
# --msvc and --msbuild name the defaults, so a caller can be explicit and a
# contradictory pair can be caught rather than silently resolved.
@{ Name = '--msvc is the compiler default spelled out'; Args = @('-d', '--msvc')
Contains = @('-G "Visual Studio', '-A x64')
NotContains = @('clang-cl') }
@{ Name = '--msbuild is the generator default spelled out'; Args = @('-d', '--msbuild')
Contains = @('-G "Visual Studio')
NotContains = @('Ninja') }
@{ Name = '--msvc with -x gives Ninja driving cl'; Args = @('-d', '--msvc', '-x')
Contains = @('-G "Ninja Multi-Config"')
NotContains = @('clang-cl') }
@{ Name = '--msbuild with -l gives the VS generator and the ClangCL toolset'; Args = @('-d', '--msbuild', '-l')
Contains = @('-G "Visual Studio', '-T ClangCL') }
# A developer with a standalone LLVM points at it, and the path is passed
# with forward slashes so CMake cannot read a backslash as an escape.
@{ Name = '--clang-path names the compiler, quoted for its spaces'; Args = @('-d', '-x', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe')
Contains = @('-DCMAKE_C_COMPILER="C:/Program Files/LLVM/bin/clang-cl.exe"',
'-DCMAKE_CXX_COMPILER="C:/Program Files/LLVM/bin/clang-cl.exe"') }
@{ Name = '--clang-path beats the Visual Studio clang'; Args = @('-s', '-x', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe')
Contains = @('Compiler: C:/Program Files/LLVM/bin/clang-cl.exe') }
@{ Name = '--clang-path is a clang request on its own'; Args = @('-d', '-x', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe')
Contains = @('deps/build-clang') }
@{ Name = '--clang-path needs Ninja to take effect'; Args = @('-d', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe'); ExpectExit = 1
Contains = @('needs the Ninja generator') }
# `exist` is true for a directory too, and a directory would reach CMake
# as the compiler.
@{ Name = '--clang-path must name the exe, not its folder'; Args = @('-d', '-x', '--clang-path', 'C:\Program Files\LLVM'); ExpectExit = 1
Contains = @('is a directory') }
@{ Name = 'a clang-cl that is not there is caught early'; Args = @('-d', '-x', '--clang-path', 'C:\nope\clang-cl.exe'); ExpectExit = 1
Contains = @('No clang-cl at')
NotContains = @('cmake -S deps') }
@{ Name = '--clang-path contradicting --msvc is rejected'; Args = @('-d', '-x', '--msvc', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe'); ExpectExit = 1
Contains = @('select different compilers') }
@{ Name = '--clang-cl and --msvc together are rejected'; Args = @('-d', '-l', '--msvc'); ExpectExit = 1
Contains = @('select different compilers') }
@{ Name = '--ninja and --msbuild together are rejected'; Args = @('-d', '-x', '--msbuild'); ExpectExit = 1
Contains = @('select different generators') }
# One option with a value, driven by a table, rather than a flag per
# release. Adding a release should not need a new flag.
@{ Name = '--vs 2019 pins that release and skips autodetect'; Args = @('-d', '--vs', '2019')
Contains = @('-G "Visual Studio 16 2019"')
NotContains = @('Detecting Visual Studio') }
@{ Name = '--vs 2022 pins that release'; Args = @('-d', '--vs', '2022')
Contains = @('-G "Visual Studio 17 2022"') }
@{ Name = '--vs 2026 pins that release'; Args = @('-d', '--vs', '2026')
Contains = @('-G "Visual Studio 18 2026"') }
@{ Name = 'an unknown release is rejected and the known ones listed'; Args = @('-d', '--vs', '2015'); ExpectExit = 1
Contains = @('Unknown Visual Studio release', '2019, 2022, 2026') }
@{ Name = '--vs and --ninja together are rejected'; Args = @('-d', '--vs', '2022', '-x'); ExpectExit = 1
Contains = @('select different generators') }
@{ Name = 'without --vs the release is autodetected'; Args = @('-d')
Contains = @('Detecting Visual Studio') }
'architecture'
@{ Name = 'x64 is the default'; Args = @('-d')
Contains = @('Configuration: Release, x64')
NotContains = @('build-arm64') }
@{ Name = 'arm64 sets the generator platform and deps tree'; Args = @('-d', '--arch', 'arm64')
Contains = @('-A ARM64', 'deps/build-arm64') }
@{ Name = 'the architecture is matched case-insensitively'; Args = @('-d', '--arch', 'ARM64')
Contains = @('-A ARM64', 'deps/build-arm64') }
@{ Name = 'arm64 under Ninja has no -A but keeps the arm64 tree'; Args = @('-d', '--arch', 'arm64', '-x', '-l')
Contains = @('deps/build-clang-arm64')
Match = @('-DCMAKE_C_COMPILER="[^"]+/clang-cl\.exe"')
NotContains = @('-A ') }
'build configurations'
# One option with a value, driven by a table, so Release is named rather
# than being whatever is left when no flag is passed.
@{ Name = 'release is the default'; Args = @('-s')
Contains = @('-DCMAKE_BUILD_TYPE=Release', 'cmake -B "build" ') }
@{ Name = '--config release is the default spelled out'; Args = @('-s', '--config', 'release')
Contains = @('-DCMAKE_BUILD_TYPE=Release', 'cmake -B "build" ') }
@{ Name = '--config debug builds into build-dbg'; Args = @('-s', '--config', 'debug')
Contains = @('-DCMAKE_BUILD_TYPE=Debug', 'cmake -B "build-dbg" ') }
@{ Name = '--config relwithdebinfo builds into build-dbginfo'; Args = @('-s', '--config', 'relwithdebinfo')
Contains = @('-DCMAKE_BUILD_TYPE=RelWithDebInfo', 'cmake -B "build-dbginfo" ') }
@{ Name = '--config minsizerel builds into build-minsize'; Args = @('-s', '--config', 'minsizerel')
Contains = @('-DCMAKE_BUILD_TYPE=MinSizeRel', 'cmake -B "build-minsize" ') }
# Batch variable names are case-insensitive, so the table lookup is too.
@{ Name = 'the configuration name is matched case-insensitively'; Args = @('-s', '--config', 'RelWithDebInfo')
Contains = @('-DCMAKE_BUILD_TYPE=RelWithDebInfo') }
@{ Name = 'an unknown configuration is rejected and the known ones listed'; Args = @('-s', '--config', 'bogus'); ExpectExit = 1
Contains = @('Unknown configuration', 'release, debug, relwithdebinfo, minsizerel') }
# Release, RelWithDebInfo and MinSizeRel all link the /MD dependencies, so
# one tree serves all three. Debug is /MDd and cannot share.
@{ Name = 'minsizerel builds against the release deps'; Args = @('-d', '-s', '--config', 'minsizerel')
Contains = @('cmake -S deps -B "deps/build"', 'cmake -B "build-minsize" ')
NotContains = @('deps/build-minsize') }
@{ Name = 'relwithdebinfo builds against them too'; Args = @('-d', '-s', '-l', '--config', 'relwithdebinfo')
Contains = @('cmake -S deps -B "deps/build-clang"', 'cmake -B "build-dbginfo-clang" ')
NotContains = @('deps/build-dbginfo') }
@{ Name = 'debug keeps a dependency tree of its own'; Args = @('-d', '--config', 'debug')
Contains = @('cmake -S deps -B "deps/build-dbg"') }
# The build tree and the deps tree now have different names, so the
# derivation from the binary directory name cannot work and it is named.
@{ Name = 'a shared deps tree is named outright'; Args = @('-s', '-l', '--config', 'relwithdebinfo')
Match = @('-DDEP_BUILD_DIR="[A-Za-z]:\\.*\\deps\\build-clang"') }
@{ Name = 'configuration and arch combine into build-dbg-arm64'; Args = @('-s', '--config', 'debug', '--arch', 'arm64')
Contains = @('cmake -B "build-dbg-arm64" ') }
'what gets built'
@{ Name = 'the deps target defaults to deps'; Args = @('-d')
Contains = @('--target deps') }
@{ Name = '-t overrides the deps target'; Args = @('-d', '-t', 'dep_Boost')
Contains = @('--target dep_Boost') }
@{ Name = 'unit tests are off unless asked for'; Args = @('-s')
Contains = @('-DBUILD_TESTS=OFF') }
@{ Name = '--tests turns the unit tests on'; Args = @('-s', '--tests')
Contains = @('-DBUILD_TESTS=ON') }
@{ Name = '-a enables ASAN for the slicer'; Args = @('-s', '-a')
Contains = @('-DSLIC3R_ASAN=ON') }
@{ Name = '--no-pch turns the precompiled header off'; Args = @('-s', '--no-pch')
Contains = @('-DSLIC3R_PCH=OFF') }
@{ Name = '--no-pch says so in the banner'; Args = @('-s', '--no-pch')
Contains = @('Precompiled header: off') }
@{ Name = 'the precompiled header is on unless asked'; Args = @('-s')
NotContains = @('SLIC3R_PCH') }
@{ Name = '--no-pch works without a cache'; Args = @('-s', '--no-pch')
NotContains = @('COMPILER_LAUNCHER') }
@{ Name = 'the slicer build runs gettext'; Args = @('-s')
Contains = @('run_gettext.bat') }
# tools\7z.exe needs a 7z.dll beside it, which the repo does not carry,
# so the pack falls back to the bsdtar Windows ships. Either is correct;
# what matters is that one is chosen and handed the right names.
@{ Name = '-p packs the deps tree with whichever archiver is usable'; Args = @('-d', '-p')
Match = @('^\+ .*(7z\.exe a|tar\.exe -a -c -f) OrcaSlicer_dep_win-\S+\.zip OrcaSlicer_dep$') }
# The bundle is only good for what built it, so it carries the same three
# axes as the tree. Release x64 on cl keeps the historical plain name.
@{ Name = 'the bundle name carries compiler and deps flavour'; Args = @('-p', '-l', '--config', 'debug', '--arch', 'arm64')
Contains = @('OrcaSlicer_dep_win-ARM64-clang-dbg_') }
@{ Name = 'a relwithdebinfo pack is the release bundle'; Args = @('-p', '--config', 'relwithdebinfo')
Contains = @('OrcaSlicer_dep_win-x64_')
NotContains = @('-dbg', 'dbginfo') }
@{ Name = 'a plain release bundle keeps its old name'; Args = @('-p')
Contains = @('OrcaSlicer_dep_win-x64_')
NotContains = @('-clang', '-Release') }
@{ Name = 'the bundle is stamped with today, not a shuffled date'; Args = @('-p')
DateStampedZip = $true }
# powershell.exe is not in System32 itself, so a trimmed PATH used to
# leave the stamp empty and the bundle named OrcaSlicer_dep_win-x64_.zip.
@{ Name = 'the bundle is stamped even with a bare PATH'; Args = @('-p')
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
DateStampedZip = $true }
@{ Name = '-p packs without rebuilding'; Args = @('-p')
Match = @('^\+ .*(7z\.exe a|tar\.exe -a -c -f) ')
NotContains = @('cmake -S deps') }
@{ Name = 'deps and slicer build in one invocation'; Args = @('-d', '-s', '-x', '-l')
Contains = @('cmake -S deps', 'cmake -B "build-clang" ') }
'the compiler cache'
@{ Name = '--cache needs clang-cl and Ninja'; Args = @('-s', '--cache', 'ccache'); ExpectExit = 1
Contains = @('needs clang-cl and Ninja') }
# cl.exe is out of scope, since ccache refuses every compile under /Zi.
@{ Name = '--cache under Ninja still needs clang-cl'; Args = @('-s', '-x', '--cache', 'ccache'); ExpectExit = 1
Contains = @('needs clang-cl and Ninja') }
@{ Name = 'an unknown --cache value is rejected'; Args = @('-s', '-x', '--cache', 'nope'); ExpectExit = 1
Contains = @('Expected ccache, sccache or off') }
# A bare PATH, since the machine running the tests may have sccache installed.
@{ Name = 'a --cache tool that is not there is caught early'; Args = @('-s', '-l', '-x', '--cache', 'sccache'); ExpectExit = 1
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
Contains = @('is not on PATH') }
@{ Name = '--cache takes any casing'; Args = @('-s', '-l', '-x', '--cache', 'CCACHE')
Env = @{ PATH = $ccacheOnPath }
Contains = @('ccache.exe') }
@{ Name = '--cache off asks for no launcher'; Args = @('-s', '-x', '--cache', 'off')
NotContains = @('COMPILER_LAUNCHER') }
@{ Name = 'no --cache asks for no launcher'; Args = @('-s', '-x')
NotContains = @('COMPILER_LAUNCHER') }
@{ Name = '--cache turns the precompiled header off'; Args = @('-s', '-l', '-x', '--cache', 'ccache')
Env = @{ PATH = $ccacheOnPath }
Contains = @('-DSLIC3R_PCH=OFF', 'COMPILER_LAUNCHER') }
# Without it the objects name the build directory and only that tree can use them.
@{ Name = '--cache asks for relative debug paths'; Args = @('-s', '-l', '-x', '--cache', 'ccache')
Env = @{ PATH = $ccacheOnPath }
Contains = @('-DSLIC3R_RELATIVE_DEBUG_PATHS=ON') }
@{ Name = 'no --cache leaves the debug paths alone'; Args = @('-s', '-l', '-x')
NotContains = @('SLIC3R_RELATIVE_DEBUG_PATHS') }
# The resolved path, not the bare name, so PATH cannot change it later.
@{ Name = '--cache names the resolved path in the banner'; Args = @('-s', '-l', '-x', '--cache', 'ccache')
Env = @{ PATH = $ccacheOnPath }
Match = @('^Compiler cache: .*/ccache\.exe$') }
@{ Name = '--cache reaches the dependency configure too'; Args = @('-d', '-l', '-x', '--cache', 'ccache')
Env = @{ PATH = $ccacheOnPath }
Contains = @('-DCMAKE_C_COMPILER_LAUNCHER=') }
# Nothing records a launcher without a configure, so the tool is not needed.
# Reaching the cmake check on a bare PATH is what proves it was skipped.
@{ Name = '--no-configure asks for no cache tool'; Args = @('-s', '-l', '-x', '--no-configure', '--cache', 'ccache'); ExpectExit = 1
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
Contains = @('CMake was not found')
NotContains = @('is not on PATH') }
'the developer loop'
@{ Name = '--slicer-target builds one target'; Args = @('-s', '--slicer-target', 'libslic3r')
Contains = @('--config Release --target libslic3r') }
@{ Name = '--no-configure skips the slicer configure'; Args = @('-s', '--no-configure')
Contains = @('cmake --build "build"')
NotContains = @('cmake -B "build" ') }
@{ Name = '--no-configure skips the deps configure'; Args = @('-d', '--no-configure')
Contains = @('cmake --build "deps/build"')
NotContains = @('cmake -S deps') }
@{ Name = '--no-gettext skips the translation step'; Args = @('-s', '--no-gettext')
Contains = @('cmake --build "build"')
NotContains = @('run_gettext') }
# Installing copies the whole tree again for a layout only releases need,
# so it is asked for rather than assumed.
@{ Name = 'nothing is installed unless asked'; Args = @('-s')
Contains = @('run_gettext')
NotContains = @('--target install') }
@{ Name = '-i adds the install step'; Args = @('-s', '-i')
Contains = @('--target install') }
# install(TARGETS OrcaSlicer) has no OPTIONAL, so this would fail partway
# through a build instead of before it.
@{ Name = 'installing a tree with no executable is refused'; Args = @('-s', '-i', '--slicer-target', 'glad'); ExpectExit = 1
Contains = @('--install needs the executable')
NotContains = @('cmake --build') }
# Without -s there is no install step, so there is nothing to refuse.
@{ Name = 'the same flags without a slicer build are left alone'; Args = @('-d', '-i', '--slicer-target', 'glad')
Contains = @('cmake -S deps')
NotContains = @('--install needs the executable') }
@{ Name = 'naming the executable target is allowed'; Args = @('-s', '-i', '--slicer-target', 'OrcaSlicer')
Contains = @('--target install') }
# Ninja counts compilers, so -j goes on the command line. MSBuild's -j
# counts projects while /MP still runs one cl per core inside each, so the
# cap goes to CL_MPCount in the environment instead. A dry run can only
# show that no -j is passed.
@{ Name = '-j on Ninja passes -j to cmake'; Args = @('-s', '-x', '-j', '4')
Contains = @('Parallel jobs: 4', '--config Release -j 4') }
@{ Name = '-j on Ninja reaches the deps build'; Args = @('-d', '-x', '-j', '2')
Contains = @('--target deps -j 2') }
@{ Name = '-j on MSBuild does not pass -j, which would count projects'; Args = @('-s', '-j', '4')
Contains = @('Parallel jobs: 4')
NotContains = @('-j 4') }
@{ Name = '-j on MSBuild leaves the deps build without -j too'; Args = @('-d', '-j', '2')
Contains = @('--target deps')
NotContains = @('-j 2') }
@{ Name = 'no -j means no job limit'; Args = @('-s')
NotContains = @('parallel jobs', '-j ') }
@{ Name = 'a non-numeric -j is rejected'; Args = @('-s', '-j', 'abc'); ExpectExit = 1
Contains = @('Invalid --jobs value') }
@{ Name = 'a zero -j is rejected'; Args = @('-s', '-j', '0'); ExpectExit = 1
Contains = @('Invalid --jobs value') }
@{ Name = 'the loop options combine into a single build command'; Args = @('-s', '-x', '--no-configure', '--no-gettext', '--slicer-target', 'libslic3r_tests', '-j', '8')
Contains = @('--target libslic3r_tests -j 8')
NotContains = @('cmake -B "build" ', 'run_gettext', '--target install') }
'one tree per configuration, compiler and architecture'
# CMake resets its cache and carries on when the compiler changes under an
# existing tree, leaving stamps from the old toolchain. Give each its own.
@{ Name = 'clang builds land in their own trees'; Args = @('-d', '-s', '-l')
Contains = @('cmake -S deps -B "deps/build-clang"', 'cmake -B "build-clang" ') }
@{ Name = 'MSVC keeps the historical plain names'; Args = @('-d', '-s', '--msvc')
Contains = @('cmake -S deps -B "deps/build"', 'cmake -B "build" ')
NotContains = @('build-clang') }
@{ Name = 'configuration, compiler and arch all name the tree'; Args = @('-d', '-s', '-l', '--config', 'debug', '--arch', 'arm64')
Contains = @('cmake -S deps -B "deps/build-dbg-clang-arm64"', 'cmake -B "build-dbg-clang-arm64" ') }
'locating the dependency tree'
# Without --deps-dir nothing is passed, so CMakeLists derives the path from
# the binary directory name as it always has.
@{ Name = 'the deps path is left to CMake by default'; Args = @('-s')
NotContains = @('-DDEP_BUILD_DIR') }
@{ Name = '--deps-dir tells the slicer where the deps are'; Args = @('-s', '--deps-dir', 'D:\orca-deps')
Contains = @('-DDEP_BUILD_DIR="D:\orca-deps"') }
@{ Name = '--deps-dir also redirects the deps build'; Args = @('-d', '--deps-dir', 'D:\orca-deps')
Contains = @('cmake -S deps -B "D:\orca-deps"', 'cmake --build "D:\orca-deps"')
NotContains = @('deps/build') }
# Paths are quoted throughout, so one with spaces survives the whole run.
@{ Name = 'a deps path with spaces survives'; Args = @('-d', '-s', '--deps-dir', 'C:\Program Files\deps')
Contains = @('-B "C:\Program Files\deps"', '-DDEP_BUILD_DIR="C:\Program Files\deps"') }
@{ Name = '--deps-dir also redirects the pack'; Args = @('-p', '--deps-dir', 'D:\orca-deps')
Contains = @('cd /d "D:\orca-deps"') }
@{ Name = 'pack uses an absolute default path'; Args = @('-p')
Match = @('^\+ cd /d "[A-Za-z]:\\.*\\deps\\build"') }
# CMakeLists derives DEP_BUILD_DIR from the build directory's name, so
# pointing the build elsewhere has to name the deps tree outright.
@{ Name = '--build-dir moves the slicer build'; Args = @('-s', '-l', '-x', '--build-dir', 'out/build/x64-clang')
Contains = @('cmake -B "out/build/x64-clang" ') }
@{ Name = '--build-dir still names the deps tree'; Args = @('-s', '-l', '-x', '--build-dir', 'out/build/x64-clang')
Match = @('-DDEP_BUILD_DIR="[A-Za-z]:\\.*\\deps\\build-clang"') }
@{ Name = '--deps-dir wins over the derived tree'; Args = @('-s', '--build-dir', 'out/build/x64-clang', '--deps-dir', 'D:\orca-deps')
Contains = @('-DDEP_BUILD_DIR="D:\orca-deps"') }
# A value ending in a backslash escapes the closing quote it is spliced
# into, so cmake would receive D:\tree" and swallow the next argument.
@{ Name = 'a trailing backslash is trimmed off a path'; Args = @('-s', '--build-dir', 'D:\tree\')
Contains = @('cmake -B "D:\tree" ') }
@{ Name = 'a build directory with spaces survives'; Args = @('-s', '--build-dir', 'C:\Program Files\tree')
Contains = @('cmake -B "C:\Program Files\tree" ') }
@{ Name = 'the default build names no deps tree'; Args = @('-s')
NotContains = @('DEP_BUILD_DIR') }
'saying what mode and toolchain are in play'
@{ Name = 'a dry run announces itself before the first command'; Args = @('-u')
First = '^Dry run: printing commands without running them\.$' }
@{ Name = 'the announcement leads even a full build'; Args = @('-ds')
First = '^Dry run: ' }
# Autodetect only runs for the Visual Studio generator, and only when no
# release was pinned, so it needs a Visual Studio on the machine.
@{ Name = 'the detected Visual Studio names its release year'; Args = @('-s')
Match = @('^Detected Visual Studio \d+ \(20\d\d\)$') }
@{ Name = 'pinning a release skips detection'; Args = @('-s', '--vs', '2022')
NotContains = @('Detected Visual Studio') }
'an action has to be asked for'
# Neither can happen without building the slicer, so they stand alone the
# way --install-vs does.
@{ Name = '--run-tests is an action on its own'; Args = @('--run-tests')
Contains = @('-DBUILD_TESTS=ON', 'ctest --test-dir')
NotContains = @('Nothing to do') }
@{ Name = '--tests is too'; Args = @('--tests')
Contains = @('-DBUILD_TESTS=ON')
NotContains = @('Nothing to do', 'ctest --test-dir') }
# Naming an action means that action, not a fuller build.
@{ Name = 'they do not add a slicer build to one already asked for'; Args = @('-d', '--tests')
Contains = @('cmake -S deps')
NotContains = @('cmake -B "build"') }
@{ Name = 'shaping options alone are not an action'; Args = @('--config', 'debug'); ExpectExit = 1
Contains = @('Nothing to do.')
NotContains = @('Build completed') }
@{ Name = '-j alone is not an action either'; Args = @('-j', '4'); ExpectExit = 1
Contains = @('Nothing to do.') }
@{ Name = '--install-vs counts as an action on its own'; Args = @('--install-vs', 'ide')
NotContains = @('Nothing to do.') }
'diagnostics'
@{ Name = '-v asks cmake for the command lines'; Args = @('-s', '-v')
Contains = @('--verbose') }
@{ Name = '-v applies to the deps build too'; Args = @('-d', '-v')
Contains = @('--target deps', '--verbose') }
# ninja's own default shows neither a percentage nor a time. %w and %W
# need ninja 1.12, and an unknown placeholder is fatal, so the format is
# chosen from the version rather than hardcoded.
@{ Name = '-v names the ninja progress format'; Args = @('-s', '-x', '-v')
Match = @('^Ninja progress format: \[.*%p.*\]') }
@{ Name = 'a ninja older than 1.12 gets the format it understands'; Args = @('-s', '-x', '-v')
Env = @{ PATH = $ninjaPaths['old'] }
Contains = @('Ninja progress format: [%s/%t %p :: %e]') }
@{ Name = 'ninja 1.12 gets elapsed and remaining'; Args = @('-s', '-x', '-v')
Env = @{ PATH = $ninjaPaths['new'] }
Contains = @('Ninja progress format: [%f/%t %p :: %w / %W]') }
@{ Name = 'a format you set yourself is left alone'; Args = @('-s', '-x', '-v')
Env = @{ NINJA_STATUS = '[mine] ' }
Contains = @('Ninja progress format: [mine]') }
@{ Name = 'MSBuild builds mention no ninja format'; Args = @('-s', '-v')
NotContains = @('Ninja progress format') }
@{ Name = 'builds are quiet without -v'; Args = @('-s')
NotContains = @('--verbose') }
'installing prerequisites'
# -u installs CMake, Perl and Git. Visual Studio is a separate ask, because
# most people already have it, and which one you want is a real choice.
@{ Name = '-u alone installs no Visual Studio'; Args = @('-u')
Contains = @('Kitware.CMake', 'StrawberryPerl', 'Git.Git')
NotContains = @('Microsoft.VisualStudio') }
# Asking for Visual Studio is asking to install prerequisites, so it does
# not also need -u; requiring both meant --install-vs alone did nothing.
@{ Name = '--install-vs works without -u'; Args = @('--install-vs', 'ide')
Contains = @('id=Microsoft.VisualStudio.Community', 'Kitware.CMake', 'Git.Git') }
@{ Name = '--install-vs buildtools asks for the build tools'; Args = @('-u', '--install-vs', 'buildtools')
Contains = @('id=Microsoft.VisualStudio.BuildTools')
NotContains = @('VC.CoreIde') }
@{ Name = '--install-vs ide asks for Community with the IDE component'; Args = @('-u', '--install-vs', 'ide')
Contains = @('id=Microsoft.VisualStudio.Community', 'VC.CoreIde') }
@{ Name = 'an unknown edition is rejected and the known ones listed'; Args = @('-u', '--install-vs', 'bogus'); ExpectExit = 1
Contains = @('Unknown Visual Studio edition', 'buildtools, ide') }
@{ Name = '--vs picks which release to install'; Args = @('-u', '--vs', '2022', '--install-vs', 'buildtools')
Contains = @('id=Microsoft.VisualStudio.2022.BuildTools') }
@{ Name = '-l adds the clang compiler and the MSBuild toolset'; Args = @('-u', '--install-vs', 'buildtools', '-l')
Contains = @('VC.Llvm.Clang ', 'VC.Llvm.ClangToolset') }
# CMake 4.x breaks Boost.Context on ARM64, so the installer pins 3.31 there
# and leaves x64 on the current release.
@{ Name = 'CMake is pinned to 3.31 when installing for arm64'; Args = @('-u', '--arch', 'arm64')
Contains = @('Kitware.CMake --version 3.31.8') }
@{ Name = 'CMake is not pinned for x64'; Args = @('-u', '--arch', 'x64')
Contains = @('Kitware.CMake')
NotContains = @('--version') }
@{ Name = 'installing for arm64 asks for the ARM64 toolset'; Args = @('--install-vs', 'buildtools', '--arch', 'arm64')
Contains = @('Microsoft.VisualStudio.Component.VC.Tools.ARM64') }
@{ Name = 'an x64 install asks only for the x64 toolset'; Args = @('--install-vs', 'buildtools')
Contains = @('Microsoft.VisualStudio.Component.VC.Tools.x86.x64')
NotContains = @('VC.Tools.ARM64') }
'dry run changes nothing'
# clean_tree resolves the path before removing it, so the line echoed is
# absolute. It is the last thing printed before a directory goes.
@{ Name = '-c echoes the deps rmdir rather than running it'; Args = @('-d', '-c')
Match = @('^\+ rmdir /S /Q "[A-Za-z]:\\.*\\deps\\build"$') }
@{ Name = '-c removes the tree --deps-dir named, not the default one'; Args = @('-d', '-c', '--deps-dir', 'D:\orca-deps')
Match = @('^\+ rmdir /S /Q "D:\\orca-deps"$')
NotContains = @('\deps\build') }
@{ Name = '-c echoes the slicer rmdir rather than running it'; Args = @('-s', '-c')
Match = @('^\+ rmdir /S /Q "[A-Za-z]:\\.*\\build"$') }
@{ Name = 'cleaning a slicer build leaves the deps tree alone'; Args = @('-s', '-c')
NotMatch = @('rmdir.*\\deps\\') }
@{ Name = 'cleaning both builds removes both trees'; Args = @('-d', '-s', '-c')
Match = @('^\+ rmdir /S /Q "[A-Za-z]:\\[^"]*\\deps\\build"$',
'^\+ rmdir /S /Q "[A-Za-z]:\\(?!.*\\deps\\)[^"]*\\build"$') }
# Nothing below should be reachable. They are a floor under a bug that
# hands clean_tree a path far shorter than it looks.
@{ Name = 'a configuration whose lookup comes back empty is rejected'; Args = @('-d', '-c', '--config', 'release '); ExpectExit = 1
Contains = @('Unknown configuration')
NotContains = @('rmdir') }
@{ Name = 'a drive root is refused'; Args = @('-d', '-c', '--deps-dir', 'D:\'); ExpectExit = 1
Contains = @('that is a drive root')
NotContains = @('+ rmdir') }
@{ Name = 'the repository itself is refused'; Args = @('-d', '-c', '--deps-dir', '.'); ExpectExit = 1
Contains = @('that is the repository itself')
NotContains = @('+ rmdir') }
@{ Name = '-k echoes the taskkills rather than running them'; Args = @('-k')
Contains = @('+ taskkill /F /IM MSBuild.exe', '+ taskkill /F /IM cl.exe') }
@{ Name = '-k also covers the Ninja toolchain'; Args = @('-k')
Contains = @('+ taskkill /F /IM ninja.exe', '+ taskkill /F /IM clang-cl.exe') }
@{ Name = '-k announces the dry run like every other action'; Args = @('-k')
First = '^Dry run: ' }
@{ Name = '-u echoes the winget installs rather than running them'; Args = @('-u')
Contains = @('+ winget install', 'Kitware.CMake')
NotContains = @('cmake -S deps') }
# Not a dry run: winget is a stub that fails, so nothing is installed.
@{ Name = 'a failed install is reported, not claimed as success'; Args = @('-u')
DryRun = $false; ExpectExit = 1
Env = @{ PATH = $stubPath }
Contains = @('Failed to install:', 'CMake', 'Perl', 'Git')
NotContains = @('Installed the prerequisites') }
# The reason belongs inside the frame. Every command this path ran had
# already succeeded, so naming the last one would point at the wrong thing.
@{ Name = 'a failed install names the reason, not the last command'; Args = @('-u')
DryRun = $false; ExpectExit = 1
Env = @{ PATH = $stubPath }
Contains = @('####', 'Failed to install:')
NotContains = @('Failed: winget') }
@{ Name = 'a dry run does not claim the install happened'; Args = @('-u')
Contains = @('Dry run: nothing was installed.')
NotContains = @('are in place') }
@{ Name = '-u with a build defers that build, not a fuller one'; Args = @('-u', '-d')
Contains = @('build_win.bat -d')
NotContains = @('build_win.bat -ds') }
@{ Name = '-u on its own suggests the whole build'; Args = @('-u')
Contains = @('build_win.bat -ds') }
@{ Name = 'a dry run only echoes, it never configures'; Args = @('-d')
Contains = @('+ cmake')
NotContains = @('CMake Error', 'Configuring done') }
'extra configure arguments'
# --deps-args and --slicer-args are declared "rawstring", so a value that
# looks like an option is allowed through. Plain string options still
# reject one, since there it almost always means a forgotten value.
@{ Name = '--deps-args reaches the deps configure'; Args = @('-d', '--deps-args', 'FOO')
Contains = @('-DCMAKE_BUILD_TYPE=Release FOO') }
@{ Name = '--slicer-args reaches the slicer configure'; Args = @('-s', '--slicer-args', 'BAZ')
Contains = @('BAZ') }
@{ Name = '--deps-args accepts a value that starts with a dash'; Args = @('-d', '--deps-args', '-DFOO')
Contains = @('-DFOO') }
@{ Name = 'a plain string option still rejects a dash-leading value'; Args = @('-d', '--deps-target', '--tests'); ExpectExit = 1
Contains = @('looks like another option') }
# cmd splits arguments on "=" as well as spaces, so an unquoted -D reaches
# the script as two arguments however it was invoked.
@{ Name = 'an unquoted value containing = is rejected'; Args = @('-d', '--deps-args', 'FOO=BAR'); ExpectExit = 1
Contains = @('Unknown argument') }
# The environment overrides exist for that reason; nothing tokenises them.
@{ Name = 'ORCA_DEPS_CMAKE_ARGS reaches the deps configure'; Args = @('-d')
Env = @{ ORCA_DEPS_CMAKE_ARGS = '-DFOO=BAR -DBAZ=QUX' }
Contains = @('-DFOO=BAR -DBAZ=QUX') }
@{ Name = 'ORCA_SLICER_CMAKE_ARGS reaches the slicer configure'; Args = @('-s')
Env = @{ ORCA_SLICER_CMAKE_ARGS = '-DWANTED=1' }
Contains = @('-DWANTED=1') }
@{ Name = 'the deps override does not leak into the slicer configure'; Args = @('-s')
Env = @{ ORCA_DEPS_CMAKE_ARGS = '-DDEPSONLY=1' }
NotContains = @('-DDEPSONLY=1') }
@{ Name = 'the help points at the environment for a spaced argument'; Args = @('--help'); DryRun = $false
Contains = @('Neither form supports a value containing an ampersand') }
@{ Name = 'the help lists the environment overrides'; Args = @('--help'); DryRun = $false
Contains = @('Environment:', 'ORCA_DEPS_CMAKE_ARGS', 'ORCA_SLICER_CMAKE_ARGS') }
'running the unit tests'
@{ Name = '--tests builds them without running them'; Args = @('-s', '--tests')
Contains = @('-DBUILD_TESTS=ON')
NotContains = @('ctest') }
@{ Name = '--run-tests builds and runs them'; Args = @('-s', '--run-tests')
Contains = @('-DBUILD_TESTS=ON', 'ctest --test-dir "build/tests" -C Release --output-on-failure') }
@{ Name = '--run-tests follows the build type and directory'; Args = @('-s', '--run-tests', '--config', 'debug')
Contains = @('ctest --test-dir "build-dbg/tests" -C Debug') }
@{ Name = 'no tests are run by default'; Args = @('-s')
NotContains = @('ctest') }
'failures are reported'
@{ Name = 'a missing cmake is caught and exits non-zero'; Args = @('-d'); ExpectExit = 1
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
Contains = @('CMake was not found') }
@{ Name = 'packing does not need cmake, only an archiver'; Args = @('-p')
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
NotContains = @('CMake was not found') }
# Not a dry run: a cd to a missing drive is a real failure inside a
# parenthesised block, which is where exit /b silently loses its code.
# Without the jump to :die this exits 0 and a failed build reads as a
# successful one.
@{ Name = 'a failure inside a build block reaches the caller'; Args = @('-p', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('Exit code 1.', '####')
NotContains = @('Build completed', 'Try') }
# The retry follows the stage that failed. Offering it for the whole run
# would clean a dependency tree that was not at fault.
@{ Name = 'a failure names a retry scoped to the stage that failed'; Args = @('-d', '-s', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('build_win.bat -d --deps-dir "Z:\nope" -c')
NotContains = @('build_win.bat -ds') }
# CMake's own failure here is hundreds of lines about package resolution.
@{ Name = 'a missing dependency tree is named, not left to CMake'; Args = @('-s', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('Dependencies not found at', 'Build them with build_win.bat -d --deps-dir "Z:\nope"')
NotContains = @('cmake -B', 'Try') }
# Every other suggestion carries the flags that reproduce the run; a bare
# -d would point at the MSVC tree after a clang build.
@{ Name = 'the missing-deps hint names this toolchain'; Args = @('-s', '-l', '-x', '--deps-dir', 'deps/not-built')
DryRun = $false; ExpectExit = 1
Contains = @('Build them with build_win.bat -d -l -x --deps-dir "deps/not-built"')
NotExists = @('deps/not-built') }
# A dry run configures nothing, so it must not depend on which trees happen
# to exist on the machine running the suite.
@{ Name = 'a dry run does not check for the deps tree'; Args = @('-s', '--deps-dir', 'Z:\nope')
Contains = @('cmake -B "build"')
NotContains = @('Dependencies not found') }
# -d is about to build them, so there is nothing to report yet.
@{ Name = 'building the deps in the same run skips the check'; Args = @('-d', '-s', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
NotContains = @('Dependencies not found') }
# A configure fails before any compiler runs, so -v has nothing to show.
@{ Name = 'a configure failure is not offered a verbose rebuild'; Args = @('-d', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('-c discard that tree')
NotContains = @('-v show the failing') }
# A bare --no-configure succeeds on a machine that already has a usable
# build tree, so name one that cannot exist instead.
@{ Name = 'a build failure is'; Args = @('-s', '--no-configure', '--build-dir', 'deps/no-such-tree')
DryRun = $false; ExpectExit = 1
Contains = @('-v show the failing')
NotExists = @('deps/no-such-tree') }
# --build-dir names the slicer tree, which a deps failure has nothing to do
# with. --deps-dir stays, because that is the tree that failed.
@{ Name = 'a deps retry leaves out the slicer tree'; Args = @('-d', '-s', '--deps-dir', 'Z:\nope', '--build-dir', 'D:\b')
DryRun = $false; ExpectExit = 1
Contains = @('build_win.bat -d --deps-dir "Z:\nope" -c')
NotContains = @('--build-dir') }
@{ Name = 'an unknown configuration stays a single line'; Args = @('-s', '--config', 'bogus'); ExpectExit = 1
Contains = @('Unknown configuration')
NotContains = @('####', 'Try') }
@{ Name = 'a bad --jobs value is not framed either'; Args = @('-s', '-j', 'x'); ExpectExit = 1
Contains = @('Invalid --jobs value')
NotContains = @('####') }
'the summary says what to do next'
# Every suggested command carries the flags that reproduce this run.
@{ Name = 'a deps build points at the slicer build'; Args = @('-d', '-l')
Contains = @('Build the slicer build_win.bat -s -l')
NotContains = @('Run it') }
@{ Name = 'a ninja slicer build offers a single target'; Args = @('-s', '-l', '-x')
Contains = @('Rebuild after edits build_win.bat -s -l -x --no-configure', 'Rebuild one target')
NotContains = @('Solution', 'Open in Visual Studio') }
@{ Name = 'a visual studio build names the solution instead'; Args = @('-s')
Contains = @('Solution ', 'Open in Visual Studio build\OrcaSlicer.sln',
'Rebuild after edits build_win.bat -s --no-configure')
NotContains = @('Rebuild one target') }
@{ Name = 'the configuration and architecture come back'; Args = @('-s', '-l', '-x', '--config', 'debug', '--arch', 'arm64')
Contains = @('build_win.bat -s -l -x --config debug --arch arm64 --no-configure') }
@{ Name = 'the tree overrides come back quoted'; Args = @('-s', '--deps-dir', 'D:\d', '--build-dir', 'D:\b')
Contains = @('--deps-dir "D:\d" --build-dir "D:\b"') }
@{ Name = 'a pinned visual studio release comes back'; Args = @('-s', '--vs', '2022')
Contains = @('build_win.bat -s --vs 2022 --no-configure') }
# Autodetection writes what it found into the same variable, so a detected
# release must not come back as though it had been asked for.
@{ Name = 'a detected release does not'; Args = @('-s')
NotContains = @('--vs') }
@{ Name = 'the binary is named in the build tree it was built in'; Args = @('-s', '-l', '-x')
Contains = @('build-clang\src\Release\orca-slicer.exe') }
@{ Name = 'installing names the installed copy instead'; Args = @('-s', '-l', '-x', '-i')
Contains = @('build-clang\OrcaSlicer\orca-slicer.exe') }
# -i changes where the binary lands, so a rebuild that dropped it would
# leave the path above pointing at a stale copy.
@{ Name = 'the rebuild suggestion keeps -i'; Args = @('-s', '-l', '-x', '-i')
Contains = @('Rebuild after edits build_win.bat -s -l -x -i --no-configure') }
# A deps retry has no install step to repeat.
@{ Name = 'a deps retry drops it'; Args = @('-d', '-s', '-i', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('build_win.bat -d --deps-dir "Z:\nope" -c')
NotContains = @('-d -i') }
# Naming a target builds it and its dependencies, not its dependents, so
# the binary on disk is whatever the last full build left there.
@{ Name = 'a single-target build does not claim the whole binary'; Args = @('-s', '-l', '-x', '--slicer-target', 'glad')
Contains = @('Target glad', 'Relink the binary build_win.bat -s -l -x --no-configure')
NotContains = @('Run it', 'orca-slicer.exe', 'Rebuild after edits') }
# The executable has a target of its own, and naming that one does relink.
@{ Name = 'naming the executable target still claims the binary'; Args = @('-s', '-l', '-x', '--slicer-target', 'OrcaSlicer')
Contains = @('Run it', 'orca-slicer.exe', 'Rebuild after edits')
NotContains = @('Target OrcaSlicer', 'Relink the binary') }
@{ Name = '--run-tests offers the ctest line'; Args = @('-s', '-l', '-x', '--run-tests')
Contains = @('Re-run the tests ctest --test-dir build-clang/tests -C Release') }
@{ Name = 'packing names the bundle and how to use it'; Args = @('-p', '-l')
Contains = @('Bundle ', 'Share the bundle') }
# The line supplies its own tree, so the one this run used must not ride
# along and contradict it.
@{ Name = 'the bundle line names one tree, not two'; Args = @('-s', '-p', '-l', '-x', '--deps-dir', 'D:\shared')
Contains = @('then build_win.bat -s -l -x --deps-dir <path>')
NotContains = @('--deps-dir "D:\shared" --deps-dir') }
@{ Name = 'installing prerequisites suggests the build that follows'; Args = @('-u', '-l')
Contains = @('Restart this shell', 'build_win.bat -ds -l')
NotContains = @('Run it') }
# Everything below the header line is worked out the same way in either
# run, which is why a dry run can cover it.
@{ Name = 'a dry run does not claim a build happened'; Args = @('-s', '-l', '-x')
Contains = @('Dry run: nothing was built.')
NotContains = @('Build completed in') }
# --no-configure is the iteration loop and still gets the block; four
# lines after a rebuild is not enough to be worth suppressing.
@{ Name = '--no-configure still gets the summary'; Args = @('-s', '-l', '-x', '--no-configure')
Contains = @('Next', 'Rebuild after edits') }
'pointing at the solution'
# The extension follows the generator, so these two pin the release and a
# build directory that cannot already hold a solution of either kind.
@{ Name = 'the 2026 generator gets the XML solution'; Args = @('-s', '--vs', '2026', '--build-dir', 'D:\tree')
Contains = @('Solution D:\tree\OrcaSlicer.slnx', 'Open in Visual Studio D:\tree\OrcaSlicer.slnx') }
@{ Name = 'the releases before it get the classic one'; Args = @('-s', '--vs', '2022', '--build-dir', 'D:\tree')
Contains = @('Solution D:\tree\OrcaSlicer.sln', 'Open in Visual Studio D:\tree\OrcaSlicer.sln') }
@{ Name = 'a solution already on disk wins over the generator'; Args = @('-s', '--vs', '2026', '--build-dir', $slnDir)
Match = @('^ Solution .*\\OrcaSlicer\.sln$') }
# Extension-agnostic from here: these cases are about the directory, and
# the release is whatever is installed.
@{ Name = 'the VS generator says where the solution is'; Args = @('-s')
Match = @('^ Solution .*\\build\\OrcaSlicer\.slnx?$') }
@{ Name = 'the solution path follows the configuration'; Args = @('-s', '--config', 'debug')
Match = @('^ Solution .*\\build-dbg\\OrcaSlicer\.slnx?$') }
@{ Name = 'the solution line survives an install'; Args = @('-s', '-i')
Contains = @(' Solution ') }
# The path is resolved, not pasted onto the repository root, so it is
# right whether --build-dir came absolute or with forward slashes.
@{ Name = 'a moved build still prints one real path'; Args = @('-s', '--build-dir', 'out/build/x64-clang')
Match = @('^ Solution [A-Za-z]:\\[^/]+\\OrcaSlicer\.slnx?$') }
@{ Name = 'an absolute --build-dir is not glued onto the repo root'; Args = @('-s', '--build-dir', 'D:\tree')
Match = @('^ Solution D:\\tree\\OrcaSlicer\.slnx?$') }
)
function Invoke-BuildScript {
param([string[]] $Arguments, [hashtable] $Environment)
$saved = @{}
if ($Environment) {
foreach ($key in $Environment.Keys) {
$saved[$key] = [Environment]::GetEnvironmentVariable($key)
Set-Item -Path "env:$key" -Value $Environment[$key]
}
}
try {
# 'Stop' turns a native command's stderr into a terminating error, and
# a case that exercises a real failure writes to stderr. Let the output
# through and judge the run by its exit code instead. The assignment is
# scoped to this function, so the rest of the suite keeps 'Stop'.
$ErrorActionPreference = 'Continue'
if ($Arguments.Count -eq 0) {
$out = & $Script 2>&1 | Out-String
} else {
$out = & $Script @Arguments 2>&1 | Out-String
}
return [pscustomobject]@{ Output = $out; Exit = $LASTEXITCODE }
} finally {
foreach ($key in $saved.Keys) {
if ($null -eq $saved[$key]) { Remove-Item -Path "env:$key" -ErrorAction SilentlyContinue }
else { Set-Item -Path "env:$key" -Value $saved[$key] }
}
}
}
$knownFields = @(
'Name', 'Args', 'ExpectExit', 'DryRun', 'First', 'Env',
'Contains', 'NotContains', 'Match', 'NotMatch', 'NotExists', 'DateStampedZip'
)
function Test-Case {
param([hashtable] $Case)
# Read fields with the indexer, not dot notation. A hashtable exposes its
# own members too, so $Case.Contains returns the Contains *method* whenever
# the case has no key by that name.
$argv = @($Case['Args'])
if (-not $Case.ContainsKey('DryRun') -or $Case['DryRun']) { $argv += '--dry-run' }
$expect = 0
if ($Case.ContainsKey('ExpectExit')) { $expect = $Case['ExpectExit'] }
$started = Get-Date
$result = Invoke-BuildScript -Arguments $argv -Environment $Case['Env']
$finished = Get-Date
$problems = @()
# A misspelled field is silently ignored by the checks below, which
# leaves the case asserting nothing at all and passing.
foreach ($field in $Case.Keys) {
if ($knownFields -notcontains $field) { $problems += "unknown field '$field'" }
}
if ($result.Exit -ne $expect) { $problems += "exit $($result.Exit), expected $expect" }
foreach ($needle in $Case['Contains']) {
if (-not $result.Output.Contains($needle)) { $problems += "missing '$needle'" }
}
foreach ($needle in $Case['NotContains']) {
if ($result.Output.Contains($needle)) { $problems += "unexpected '$needle'" }
}
$lines = $result.Output -split "`r?`n"
if ($Case['First'] -and $lines[0] -notmatch $Case['First']) {
$problems += "first line was '$($lines[0])'"
}
foreach ($pattern in $Case['Match']) {
if (@($lines | Where-Object { $_ -match $pattern }).Count -eq 0) {
$problems += "no line matching /$pattern/"
}
}
if ($Case['DateStampedZip']) {
# Bound the accepted dates to this invocation so crossing midnight is
# valid without allowing an unrelated past or future date.
$dateStamps = @($started.ToString('yyyyMMdd'), $finished.ToString('yyyyMMdd')) | Select-Object -Unique
$pattern = '_(' + ($dateStamps -join '|') + ')\.zip$'
if (@($lines | Where-Object { $_ -match $pattern }).Count -eq 0) {
$problems += "no line matching /$pattern/"
}
}
foreach ($pattern in $Case['NotMatch']) {
foreach ($line in @($lines | Where-Object { $_ -match $pattern })) {
$problems += "line matches /$pattern/: $line"
}
}
# Output cannot show what a run did not create.
foreach ($path in $Case['NotExists']) {
$full = Join-Path (Split-Path -Parent $Script) $path
if (Test-Path $full) {
$problems += "created '$path'"
}
}
return ,$problems
}
$pass = 0
$failed = @()
# Held back so a filtered run does not print headings for groups it skipped.
$heading = $null
foreach ($case in $cases) {
if ($case -is [string]) {
$heading = $case
continue
}
if ($Name -and $case['Name'] -notmatch $Name) { continue }
if ($heading) {
Write-Host ''
Write-Host $heading -ForegroundColor Cyan
$heading = $null
}
$problems = Test-Case -Case $case
if ($problems.Count -eq 0) {
$pass++
Write-Host (' ok ' + $case['Name'])
} else {
$failed += $case['Name']
Write-Host (' FAIL ' + $case['Name']) -ForegroundColor Red
foreach ($problem in $problems) { Write-Host (' ' + $problem) -ForegroundColor Red }
Write-Host (' args: ' + (@($case['Args']) -join ' '))
}
}
Remove-Item -Recurse -Force $fixtures -ErrorAction SilentlyContinue
Write-Host ''
# A pattern that matched nothing has proved nothing, so do not report it as
# a clean run.
if ($Name -and $pass -eq 0 -and $failed.Count -eq 0) {
Write-Host "no case matched /$Name/" -ForegroundColor Red
exit 1
}
Write-Host "$pass passed, $($failed.Count) failed"
if ($failed.Count -gt 0) {
foreach ($name in $failed) { Write-Host " failed: $name" -ForegroundColor Red }
exit 1
}
exit 0