Command-line builds

In the early days of working on our game, after the honeymoon phase of playing with tutorials, we suddenly remembered our days of software engineering, where we needed repeatable builds and tests. And by repeatable, I don’t mean clicking the run button in the IDE, clicking on the Unreal Editor to package the game; but instead running some kind of command-line build tool. Of course, this tool should report the build process, as well as any test results in machine-readable format, one that can be easily wired into our TeamCity build runner. Something like Java’s Maven or Rust’s Cargo; if not then CMake, or dare we say sbt.

Unreal Automation Tool

Unreal Engine does come with Unreal Automation Tool, and its main script RunUAT.bat or RunUAT.sh (depending on platform); but the experience was a long way from having a richer build definition, with comprehensible test runners, reports, machine-readable output… One just runs RunUAT script with the right parameters, and out comes a built game or errors on stdout or stderr. That is… not quite enough. It is possible to create a small script file to automate setting the parameters for RunUAT (such as project file, output directory, …), but we didn’t want to maintain Linux and Windows versions, and we wanted to make a somewhat reusable tool.

UAT wrapper

We built a UAT wrapper, and we used PowerShell for it–it is available on Windows as well as our target Linux distros. The UAT wrapper constructs the parameters for UAT, but also parses its output for convenient usage with TeamCity; additionally, it also parses Unreal Test JSON outputs into a TeamCity format. With this in place, all our projects only need to add fairly simple Build.ps1 PowerShell script; this Build.ps1 imports the other UAT wrapper tooling.

#!/usr/bin/pwsh

param ($target = "Shipping", $outDirectory = "", $buildNumber = "", $revision = "", [switch] $sync)

. Automation/BuildCommon.ps1
. Automation/UATTeamCity.ps1

$b = [BuilderSettings]::new()

$p = [ProjectSettings]::new()
$p.projectDirectory = Get-Location
$p.projectFile = "Kopi.uproject"
$p.target = "Kopi"
$p.testCategories = @("Project.Game", "Project.Plugins")
if ($buildNumber -ne "") {
    $p.buildNumber = $buildNumber
}
if ($revision -ne "") {
    $p.buildNumber = "$( $revision ) $( Get-Date -Format "HH:mm dd-MM-yy" )"
}

$b = [Builder]::new($b, $p)
$r = $b.Build($target)
ConvertTo-TeamCityBuild $r.log

if ($null -ne $r.testReportJsonFile -and $r.testReportJsonFile -ne "")
{
    ConvertTo-TeamCityTestReport -Path $r.testReportJsonFile -WarningsAsErrors $true
}

if ($r.errorCount -gt 0) {
    Exit 1
}

This Build.ps1 now lives its life like almost every other project build file: a long, long time ago, we wrote one from scratch; now we just copy it from project to project, making small changes as needed. This perfectly matches our enterprise software experience, so no problems there! The output of the wrapper is somewhat human-readable output.

...\Kopi> ./Build.ps1
WARNING: Did not set build number. Check BuildInfo.h.
C:\'Program Files\Epic Games\UE_5.3\Engine\Build\BatchFiles'\RunUAT.bat         BuildCookRun        -installed         -nop4         -build        -clean        -project="D:\Games\Kopi/Kopi.uproject"         -cook         -CookAll        -stage         -pak         -archive         -archivedirectory="E:\"         -package         -map="/Game/Maps/L_MainMenu.umap+/Game/Maps/CabinCafe/L_CabinCafe_1.umap+/Game/Maps/Diner/L_Diner_1.umap+/Game/Maps/HomeKitchen/L_HomeKitchen_1.umap+/Game/Maps/HomeKitchen/L_HomeKitchen_2.umap+/Game/Maps/HomeKitchen/L_HomeKitchen_3.umap+/Game/Maps/Homeless/L_Homeless_1.umap+/Game/Maps/Homeless/L_Homeless_2.umap+/Game/Maps/Homeless/L_Homeless_3.umap"        -ddc=InstalledDerivedDataBackendGraph         -nullrhi         -prereqs         -ForceMonolithic         -targetplatform=Win64         -target=Kopi         -configuration=Shipping        -serverconfig=Shipping         -clientconfig=Shipping         -utf8output
...
##teamcity[message text='|[w|] UAT: |[TEXTURE|] /Engine/EngineMaterials/BaseFlattenNormalMap_VT.BaseFlattenNormalMap_VT is marked for virtual streaming but virtual texture streaming is not available' status='WARNING' timestamp='2024-02-27T18:13:01.000']
##teamcity[message text='|[w|] UAT: |[TEXTURE|] /Engine/EngineMaterials/BaseFlattenLinearColor_VT.BaseFlattenLinearColor_VT is marked for virtual streaming but virtual texture streaming is not available' status='WARNING' timestamp='2024-02-27T18:13:01.000']
##teamcity[message text='|[w|] UAT: |[TEXTURE|] /Engine/EngineMaterials/BaseFlattenLinearColor_VT.BaseFlattenLinearColor_VT is marked for virtual streaming but virtual texture streaming is not available' status='WARNING' timestamp='2024-02-27T18:13:01.000']
##teamcity[message text='|[w|] UAT: |[TEXTURE|] /Engine/EngineMaterials/Black_1x1_EXR_Texture_VT.Black_1x1_EXR_Texture_VT is marked for virtual streaming but virtual texture streaming is not available' status='WARNING' timestamp='2024-02-27T18:13:01.000']
##teamcity[message text='|[w|] UAT: |[TEXTURE|] /Engine/EngineMaterials/Black_1x1_EXR_Texture_VT.Black_1x1_EXR_Texture_VT is marked for virtual streaming but virtual texture streaming is not available' status='WARNING' timestamp='2024-02-27T18:13:01.000']
##teamcity[message text='|[w|] UAT: |[TEXTURE|] /Engine/EngineMaterials/BaseFlattenGrayscaleMap_VT.BaseFlattenGrayscaleMap_VT is marked for virtual streaming but virtual texture streaming is not available' status='WARNING' timestamp='2024-02-27T18:13:01.000']
##teamcity[message text='|[w|] UAT: |[TEXTURE|] /Engine/EngineMaterials/BaseFlattenGrayscaleMap_VT.BaseFlattenGrayscaleMap_VT is marked for virtual streaming but virtual texture streaming is not available' status='WARNING' timestamp='2024-02-27T18:13:01.000']
##teamcity[message text='|[w|] UAT: |[TEXTURE|] /Engine/EngineMaterials/BaseFlattenEmissiveMap_VT.BaseFlattenEmissiveMap_VT is marked for virtual streaming but virtual texture streaming is not available' status='WARNING' timestamp='2024-02-27T18:13:01.000']
##teamcity[message text='|[w|] UAT: |[TEXTURE|] /Engine/EngineMaterials/BaseFlattenEmissiveMap_VT.BaseFlattenEmissiveMap_VT is marked for virtual streaming but virtual texture streaming is not available' status='WARNING' timestamp='2024-02-27T18:13:01.000']
##teamcity[message text='|[w|] UAT: |[TEXTURE|] /Engine/EngineMaterials/BaseFlattenDiffuseMap_VT.BaseFlattenDiffuseMap_VT is marked for virtual streaming but virtual texture streaming is not available' status='WARNING' timestamp='2024-02-27T18:13:01.000']
##teamcity[message text='|[w|] UAT: |[TEXTURE|] /Engine/EngineMaterials/BaseFlattenDiffuseMap_VT.BaseFlattenDiffuseMap_VT is marked for virtual streaming but virtual texture streaming is not available' status='WARNING' timestamp='2024-02-27T18:13:01.000']
##teamcity[message text='|[*|] Build Succeeded']

Notice the processed ##teamcity messages and the extracted relevant error or warning details. This way, it’s easy to spot any build problems in the TeamCity project view, but even outside TeamCity, it’s easy to eyeball the terminal output.

The core of the automation wrapper is in

  • Automation/BuildCommon.ps1
    The core of the automation wrapper
  • Automation/UATTeamCity.ps1
    TeamCity converter for UAT JSON reports

Let’s start with BuildCommon.ps1; with PowerShell, we get a little more creature-comforts over shell scripting, never mind Windows batch files. We can split the BuildCommon.ps1 into three parts: the UAT output processing, the configuration parameters for the builder and the project, and the wrapper itself.

enum LogLevel
{
   ...
}

class LogEntryDetail 
{
    [string]$raw
}

class LogEntry 
{
    [string]$stage
    [LogEntryDetail]$detail
    [LogLevel]$level
    [DateTime]$timestamp
}

class UATLogEntryParser
{
    [LogEntry]
    Parse($raw) { ... }
}

The job of he LogEntry and UATLogEntryParser is to regex-out the UAT output and pick out the relevant details. Did I say regex? That sounds a little–brittle. But fear not, PowerShell actually comes with neat pattern matching that can do regex, too.

class UATLogEntryParser
{
    [string[]]$ignoredWarnings
    UATLogEntryParser($ignoredWarnings)
    {
        $this.ignoredWarnings = $ignoredWarnings
    }

    [LogEntry]
    Parse($raw)
    {
        $now = [DateTime]::Now
        switch -regex ($raw)
        {
            "^\[(\d+.\d+.\d+-\d+.\d+.\d+:\d+)\]\[\W*(\d+)\](.*)" {
                [DateTime]::ParseExact($matches[1], 'yyyy.MM.dd-HH.mm.ss:fff', $null)
                $raw = $matches[3]
            }
        }
        $x = switch -regex ($raw)
        {
            ".*LogStringTable: Warning: Failed to find string table entry for '([^']+)' '([^']+)'.*" {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[MISSING_STRINGTABLE_ENTRY] $( $matches[1] )::$( $matches[2] )"))
                Break
            }
            ".*LogLinker: Warning: CreateExport: Failed to load Parent for BlueprintGeneratedClass (.*)" {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[MISSING_BP_PARENT] $( $matches[1] )"))
                Break
            }
            ".*LogUObjectGlobals: Warning: \[AssetLog\] (.*): (.*) '([^']+)': (.*)'" {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[MISSING_TEXTURE] $( $matches[1] ) -> $( $matches[3] )"))
                Break
            }
            ".*LogLinker: Warning: VerifyImport: Failed to load package for import object 'Package (.*)'" {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[MISSING_OBJECT] $( $matches[1] )"))
                Break
            }
            Default {
                [LogEntry]::new("UAT", [LogLevel]::None,[LogEntryDetail]::new("[RAW] $raw"))
                Break
            }
        }

        $x.timestamp = $now

        return $x
    }
}

We have included $ignoredWarnings list, which you can add to if you have a pedantic (i.e. treat warnings as errors) release build, but there is a warning that you cannot get rid of. On to builder settings; the builder settings contain the engine and output directories; the project settings is similar in the sense of pointing to the correct project files, list of maps to be included in the built artefacts; and other small details.

class BuilderSettings
{
    [string]$ueBatchDir
    [string]$ueBatchRunUAT
    [string]$ueEditorCmd
    [string]$ueBinDir
    [string]$platform
    [string]$outDirectory
}

class ProjectSettings
{
    [string]$projectDirectory
    [string]$projectFile
    [string]$target
    [string]$buildNumber
    [string[]]$includedMaps = @()
    [string[]]$testCategories
    [string[]]$ignoredWarnings = @()
    [string[]]$excludedMaps = @()
}

The final piece of is the BuildResult and Builder itself; the most important is Builder, which actually wraps the UAT scripts and coordinates all other code.

class BuildResult
{
    [int]$errorCount
    [int]$warningCount
    [LogEntry[]]$log
    [boolean]$built
    [string]$testReportJsonFile = $null
}

class Builder
{
   [BuildResult]
   Build($config) { ... }
}

The core of the Builder is the code that constructs the parameters for the Unreal Build Tool; I have used the excellent GitHub – botman99/ue4-unreal-automation-tool description to construct the parameters for RunUAT.

class Builder {
    ...

    [string[]]
    BuildAndCook($config, $maps)
    {
        $params = @"
        BuildCookRun
        -installed 
        -nop4 
        -build
        -clean
        -project=`"$( $this.projectSettings.projectDirectory )/$( $this.projectSettings.projectFile )`" 
        -cook 
        -CookAll
        -stage 
        -pak 
        -archive 
        -archivedirectory=`"$( $this.builderSettings.outDirectory )`" 
        -package 
        -map=`"$($this.builderSettings.join($maps) )`"
        -ddc=InstalledDerivedDataBackendGraph 
        -nullrhi 
        -prereqs 
        -ForceMonolithic 
        -targetplatform=$( $this.builderSettings.platform ) 
        -target=$( $this.projectSettings.target ) 
        -configuration=$config
        -serverconfig=$config 
        -clientconfig=$config 
        -utf8output 
"@.Replace("`r`n", "").Replace("`n", "")
        Write-Host "$( $this.builderSettings.ueBatchRunUAT ) $params"
        return Invoke-Expression "$( $this.builderSettings.ueBatchRunUAT ) $params"
    }

    [BuildResult]
    Build($config)
    {
        $this.WriteBuildNumber()

        [LogEntry[]]$entries = @()
        $parser = [UATLogEntryParser]::new($this.projectSettings.ignoredWarnings)
        $cwd = Get-Location

        $maps = $this.projectSettings.includedMaps
        # Auto-discover maps, but ignoring the Developer-specific ones, even if they are present 
        $discoveredMaps = Get-ChildItem -Filter *.umap -Path "$( $this.projectSettings.projectDirectory )/Content" -Recurse
        foreach ($discoveredMap in $discoveredMaps)
        {
            $localMapFile = $discoveredMap.FullName.Replace($this.projectSettings.projectDirectory, "").Replace("\Content", "\Game").Replace('\', '/')
             if (!$localMapFile.StartsWith("/Game/Developers"))
            {
                $maps += $localMapFile
            }
        }
        foreach ($excludedMap in $this.projectSettings.excludedMaps)
        {
            $maps.Remove($excludedMap)
        }

        [BuildResult]$result = [BuildResult]::new()

        try
        {
            Set-Location $( $this.projectSettings.projectDirectory )
            $build = $this.BuildAndCook($config, $maps)
            foreach ($line in $build)
            {
                $entries += $parser.Parse($line)
            }

            ...
        }
        finally
        {
            Set-Location $cwd
        }

        $summary = @{}
        foreach ($name in [LogLevel].GetEnumValues())
        {
            $summary[$name] = 0
        }
        foreach ($entry in $entries) {
            $summary[$entry.level] = $summary[$entry.level] + 1
        }

        $result.errorCount = $summary[[LogLevel]::Error]
        $result.warningCount = $summary[[LogLevel]::Warning]
        $result.log = $entries

        return $result
    }

    
}

The BuildAndCook is the point of handing out the heavy-lifting of actually building the game to the Unreal Automation Tool; it returns the raw output from the RunUAT; this raw output is then parsed in the calling method,

Source code

If you want to use the entire script directly, or as inspiration; you can copy-and-paste to your heart’s content.

BuildCommon.ps1

<#
Copyright (c) 2023-2024, Dream on a Stick Limited. All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

3. All advertising materials mentioning features or use of this software must
display the following acknowledgement:
This product includes software developed by Dream on a Stick.

4. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER "AS IS" AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#>


enum LogLevel
{
    None = 0
    Warning = 1
    Error = 2
    Always = 3
}

class LogEntryDetail
{
    [string]$raw
    LogEntryDetail($raw)
    {
        $this.raw = $raw
    }
    [string]
    ToString()
    {
        return $this.raw
    }
}

class LogEntry
{
    [string]$stage
    [LogEntryDetail]$detail
    [LogLevel]$level
    [DateTime]$timestamp

    LogEntry($stage, $level, $detail)
    {
        $this.stage = $stage
        $this.level = $level
        $this.detail = $detail
    }

    [string]
    Parsed()
    {
        return $this.raw
    }

    [string]
    ToString()
    {
        $result = switch ($this.level)
        {
            "None"    {
                "[ ] "
            }
            "Warning" {
                "[w] "
            }
            "Error"   {
                "[e] "
            }
            "Always"  {
                "[*] "
            }
        }
        $result += $this.stage
        $result += ": "
        $result += $this.detail.ToString()

        return $result
    }
}

class UATLogEntryParser
{
    [string[]]$ignoredWarnings
    UATLogEntryParser($ignoredWarnings)
    {
        $this.ignoredWarnings = $ignoredWarnings
    }

    [LogEntry]
    Parse($raw)
    {
        $now = [DateTime]::Now
        switch -regex ($raw)
        {
            "^\[(\d+.\d+.\d+-\d+.\d+.\d+:\d+)\]\[\W*(\d+)\](.*)" {
                [DateTime]::ParseExact($matches[1], 'yyyy.MM.dd-HH.mm.ss:fff', $null)
                $raw = $matches[3]
            }
        }
        $x = switch -regex ($raw)
        {
            ".*LogStringTable: Warning: Failed to find string table entry for '([^']+)' '([^']+)'.*" {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[MISSING_STRINGTABLE_ENTRY] $( $matches[1] )::$( $matches[2] )"))
                Break
            }
            ".*LogLinker: Warning: CreateExport: Failed to load Parent for BlueprintGeneratedClass (.*)" {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[MISSING_BP_PARENT] $( $matches[1] )"))
                Break
            }
            ".*LogUObjectGlobals: Warning: \[AssetLog\] (.*): (.*) '([^']+)': (.*)'" {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[MISSING_TEXTURE] $( $matches[1] ) -> $( $matches[3] )"))
                Break
            }
            ".*LogLinker: Warning: VerifyImport: Failed to load package for import object 'Package (.*)'" {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[MISSING_OBJECT] $( $matches[1] )"))
                Break
            }
            ".*LogAutomationController: Error: Test Completed. Result={.*} Name={(.*)} Path={(.*)}" {
                [LogEntry]::new("UAT", [LogLevel]::Error,[LogEntryDetail]::new("[TEST_FAILURE] $( $matches[1] ) in $( $matches[2] ) failed"))
                Break
            }
            ".*LogAutomationController: Error: Test (.*) failed, but no errors were logged" {
                [LogEntry]::new("UAT", [LogLevel]::Error,[LogEntryDetail]::new("[TEST_FAILURE] $( $matches[1] ) failed"))
                Break
            }
            ".*LogAutomationController: Error: (.*) will be marked as failing due to errors being logged.*" {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[TEST_FAILURE] Test logged error $( $matches[1] )"))
                Break
            }
            ".*LogAutomationController: Error: Script Msg: (.*) \[.*" {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[TEST_FAILURE] Test logged error '$( $matches[1] )'"))
                Break
            }
            ".*LogAutomationController: Error: (.*): FinishTest TestResult=Failed. (.*) \[.*" {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[TEST_FAILURE] Test $( $matches[1] ) failed with '$( $matches[2] )'"))
                Break
            }
            ".*LogBlueprint: Warning: \[AssetLog\] (.*): \[Compiler\] .* : Usage of '(.*)' has been deprecated. Please replace or remove it." {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[DEPRECATION] $( $matches[1] ) -> $( $matches[2] ) has been deprecated"))
                Break
            }
            ".*LogTexture: Display: (.*) is marked for virtual streaming but virtual texture streaming is not available." {
                [LogEntry]::new("UAT", [LogLevel]::Warning,[LogEntryDetail]::new("[TEXTURE] $( $matches[1] ) is marked for virtual streaming but virtual texture streaming is not available"))
                Break
            }
            ".*LogInterchangeDispatcher: Display: Handler ended with error: Worker process issue .*" {
                [LogEntry]::new("UAT", [LogLevel]::None,[LogEntryDetail]::new("[INTERCHANGE] Skipped benign warning"))
                Break
            }
            ".*Warning:.*" {
                $level = [LogLevel]::Warning
                foreach ($iw in $this.ignoredWarnings)
                {
                    if ($raw -match ".*$iw.*")
                    {
                        $level = [LogLevel]::None
                        Break
                    }
                }
                [LogEntry]::new("UAT", $level,[LogEntryDetail]::new("[RAW] $raw"))
                Break
            }
            ".*Error:.*" {
                [LogEntry]::new("UAT", [LogLevel]::Error,[LogEntryDetail]::new("[RAW] $raw"))
                Break
            }
            Default {
                [LogEntry]::new("UAT", [LogLevel]::None,[LogEntryDetail]::new("[RAW] $raw"))
                Break
            }
        }

        $x.timestamp = $now

        return $x
    }
}

# -------------------- Build & Archive ---------------------
class BuildResult
{
    [int]$errorCount
    [int]$warningCount
    [LogEntry[]]$log
    [boolean]$built
    [string]$testReportJsonFile = $null
}

class BuilderSettings
{
    [string]$ueBatchDir
    [string]$ueBatchRunUAT
    [string]$ueEditorCmd
    [string]$ueBinDir
    [string]$platform
    [string]$outDirectory

    BuilderSettings()
    {
        if ($env:OS -eq "Windows_NT")
        {
            $this.ueBatchDir = "C:\'Program Files\Epic Games\UE_5.3\Engine\Build\BatchFiles'"
            $this.ueBinDir = "C:\'Program Files\Epic Games\UE_5.3\Engine\Binaries\Win64'"
            $this.ueBatchRunUAT = "$( $this.ueBatchDir )\RunUAT.bat"
            $this.ueEditorCmd = "$( $this.ueBinDir )\UnrealEditor-Cmd.exe"
            $this.outDirectory = "E:\"
            $this.platform = "Win64"
        }
        elseif ($env:WSL_DISTRO_NAME -ne "")
        {
            $this.ueBatchDir = "/opt/ue5_3/Engine/Build/BatchFiles"
            $this.ueBinDir = "/opt/ue5_3/Engine/Binaries/Linux"
            $this.ueBatchRunUAT = "$( $this.ueBatchDir )/RunUAT.sh"
            $this.ueEditorCmd = "$( $this.ueBinDir )/UnrealEditor-Cmd"
            $this.outDirectory = "/var/ue5_3"
            $this.platform = "Linux"
        }
        else
        {
            throw "Not implemented yet."
        }
    }

    [string]
    join([string[]] $strings)
    {
        if ($this.platform -eq "Win64")
        {
            return $strings -join "+"
        }
        else
        {
            return $strings -join ":"
        }
    }

}

class ProjectSettings
{
    [string]$projectDirectory
    [string]$projectFile
    [string]$target
    [string]$buildNumber
    [string[]]$includedMaps = @()
    [string[]]$testCategories
    [string[]]$ignoredWarnings = @()
    [string[]]$excludedMaps = @()

    ProjectSettings()
    {
        $this.buildNumber = "SNAPSHOT $( Get-Date -Format "HH:mm dd-MM-yy" )"
    }

}

class Builder
{
    [ProjectSettings]$projectSettings
    [BuilderSettings]$builderSettings
    [string]$testReportExportPath

    Builder($builderSettings, $projectSettings)
    {
        $this.projectSettings = $projectSettings
        $this.builderSettings = $builderSettings
        $this.testReportExportPath = "$( $this.builderSettings.outDirectory )/Saved/TestReport"
    }

    [void]
    WriteBuildNumber()
    {
        $buildInfoHFileName = "$( $this.projectSettings.projectDirectory )/Source/BuildInfo.h"
        $buildH = Get-Content $buildInfoHFileName
        $buildH = $buildH.ForEach({
            switch -regex ($_)
            {
                "#define BUILD_NUMBER " {
                    "#define BUILD_NUMBER `"$( $this.projectSettings.buildNumber )`""
                }
                Default {
                    $_
                }
            }
        });
        try
        {
            Set-Content -Path $buildInfoHFileName -Value $buildH -ErrorAction Ignore
        }
        catch
        {
            # noop
            Write-Host "WARNING: Did not set build number. Check BuildInfo.h."
        }
    }

    [string[]]
    Test()
    {
        $tcs = $this.projectSettings.testCategories -join "+"
        $rhi = "-nullrhi"
        if ($this.projectSettings.testCategories -contains "Project.Functional Tests")
        {
            $rhi = ""
        }
        $params = @"
        `"$( $this.projectSettings.projectDirectory )/$( $this.projectSettings.projectFile )`" 
        -stdout 
        -fullstdoutlogoutput 
        -nosplash 
        -unattended 
        -nop4 
        $( $rhi )
        -nopause 
        -ExecCmds="Automation RunTests $( $tcs )" 
        -testexit="Successfully wrote json results file!" 
        -log 
        -log=RunTests.log
        -ReportExportPath=`"$( $this.testReportExportPath )`"
"@.Replace("`r`n", "").Replace("`n", "")
        Write-Host "$( $this.builderSettings.ueEditorCmd ) $params"
        return Invoke-Expression "$( $this.builderSettings.ueEditorCmd ) $params"
    }

    [string[]]
    BuildAndCook($config, $maps)
    {
        $params = @"
        BuildCookRun
        -installed 
        -nop4 
        -build
        -clean
        -project=`"$( $this.projectSettings.projectDirectory )/$( $this.projectSettings.projectFile )`" 
        -cook 
        -CookAll
        -stage 
        -pak 
        -archive 
        -archivedirectory=`"$( $this.builderSettings.outDirectory )`" 
        -package 
        -map=`"$($this.builderSettings.join($maps) )`"
        -ddc=InstalledDerivedDataBackendGraph 
        -nullrhi 
        -prereqs 
        -ForceMonolithic 
        -targetplatform=$( $this.builderSettings.platform ) 
        -target=$( $this.projectSettings.target ) 
        -configuration=$config
        -serverconfig=$config 
        -clientconfig=$config 
        -utf8output 
"@.Replace("`r`n", "").Replace("`n", "")
        Write-Host "$( $this.builderSettings.ueBatchRunUAT ) $params"
        return Invoke-Expression "$( $this.builderSettings.ueBatchRunUAT ) $params"
    }

    [bool]
    ContainsEntryRaw($entries, $raw)
    {
        foreach ($e in $entries)
        {
            if ($e.raw -eq $raw)
            {
                return $true
            }
        }
        return $false
    }

    [BuildResult]
    Build($config)
    {
        $this.WriteBuildNumber()

        [LogEntry[]]$entries = @()
        $parser = [UATLogEntryParser]::new($this.projectSettings.ignoredWarnings)
        $cwd = Get-Location

        $maps = $this.projectSettings.includedMaps
        # Auto-discover maps, but ignoring the Developer-specific ones, even if they are present 
        $discoveredMaps = Get-ChildItem -Filter *.umap -Path "$( $this.projectSettings.projectDirectory )/Content" -Recurse
        foreach ($discoveredMap in $discoveredMaps)
        {
            $localMapFile = $discoveredMap.FullName.Replace($this.projectSettings.projectDirectory, "").Replace("\Content", "\Game").Replace('\', '/')
             if (!$localMapFile.StartsWith("/Game/Developers"))
            {
                $maps += $localMapFile
            }
        }
        foreach ($excludedMap in $this.projectSettings.excludedMaps)
        {
            $maps.Remove($excludedMap)
        }

        [BuildResult]$result = [BuildResult]::new()

        try
        {
            Set-Location $( $this.projectSettings.projectDirectory )
            $build = $this.BuildAndCook($config, $maps)
            foreach ($line in $build)
            {
                $entries += $parser.Parse($line)
            }

            if ($config -eq "Test")
            {
                $test = $this.Test()
                foreach ($line in $test)
                {
                    $entries += $parser.Parse($line)
                }
                $result.testReportJsonFile = $this.testReportExportPath + "/index.json"
            }
            else
            {
                $entries += [LogEntry]::new("CI ", [LogLevel]::Always,[LogEntryDetail]::new("Skipping tests in $config."))
            }
        }
        finally
        {
            Set-Location $cwd
        }

        $summary = @{}
        foreach ($name in [LogLevel].GetEnumValues())
        {
            $summary[$name] = 0
        }
        foreach ($entry in $entries) {
            $summary[$entry.level] = $summary[$entry.level] + 1
        }

        $result.errorCount = $summary[[LogLevel]::Error]
        $result.warningCount = $summary[[LogLevel]::Warning]
        $result.log = $entries

        return $result
    }

}

UATTeamCity.ps1

<#
Copyright (c) 2023, Dream on a Stick Limited. All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

3. All advertising materials mentioning features or use of this software must
display the following acknowledgement:
This product includes software developed by Dream on a Stick.

4. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER "AS IS" AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#>

function ConvertTo-TeamCityEscaped
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, HelpMessage = "The string to convert")]
        [string]$text
    )
    return $text.
            Replace("|", "||").
            Replace("'", "|'").
            Replace("\n", "|n").
            Replace("\r", "|r").
            Replace("[", "|[").
            Replace("]", "|]")
}

function ConvertTo-TeamCityBuild
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, HelpMessage = "The log array to convert")]
        $log
    )
    # ##teamcity[message text='Exception text' errorDetails='stack trace' status='ERROR']
    foreach ($le in $log) {
        $status = switch ($le.level) {
            None { "NORMAL" }
            Warning { "WARNING" }
            Error { "ERROR" }
            Always { "NORMAL" }
        }
        Write-Host "##teamcity[message text='$( ConvertTo-TeamCityEscaped($le.ToString()) )' status='$status' timestamp='$( $le.timestamp.ToString("yyyy-MM-dd'T'HH:mm:ss.000") )']" 
    }
}

function ConvertTo-TeamCityTestReport
{
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, HelpMessage = "The path to the JSON file to process.")]
        [string]$Path,
        [Parameter(Mandatory = $false, HelpMessage = "Treat warnings as errors.")]
        [bool]$WarningsAsErrors = $true
    )
    BEGIN {
        if (!( Test-Path -Path $Path -PathType "Leaf"))
        {
            Write-Error "The path $Path does not exist" -ErrorAction Stop
        }
        $json = Get-Content $Path | ConvertFrom-Json
    }
    PROCESS {
        foreach ($test in $json.tests)
        {
            $testName = $test.fullTestPath
            Write-Host "##teamcity[testStarted name='$testName']"
            foreach ($entry in $test.entries)
            {
                $ts = [DateTime]::ParseExact($entry.timestamp, 'yyyy.MM.dd-HH.mm.ss', $null)
                switch ($entry.event.type)
                {
                    "Info" {
                        Write-Host "##teamcity[testStdOut name='$testName' out='$( ConvertTo-TeamCityEscaped($entry.event.message) )' timestamp=`'$( $ts.ToString("yyyy-MM-dd'T'HH:mm:ss.fff") )`']"
                    }
                    "Warning" {
                        Write-Host "##teamcity[testStdErr name='$testName' out='$( ConvertTo-TeamCityEscaped($entry.event.message) )' timestamp=`'$( $ts.ToString("yyyy-MM-dd'T'HH:mm:ss.fff") )`']"
                    }
                    "Error" {
                        #  TODO: add the $entry.filename, and $entry.lineNumber (N.B. the inconsistent casing)
                        $details = "$($entry.filename)@$($entry.lineNumber)"
                        Write-Host "##teamcity[testFailed name='$testName' message='$( ConvertTo-TeamCityEscaped($entry.event.message) )' details='$( ConvertTo-TeamCityEscaped($details) )' timestamp=`'$( $ts.ToString("yyyy-MM-dd'T'HH:mm:ss.fff") )`']"
                    }
                }
            }
            $duration = $test.duration
            if ([int]$test.warnings -igt 0 -and $WarningsAsErrors)
            {
                Write-Host "##teamcity[testFailed name='$testName' message=`'Encountered warnings`' duration=`'$duration`']"
            }
            else
            {
                if ([int]$test.errors -gt 0)
                {
                    Write-Host "##teamcity[testFailed name='$testName' message=`'Encountered errors`' duration=`'$duration`']"
                }
                else
                {
                    Write-Host "##teamcity[testFinished name='$testName' duration=`'$duration`']"
                }
            }
        }
    }
}
 

Leave a Reply

Your email address will not be published. Required fields are marked *