feat(launcher): prepare Campaign LA11 user gate
This commit is contained in:
parent
09d84387a8
commit
f881e5b467
24 changed files with 3538 additions and 58 deletions
333
tools/new-campaign-la-update-fixture.ps1
Normal file
333
tools/new-campaign-la-update-fixture.ps1
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Creates deterministic isolated Campaign LA A/B update feeds.
|
||||
|
||||
.DESCRIPTION
|
||||
Packages caller-supplied published client and launcher roots for win-x64
|
||||
and linux-x64, adds a deterministic release marker, calculates the exact
|
||||
LA10 SHA-256/size manifest fields, and emits a loopback-only static server
|
||||
plus a local A/B selector. It never downloads, connects, edits a payload
|
||||
source, or writes outside -OutputDirectory.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$OutputDirectory,
|
||||
[Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryA,
|
||||
[Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryA,
|
||||
[Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryA,
|
||||
[Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryA,
|
||||
[Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryB,
|
||||
[Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryB,
|
||||
[Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryB,
|
||||
[Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryB,
|
||||
[string]$VersionA = '1.0.1-la11.a',
|
||||
[string]$VersionB = '1.0.1-la11.b',
|
||||
[string]$MinimumLauncherVersion = '1.0.0',
|
||||
[int]$Port = 43119,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||
throw 'Campaign LA update fixture creation requires PowerShell 7 or newer.'
|
||||
}
|
||||
if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
|
||||
throw '-OutputDirectory must be absolute.'
|
||||
}
|
||||
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
|
||||
[IO.Path]::GetFullPath($OutputDirectory))
|
||||
if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' }
|
||||
$semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$'
|
||||
if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or
|
||||
$MinimumLauncherVersion -notmatch $semver -or $VersionA -ceq $VersionB) {
|
||||
throw 'VersionA, VersionB, and MinimumLauncherVersion must be SemVer 2.0; A and B must differ.'
|
||||
}
|
||||
$parsedVersionA = [semver]$VersionA
|
||||
$parsedVersionB = [semver]$VersionB
|
||||
$parsedMinimumLauncherVersion = [semver]$MinimumLauncherVersion
|
||||
if ($parsedVersionB.CompareTo($parsedVersionA) -le 0) {
|
||||
throw 'VersionB must be newer than VersionA.'
|
||||
}
|
||||
if ($parsedMinimumLauncherVersion.CompareTo($parsedVersionA) -gt 0) {
|
||||
throw 'MinimumLauncherVersion must not be newer than VersionA.'
|
||||
}
|
||||
|
||||
$sources = [ordered]@{
|
||||
'A-client-win-x64' = $ClientWinX64DirectoryA
|
||||
'A-launcher-win-x64' = $LauncherWinX64DirectoryA
|
||||
'A-client-linux-x64' = $ClientLinuxX64DirectoryA
|
||||
'A-launcher-linux-x64' = $LauncherLinuxX64DirectoryA
|
||||
'B-client-win-x64' = $ClientWinX64DirectoryB
|
||||
'B-launcher-win-x64' = $LauncherWinX64DirectoryB
|
||||
'B-client-linux-x64' = $ClientLinuxX64DirectoryB
|
||||
'B-launcher-linux-x64' = $LauncherLinuxX64DirectoryB
|
||||
}
|
||||
foreach ($key in @($sources.Keys)) {
|
||||
$source = [string]$sources[$key]
|
||||
if (-not [IO.Path]::IsPathFullyQualified($source)) {
|
||||
throw "Payload source '$key' must be absolute."
|
||||
}
|
||||
$source = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($source))
|
||||
$sources[$key] = $source
|
||||
if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) {
|
||||
throw "Payload source '$key' does not exist: $source"
|
||||
}
|
||||
}
|
||||
|
||||
function Require-PayloadFile([string]$Key, [string]$Name) {
|
||||
if ($DryRun) { return }
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $sources[$Key] $Name) -PathType Leaf)) {
|
||||
throw "Payload source '$Key' is missing root file '$Name'."
|
||||
}
|
||||
}
|
||||
foreach ($release in @('A', 'B')) {
|
||||
Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe'
|
||||
Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe'
|
||||
Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe'
|
||||
Require-PayloadFile "$release-client-linux-x64" 'AcDream.App'
|
||||
Require-PayloadFile "$release-client-linux-x64" 'acdream-headless'
|
||||
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher'
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $OutputDirectory) {
|
||||
if (@(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) {
|
||||
throw '-OutputDirectory must not already contain files.'
|
||||
}
|
||||
}
|
||||
else { $null = New-Item -ItemType Directory -Path $OutputDirectory }
|
||||
|
||||
if ($DryRun) {
|
||||
$plan = [ordered]@{
|
||||
schemaVersion = 1
|
||||
kind = 'campaign-la-update-fixture-plan'
|
||||
outputDirectory = $OutputDirectory
|
||||
versions = @($VersionA, $VersionB)
|
||||
minimumLauncherVersion = $MinimumLauncherVersion
|
||||
port = $Port
|
||||
sources = $sources
|
||||
writesOutsideOutputDirectory = $false
|
||||
externalNetwork = $false
|
||||
}
|
||||
$plan | ConvertTo-Json -Depth 5 |
|
||||
Set-Content -LiteralPath (Join-Path $OutputDirectory 'dry-run.json') -Encoding utf8NoBOM
|
||||
Write-Host "Campaign LA update fixture dry run: $OutputDirectory"
|
||||
return
|
||||
}
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
|
||||
|
||||
function New-DeterministicZip(
|
||||
[string]$SourceDirectory,
|
||||
[string]$Destination,
|
||||
[string]$ReleaseLabel,
|
||||
[string]$PayloadKind,
|
||||
[string]$Rid) {
|
||||
$destinationDirectory = Split-Path -Parent $Destination
|
||||
$null = New-Item -ItemType Directory -Force -Path $destinationDirectory
|
||||
$stream = [IO.FileStream]::new(
|
||||
$Destination,
|
||||
[IO.FileMode]::CreateNew,
|
||||
[IO.FileAccess]::ReadWrite,
|
||||
[IO.FileShare]::None)
|
||||
try {
|
||||
$archive = [IO.Compression.ZipArchive]::new(
|
||||
$stream,
|
||||
[IO.Compression.ZipArchiveMode]::Create,
|
||||
$true,
|
||||
[Text.Encoding]::UTF8)
|
||||
try {
|
||||
$files = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Recurse |
|
||||
Sort-Object { [IO.Path]::GetRelativePath($SourceDirectory, $_.FullName).Replace('\', '/') })
|
||||
foreach ($file in $files) {
|
||||
if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Payload contains a reparse point: $($file.FullName)"
|
||||
}
|
||||
$relative = [IO.Path]::GetRelativePath($SourceDirectory, $file.FullName).Replace('\', '/')
|
||||
if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or
|
||||
[IO.Path]::IsPathRooted($relative)) {
|
||||
throw "Payload path escaped its root: $relative"
|
||||
}
|
||||
$entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal)
|
||||
$entry.LastWriteTime = $fixedTimestamp
|
||||
$executable = $relative -ceq 'AcDream.App' -or
|
||||
$relative -ceq 'acdream-headless' -or
|
||||
$relative -ceq 'acdream-launcher' -or
|
||||
$relative.EndsWith('.sh', [StringComparison]::Ordinal)
|
||||
$mode = if ($executable) { 0x81ED } else { 0x81A4 }
|
||||
$entry.ExternalAttributes = $mode -shl 16
|
||||
$input = [IO.File]::OpenRead($file.FullName)
|
||||
$output = $entry.Open()
|
||||
try { $input.CopyTo($output) }
|
||||
finally { $output.Dispose(); $input.Dispose() }
|
||||
}
|
||||
$marker = $archive.CreateEntry(
|
||||
'campaign-la-fixture-release.txt',
|
||||
[IO.Compression.CompressionLevel]::Optimal)
|
||||
$marker.LastWriteTime = $fixedTimestamp
|
||||
$marker.ExternalAttributes = 0x81A4 -shl 16
|
||||
$writer = [IO.StreamWriter]::new(
|
||||
$marker.Open(),
|
||||
[Text.UTF8Encoding]::new($false))
|
||||
try {
|
||||
$writer.NewLine = "`n"
|
||||
$writer.Write("release=$ReleaseLabel`npayload=$PayloadKind`nrid=$Rid`n")
|
||||
}
|
||||
finally { $writer.Dispose() }
|
||||
}
|
||||
finally { $archive.Dispose() }
|
||||
}
|
||||
finally { $stream.Dispose() }
|
||||
}
|
||||
|
||||
function Get-Artifact([string]$Path, [string]$Url) {
|
||||
$item = Get-Item -LiteralPath $Path
|
||||
return [ordered]@{
|
||||
url = $Url
|
||||
sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
size = $item.Length
|
||||
}
|
||||
}
|
||||
|
||||
$releaseDefinitions = @(
|
||||
[pscustomobject]@{ Label = 'A'; Version = $VersionA },
|
||||
[pscustomobject]@{ Label = 'B'; Version = $VersionB }
|
||||
)
|
||||
foreach ($release in $releaseDefinitions) {
|
||||
$releaseRoot = Join-Path $OutputDirectory $release.Label
|
||||
foreach ($rid in @('win-x64', 'linux-x64')) {
|
||||
New-DeterministicZip `
|
||||
$sources["$($release.Label)-client-$rid"] `
|
||||
(Join-Path $releaseRoot "client-$rid.zip") `
|
||||
$release.Label 'client' $rid
|
||||
New-DeterministicZip `
|
||||
$sources["$($release.Label)-launcher-$rid"] `
|
||||
(Join-Path $releaseRoot "launcher-$rid.zip") `
|
||||
$release.Label 'launcher' $rid
|
||||
}
|
||||
$baseUri = "http://127.0.0.1:$Port/$($release.Label)"
|
||||
$manifest = [ordered]@{
|
||||
schemaVersion = 1
|
||||
version = $release.Version
|
||||
minimumLauncherVersion = $MinimumLauncherVersion
|
||||
clients = [ordered]@{
|
||||
'win-x64' = Get-Artifact `
|
||||
(Join-Path $releaseRoot 'client-win-x64.zip') `
|
||||
"$baseUri/client-win-x64.zip"
|
||||
'linux-x64' = Get-Artifact `
|
||||
(Join-Path $releaseRoot 'client-linux-x64.zip') `
|
||||
"$baseUri/client-linux-x64.zip"
|
||||
}
|
||||
launchers = [ordered]@{
|
||||
'win-x64' = Get-Artifact `
|
||||
(Join-Path $releaseRoot 'launcher-win-x64.zip') `
|
||||
"$baseUri/launcher-win-x64.zip"
|
||||
'linux-x64' = Get-Artifact `
|
||||
(Join-Path $releaseRoot 'launcher-linux-x64.zip') `
|
||||
"$baseUri/launcher-linux-x64.zip"
|
||||
}
|
||||
}
|
||||
$manifest | ConvertTo-Json -Depth 8 -Compress |
|
||||
Set-Content -LiteralPath (Join-Path $releaseRoot 'manifest.json') -Encoding utf8NoBOM
|
||||
}
|
||||
Set-Content -LiteralPath (Join-Path $OutputDirectory 'active-release.txt') `
|
||||
-Value 'A' -Encoding ascii -NoNewline
|
||||
|
||||
$server = @'
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Root,
|
||||
[Parameter(Mandatory = $true)][int]$Port
|
||||
)
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root))
|
||||
$prefix = "http://127.0.0.1:$Port/"
|
||||
$listener = [Net.HttpListener]::new()
|
||||
$listener.Prefixes.Add($prefix)
|
||||
$listener.Start()
|
||||
Write-Host "Campaign LA fixture listening on $prefix"
|
||||
try {
|
||||
while ($listener.IsListening) {
|
||||
$context = $listener.GetContext()
|
||||
try {
|
||||
if ($context.Request.HttpMethod -cne 'GET') {
|
||||
$context.Response.StatusCode = 405
|
||||
continue
|
||||
}
|
||||
$relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/'))
|
||||
if ($relative -ceq 'manifest.json') {
|
||||
$active = (Get-Content -LiteralPath (Join-Path $Root 'active-release.txt') -Raw).Trim()
|
||||
if ($active -notin @('A', 'B')) { throw 'active-release.txt must contain A or B.' }
|
||||
$relative = "$active/manifest.json"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) {
|
||||
$context.Response.StatusCode = 404
|
||||
continue
|
||||
}
|
||||
$path = [IO.Path]::GetFullPath((Join-Path $Root $relative))
|
||||
if (-not $path.StartsWith($Root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) -or
|
||||
-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||
$context.Response.StatusCode = 404
|
||||
continue
|
||||
}
|
||||
$context.Response.ContentType = if ($path.EndsWith('.json', [StringComparison]::Ordinal)) {
|
||||
'application/json'
|
||||
} else { 'application/zip' }
|
||||
$context.Response.StatusCode = 200
|
||||
$context.Response.Headers['Cache-Control'] = 'no-store'
|
||||
$context.Response.ContentLength64 = (Get-Item -LiteralPath $path).Length
|
||||
$input = [IO.File]::OpenRead($path)
|
||||
try { $input.CopyTo($context.Response.OutputStream) }
|
||||
finally { $input.Dispose() }
|
||||
}
|
||||
catch {
|
||||
$context.Response.StatusCode = 500
|
||||
Write-Error $_
|
||||
}
|
||||
finally { $context.Response.Close() }
|
||||
}
|
||||
}
|
||||
finally { $listener.Close() }
|
||||
'@
|
||||
$server | Set-Content -LiteralPath (Join-Path $OutputDirectory 'serve-fixture.ps1') -Encoding utf8NoBOM
|
||||
|
||||
$selector = @'
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][ValidateSet('A', 'B')][string]$Release,
|
||||
[string]$Root = $PSScriptRoot
|
||||
)
|
||||
Set-StrictMode -Version Latest
|
||||
$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt'
|
||||
Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline
|
||||
Write-Host "Campaign LA fixture active release: $Release"
|
||||
'@
|
||||
$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM
|
||||
|
||||
$inventory = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
|
||||
Where-Object { $_.Name -ne 'fixture-report.json' } |
|
||||
Sort-Object FullName |
|
||||
ForEach-Object {
|
||||
[ordered]@{
|
||||
path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
|
||||
size = $_.Length
|
||||
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
})
|
||||
$report = [ordered]@{
|
||||
schemaVersion = 1
|
||||
kind = 'campaign-la-update-fixture'
|
||||
versions = [ordered]@{ A = $VersionA; B = $VersionB }
|
||||
minimumLauncherVersion = $MinimumLauncherVersion
|
||||
manifestUri = "http://127.0.0.1:$Port/manifest.json"
|
||||
loopbackOnly = $true
|
||||
initialRelease = 'A'
|
||||
sourceDirectories = $sources
|
||||
artifacts = $inventory
|
||||
}
|
||||
$report | ConvertTo-Json -Depth 8 |
|
||||
Set-Content -LiteralPath (Join-Path $OutputDirectory 'fixture-report.json') -Encoding utf8NoBOM
|
||||
Write-Host "Campaign LA update fixture: $OutputDirectory"
|
||||
394
tools/run-campaign-la-preflight.ps1
Normal file
394
tools/run-campaign-la-preflight.ps1
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Campaign LA11 display-free, connection-free automated preflight.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs the exact Release and portability ladder used before the launcher
|
||||
user gate. It never starts App/Headless in connected mode, never opens a
|
||||
window, never reads credentials, and never bakes retail DATs. All logs and
|
||||
publishes are contained beneath one logs/campaign-la-gate-<timestamp>
|
||||
directory. Use -DryRun to emit the complete command matrix without
|
||||
executing it.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
||||
[string]$OutputDirectory,
|
||||
[switch]$DryRun,
|
||||
[switch]$IncludeInstalledDat,
|
||||
[string]$InstalledDatDirectory
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||
throw 'Campaign LA preflight requires PowerShell 7 or newer.'
|
||||
}
|
||||
|
||||
$Repository = [IO.Path]::TrimEndingDirectorySeparator(
|
||||
[IO.Path]::GetFullPath($Repository))
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) {
|
||||
throw "Repository does not contain AcDream.slnx: $Repository"
|
||||
}
|
||||
if ($IncludeInstalledDat) {
|
||||
if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or
|
||||
-not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) {
|
||||
throw '-IncludeInstalledDat requires an absolute -InstalledDatDirectory.'
|
||||
}
|
||||
$InstalledDatDirectory = [IO.Path]::TrimEndingDirectorySeparator(
|
||||
[IO.Path]::GetFullPath($InstalledDatDirectory))
|
||||
foreach ($file in @(
|
||||
'client_portal.dat',
|
||||
'client_cell_1.dat',
|
||||
'client_highres.dat',
|
||||
'client_local_English.dat')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $InstalledDatDirectory $file) -PathType Leaf)) {
|
||||
throw "Installed DAT directory is missing $file."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
|
||||
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
|
||||
$OutputDirectory = Join-Path $Repository "logs/campaign-la-gate-$stamp"
|
||||
}
|
||||
elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
|
||||
$OutputDirectory = Join-Path $Repository $OutputDirectory
|
||||
}
|
||||
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
|
||||
[IO.Path]::GetFullPath($OutputDirectory))
|
||||
$logsDirectory = Join-Path $OutputDirectory 'commands'
|
||||
$publishDirectory = Join-Path $OutputDirectory 'publish'
|
||||
$null = New-Item -ItemType Directory -Force -Path $logsDirectory
|
||||
|
||||
$commandResults = [Collections.Generic.List[object]]::new()
|
||||
$failures = [Collections.Generic.List[string]]::new()
|
||||
$startedUtc = [DateTime]::UtcNow
|
||||
|
||||
function Protect-Text([string]$Text) {
|
||||
if ($null -eq $Text) { return '' }
|
||||
$protected = $Text
|
||||
foreach ($name in @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')) {
|
||||
$value = [Environment]::GetEnvironmentVariable($name)
|
||||
if (-not [string]::IsNullOrEmpty($value)) {
|
||||
$protected = $protected.Replace($value, '<redacted>', [StringComparison]::Ordinal)
|
||||
}
|
||||
}
|
||||
$protected = [Text.RegularExpressions.Regex]::Replace(
|
||||
$protected,
|
||||
'(?i)(--password|-password)(\s+|=)([^\s"'']+)',
|
||||
'$1$2<redacted>')
|
||||
$protected = [Text.RegularExpressions.Regex]::Replace(
|
||||
$protected,
|
||||
'(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")',
|
||||
'$1<redacted>$2')
|
||||
return $protected
|
||||
}
|
||||
|
||||
function Format-Command([string]$FilePath, [string[]]$Arguments) {
|
||||
$parts = [Collections.Generic.List[string]]::new()
|
||||
$parts.Add($FilePath)
|
||||
foreach ($argument in $Arguments) {
|
||||
if ($argument -match '[\s"]') {
|
||||
$parts.Add('"' + $argument.Replace('"', '\"') + '"')
|
||||
}
|
||||
else { $parts.Add($argument) }
|
||||
}
|
||||
return $parts -join ' '
|
||||
}
|
||||
|
||||
function Add-PlannedCommand(
|
||||
[string]$Name,
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[Collections.IDictionary]$Environment = @{}) {
|
||||
$commandResults.Add([ordered]@{
|
||||
name = $Name
|
||||
command = Format-Command $FilePath $Arguments
|
||||
status = 'planned'
|
||||
startedUtc = $null
|
||||
durationSeconds = 0
|
||||
exitCode = $null
|
||||
stdout = $null
|
||||
stderr = $null
|
||||
environmentKeys = @($Environment.Keys | Sort-Object)
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-GateCommand(
|
||||
[string]$Name,
|
||||
[string]$FilePath,
|
||||
[string[]]$Arguments,
|
||||
[Collections.IDictionary]$Environment = @{}) {
|
||||
if ($DryRun) {
|
||||
Add-PlannedCommand $Name $FilePath $Arguments $Environment
|
||||
return
|
||||
}
|
||||
|
||||
$safeName = $Name -replace '[^A-Za-z0-9_.-]', '-'
|
||||
$stdoutRelative = "commands/$safeName.out.log"
|
||||
$stderrRelative = "commands/$safeName.err.log"
|
||||
$stdoutPath = Join-Path $OutputDirectory $stdoutRelative
|
||||
$stderrPath = Join-Path $OutputDirectory $stderrRelative
|
||||
$begin = [DateTime]::UtcNow
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$exitCode = 74
|
||||
try {
|
||||
$startInfo = [Diagnostics.ProcessStartInfo]::new()
|
||||
$startInfo.FileName = $FilePath
|
||||
$startInfo.WorkingDirectory = $Repository
|
||||
$startInfo.UseShellExecute = $false
|
||||
$startInfo.CreateNoWindow = $true
|
||||
$startInfo.RedirectStandardOutput = $true
|
||||
$startInfo.RedirectStandardError = $true
|
||||
foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) }
|
||||
foreach ($entry in $Environment.GetEnumerator()) {
|
||||
$startInfo.Environment[[string]$entry.Key] = [string]$entry.Value
|
||||
}
|
||||
$process = [Diagnostics.Process]::new()
|
||||
$process.StartInfo = $startInfo
|
||||
if (-not $process.Start()) { throw "Could not start $FilePath." }
|
||||
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $process.StandardError.ReadToEndAsync()
|
||||
$process.WaitForExit()
|
||||
$stdout = $stdoutTask.GetAwaiter().GetResult()
|
||||
$stderr = $stderrTask.GetAwaiter().GetResult()
|
||||
$exitCode = $process.ExitCode
|
||||
$process.Dispose()
|
||||
[IO.File]::WriteAllText($stdoutPath, (Protect-Text $stdout))
|
||||
[IO.File]::WriteAllText($stderrPath, (Protect-Text $stderr))
|
||||
}
|
||||
catch {
|
||||
[IO.File]::WriteAllText($stderrPath, (Protect-Text ($_ | Out-String)))
|
||||
}
|
||||
finally {
|
||||
$watch.Stop()
|
||||
$commandResults.Add([ordered]@{
|
||||
name = $Name
|
||||
command = Format-Command $FilePath $Arguments
|
||||
status = if ($exitCode -eq 0) { 'passed' } else { 'failed' }
|
||||
startedUtc = $begin.ToString('O')
|
||||
durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3)
|
||||
exitCode = $exitCode
|
||||
stdout = $stdoutRelative
|
||||
stderr = $stderrRelative
|
||||
environmentKeys = @($Environment.Keys | Sort-Object)
|
||||
})
|
||||
}
|
||||
if ($exitCode -ne 0) {
|
||||
throw "Preflight command '$Name' failed with exit code $exitCode."
|
||||
}
|
||||
}
|
||||
|
||||
function Add-InternalCheck([string]$Name, [scriptblock]$Action) {
|
||||
if ($DryRun) {
|
||||
$commandResults.Add([ordered]@{
|
||||
name = $Name; command = '<internal contract check>'; status = 'planned'
|
||||
startedUtc = $null; durationSeconds = 0; exitCode = $null
|
||||
stdout = $null; stderr = $null; environmentKeys = @()
|
||||
})
|
||||
return
|
||||
}
|
||||
$begin = [DateTime]::UtcNow
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$exitCode = 0
|
||||
try { & $Action }
|
||||
catch { $exitCode = 1; throw }
|
||||
finally {
|
||||
$watch.Stop()
|
||||
$commandResults.Add([ordered]@{
|
||||
name = $Name; command = '<internal contract check>'
|
||||
status = if ($exitCode -eq 0) { 'passed' } else { 'failed' }
|
||||
startedUtc = $begin.ToString('O')
|
||||
durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3)
|
||||
exitCode = $exitCode; stdout = $null; stderr = $null; environmentKeys = @()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-DotNet([string]$Name, [string[]]$Arguments) {
|
||||
Invoke-GateCommand $Name 'dotnet' $Arguments
|
||||
}
|
||||
|
||||
$portableBuildProjects = @(
|
||||
'src/AcDream.Platform/AcDream.Platform.csproj',
|
||||
'src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj',
|
||||
'src/AcDream.Bake/AcDream.Bake.csproj',
|
||||
'src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj',
|
||||
'src/AcDream.Core/AcDream.Core.csproj',
|
||||
'src/AcDream.Core.Net/AcDream.Core.Net.csproj',
|
||||
'src/AcDream.Content/AcDream.Content.csproj',
|
||||
'src/AcDream.Runtime/AcDream.Runtime.csproj',
|
||||
'src/AcDream.Headless/AcDream.Headless.csproj'
|
||||
)
|
||||
$portableTestProjects = @(
|
||||
'tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj',
|
||||
'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj',
|
||||
'tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj',
|
||||
'tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj',
|
||||
'tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj',
|
||||
'tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj',
|
||||
'tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj'
|
||||
)
|
||||
|
||||
try {
|
||||
Invoke-DotNet 'release-build' @(
|
||||
'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1')
|
||||
Invoke-DotNet 'release-tests-serial' @(
|
||||
'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1',
|
||||
'--', 'RunConfiguration.MaxCpuCount=1')
|
||||
Invoke-DotNet 'focused-launcher-updater-core' @(
|
||||
'test', 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj',
|
||||
'-c', 'Release', '--no-build', '--nologo',
|
||||
'--filter', 'FullyQualifiedName~Updates')
|
||||
Invoke-DotNet 'focused-launcher-updater-ui' @(
|
||||
'test', 'tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj',
|
||||
'-c', 'Release', '--no-build', '--nologo',
|
||||
'--filter', 'FullyQualifiedName~LauncherUpdateViewModelTests|FullyQualifiedName~LauncherStartupOptionsTests')
|
||||
|
||||
foreach ($project in $portableBuildProjects) {
|
||||
$leaf = [IO.Path]::GetFileNameWithoutExtension($project)
|
||||
Invoke-DotNet "portable-build-$leaf" @(
|
||||
'build', $project, '-c', 'Release', '--no-restore', '--nologo', '-m:1')
|
||||
}
|
||||
foreach ($project in $portableTestProjects) {
|
||||
$leaf = [IO.Path]::GetFileNameWithoutExtension($project)
|
||||
Invoke-DotNet "portable-test-$leaf" @(
|
||||
'test', $project, '-c', 'Release', '--no-build', '--nologo',
|
||||
'--', 'RunConfiguration.MaxCpuCount=1')
|
||||
}
|
||||
|
||||
foreach ($rid in @('win-x64', 'linux-x64')) {
|
||||
$destination = Join-Path $publishDirectory $rid
|
||||
Invoke-DotNet "publish-launcher-$rid" @(
|
||||
'publish', 'src/AcDream.Launcher/AcDream.Launcher.csproj',
|
||||
'-c', 'Release', '-r', $rid, '--self-contained', 'true',
|
||||
'-p:PublishSingleFile=true', '-o', $destination, '--nologo')
|
||||
Add-InternalCheck "publish-contract-$rid" {
|
||||
$suffix = if ($rid.StartsWith('win-', [StringComparison]::Ordinal)) { '.exe' } else { '' }
|
||||
foreach ($name in @("acdream-launcher$suffix", "acdream-bake$suffix")) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $destination $name) -PathType Leaf)) {
|
||||
throw "$rid publish is missing $name."
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath (Join-Path $destination 'acdream-launcher.dll')) {
|
||||
throw "$rid launcher publish is not single-file."
|
||||
}
|
||||
if (Test-Path -LiteralPath (Join-Path $destination 'acdream-bake.dll')) {
|
||||
throw "$rid bake publish is not single-file."
|
||||
}
|
||||
if (-not $IsWindows -and $rid -eq 'linux-x64') {
|
||||
$mode = [IO.File]::GetUnixFileMode((Join-Path $destination 'acdream-launcher'))
|
||||
if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) {
|
||||
throw 'linux-x64 launcher is not executable.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$nativeRid = if ($IsWindows) { 'win-x64' } else { 'linux-x64' }
|
||||
$nativeSuffix = if ($IsWindows) { '.exe' } else { '' }
|
||||
$nativeRoot = Join-Path $publishDirectory $nativeRid
|
||||
$bogusRoot = if ($IsWindows) { 'Z:\definitely-not-installed' } else { '/definitely-not-installed' }
|
||||
$bogusEnvironment = @{
|
||||
DOTNET_ROOT = $bogusRoot
|
||||
DOTNET_ROOT_X64 = $bogusRoot
|
||||
DOTNET_MULTILEVEL_LOOKUP = '0'
|
||||
}
|
||||
Invoke-GateCommand 'native-launcher-bogus-dotnet-root' `
|
||||
(Join-Path $nativeRoot "acdream-launcher$nativeSuffix") `
|
||||
@('--verify-publish') $bogusEnvironment
|
||||
Invoke-GateCommand 'native-bake-bogus-dotnet-root' `
|
||||
(Join-Path $nativeRoot "acdream-bake$nativeSuffix") `
|
||||
@('--help') $bogusEnvironment
|
||||
|
||||
if ($IncludeInstalledDat) {
|
||||
$datEnvironment = @{
|
||||
ACDREAM_DAT_DIR = $InstalledDatDirectory
|
||||
ACDREAM_PROBE_LIVE_MOUNT = '1'
|
||||
}
|
||||
$datResults = Join-Path $OutputDirectory 'installed-dat-results'
|
||||
Invoke-GateCommand 'installed-dat-character-management-readonly' 'dotnet' @(
|
||||
'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj',
|
||||
'-c', 'Release', '--no-build', '--nologo',
|
||||
'--filter', 'FullyQualifiedName~CharacterManagementLiveDatTests',
|
||||
'--results-directory', $datResults,
|
||||
'--logger', 'trx;LogFileName=character-management.trx') $datEnvironment
|
||||
Add-InternalCheck 'installed-dat-character-management-require-pass' {
|
||||
$trx = Join-Path $datResults 'character-management.trx'
|
||||
if (-not (Test-Path -LiteralPath $trx -PathType Leaf)) {
|
||||
throw 'CharacterManagementLiveDatTests did not produce a TRX result.'
|
||||
}
|
||||
[xml]$result = Get-Content -LiteralPath $trx -Raw
|
||||
$outcomes = @($result.TestRun.Results.UnitTestResult | ForEach-Object { $_.outcome })
|
||||
if ($outcomes.Count -eq 0 -or $outcomes -ccontains 'NotExecuted' -or
|
||||
@($outcomes | Where-Object { $_ -cne 'Passed' }).Count -gt 0) {
|
||||
throw "CharacterManagementLiveDatTests must pass (not skip): $($outcomes -join ',')."
|
||||
}
|
||||
}
|
||||
Invoke-GateCommand 'installed-dat-action-map-readonly' 'dotnet' @(
|
||||
'test', 'tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj',
|
||||
'-c', 'Release', '--no-build', '--nologo',
|
||||
'--filter', 'FullyQualifiedName~RetailActionMapReader_LiveDatTests') $datEnvironment
|
||||
Invoke-GateCommand 'installed-dat-portal-assets-readonly' 'dotnet' @(
|
||||
'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj',
|
||||
'-c', 'Release', '--no-build', '--nologo',
|
||||
'--filter', 'FullyQualifiedName~PortalTunnelAssetTests.InstalledDat_ResolvesRetailPortalSetupAndAnimation') $datEnvironment
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$failures.Add((Protect-Text ($_ | Out-String)).Trim())
|
||||
}
|
||||
finally {
|
||||
$finishedUtc = [DateTime]::UtcNow
|
||||
$head = (& git -C $Repository rev-parse HEAD).Trim()
|
||||
$dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all)
|
||||
$artifacts = @()
|
||||
if (-not $DryRun) {
|
||||
$artifacts = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
|
||||
Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } |
|
||||
Sort-Object FullName |
|
||||
ForEach-Object {
|
||||
[ordered]@{
|
||||
path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
|
||||
size = $_.Length
|
||||
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
})
|
||||
}
|
||||
$failedCommands = @($commandResults | Where-Object { $_.status -eq 'failed' })
|
||||
$report = [ordered]@{
|
||||
schemaVersion = 1
|
||||
kind = 'campaign-la-automated-preflight'
|
||||
dryRun = [bool]$DryRun
|
||||
success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0)
|
||||
repository = $Repository
|
||||
head = $head
|
||||
dirty = ($dirtyLines.Count -gt 0)
|
||||
dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ })
|
||||
platform = [ordered]@{
|
||||
os = [Runtime.InteropServices.RuntimeInformation]::OSDescription
|
||||
architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
|
||||
processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString()
|
||||
rid = [Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier
|
||||
framework = [Runtime.InteropServices.RuntimeInformation]::FrameworkDescription
|
||||
powershell = $PSVersionTable.PSVersion.ToString()
|
||||
}
|
||||
startedUtc = $startedUtc.ToString('O')
|
||||
finishedUtc = $finishedUtc.ToString('O')
|
||||
durationSeconds = [Math]::Round(($finishedUtc - $startedUtc).TotalSeconds, 3)
|
||||
installedDatIncluded = [bool]$IncludeInstalledDat
|
||||
commands = @($commandResults)
|
||||
failures = @($failures)
|
||||
redaction = [ordered]@{
|
||||
applied = $true
|
||||
environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')
|
||||
credentialArgumentsAllowed = $false
|
||||
}
|
||||
artifacts = $artifacts
|
||||
}
|
||||
$reportPath = Join-Path $OutputDirectory 'report.json'
|
||||
$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8NoBOM
|
||||
Write-Host "Campaign LA preflight report: $reportPath"
|
||||
if (-not $report.success) { exit 1 }
|
||||
}
|
||||
346
tools/test-campaign-la-session-status.ps1
Normal file
346
tools/test-campaign-la-session-status.ps1
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Strict Campaign LA v1 session-status and terminal-process validator.
|
||||
|
||||
.DESCRIPTION
|
||||
Validates exact JSONL property sets and property order, lifecycle order for
|
||||
probe/guiSelect/gui/headless, terminal semantics, plugin expectations,
|
||||
credential redaction, and absence of launcher child-process leaks. The
|
||||
report contains hashes and event names only; it does not copy account,
|
||||
character, command, plugin-error, or other payload text.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$StatusFile,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode,
|
||||
[string]$ExpectedSessionId,
|
||||
[string[]]$ExpectedPlugin = @(),
|
||||
[switch]$ExpectNoEnteredWorld,
|
||||
[switch]$AllowPluginFailure,
|
||||
[switch]$AllowLoginCommandFailure,
|
||||
[switch]$AllowLauncherChildren,
|
||||
[string[]]$ForbiddenEnvironmentVariable = @(
|
||||
'ACDREAM_TEST_PASS',
|
||||
'ACDREAM_LA_GATE_SECRET'),
|
||||
[string]$ReportPath,
|
||||
[int]$ProcessExitWaitSeconds = 5
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||
throw 'Campaign LA status validation requires PowerShell 7 or newer.'
|
||||
}
|
||||
if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') {
|
||||
throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.'
|
||||
}
|
||||
if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) {
|
||||
$StatusFile = [IO.Path]::GetFullPath($StatusFile)
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) {
|
||||
throw "Status file does not exist: $StatusFile"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($ReportPath)) {
|
||||
$ReportPath = "$StatusFile.validation.json"
|
||||
}
|
||||
elseif (-not [IO.Path]::IsPathFullyQualified($ReportPath)) {
|
||||
$ReportPath = [IO.Path]::GetFullPath($ReportPath)
|
||||
}
|
||||
|
||||
$exactFields = @{
|
||||
started = @('v', 'e', 't', 'sessionId')
|
||||
connected = @('v', 'e', 't', 'sessionId')
|
||||
characterList = @('v', 'e', 't', 'sessionId', 'accountName', 'slotCount', 'characters')
|
||||
enteredWorld = @('v', 'e', 't', 'sessionId', 'characterId', 'characterName')
|
||||
pluginLoaded = @('v', 'e', 't', 'sessionId', 'plugin')
|
||||
pluginFailed = @('v', 'e', 't', 'sessionId', 'plugin', 'error')
|
||||
loginCommandFailed = @('v', 'e', 't', 'sessionId', 'commandIndex', 'command', 'error')
|
||||
disconnected = @('v', 'e', 't', 'sessionId', 'reason')
|
||||
exited = @('v', 'e', 't', 'sessionId', 'code', 'reason')
|
||||
}
|
||||
$failures = [Collections.Generic.List[string]]::new()
|
||||
$eventNames = [Collections.Generic.List[string]]::new()
|
||||
$loadedPlugins = [Collections.Generic.List[string]]::new()
|
||||
$sessionId = $null
|
||||
$previousTimestamp = [DateTimeOffset]::MinValue
|
||||
$terminalSeen = $false
|
||||
|
||||
function Get-Properties([Text.Json.JsonElement]$Element) {
|
||||
$properties = [Collections.Generic.List[object]]::new()
|
||||
foreach ($property in $Element.EnumerateObject()) { $properties.Add($property) }
|
||||
return @($properties)
|
||||
}
|
||||
|
||||
function Assert-String(
|
||||
[Text.Json.JsonElement]$Root,
|
||||
[string]$Name,
|
||||
[bool]$AllowEmpty = $false) {
|
||||
$value = $Root.GetProperty($Name)
|
||||
if ($value.ValueKind -ne [Text.Json.JsonValueKind]::String) {
|
||||
throw "field '$Name' is not a string"
|
||||
}
|
||||
$text = $value.GetString()
|
||||
if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($text)) {
|
||||
throw "field '$Name' is empty"
|
||||
}
|
||||
return $text
|
||||
}
|
||||
|
||||
function Assert-Int32([Text.Json.JsonElement]$Root, [string]$Name) {
|
||||
$value = $Root.GetProperty($Name)
|
||||
if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) {
|
||||
throw "field '$Name' is not a number"
|
||||
}
|
||||
return $value.GetInt32()
|
||||
}
|
||||
|
||||
function Assert-UInt32([Text.Json.JsonElement]$Root, [string]$Name) {
|
||||
$value = $Root.GetProperty($Name)
|
||||
if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) {
|
||||
throw "field '$Name' is not a number"
|
||||
}
|
||||
return $value.GetUInt32()
|
||||
}
|
||||
|
||||
$lines = @(Get-Content -LiteralPath $StatusFile)
|
||||
if ($lines.Count -eq 0) { $failures.Add('status stream is empty') }
|
||||
for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
|
||||
$lineNumber = $lineIndex + 1
|
||||
$line = $lines[$lineIndex]
|
||||
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||
$failures.Add("line $lineNumber is empty")
|
||||
continue
|
||||
}
|
||||
foreach ($variable in $ForbiddenEnvironmentVariable) {
|
||||
$secret = [Environment]::GetEnvironmentVariable($variable)
|
||||
if (-not [string]::IsNullOrEmpty($secret) -and
|
||||
$line.Contains($secret, [StringComparison]::Ordinal)) {
|
||||
$failures.Add("line $lineNumber contains the value of forbidden environment variable $variable")
|
||||
}
|
||||
}
|
||||
if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') {
|
||||
$failures.Add("line $lineNumber contains a credential-like JSON field")
|
||||
}
|
||||
|
||||
$document = $null
|
||||
try {
|
||||
$document = [Text.Json.JsonDocument]::Parse($line)
|
||||
$root = $document.RootElement
|
||||
if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
|
||||
throw 'root is not an object'
|
||||
}
|
||||
$properties = @(Get-Properties $root)
|
||||
$names = @($properties | ForEach-Object { $_.Name })
|
||||
if (@($names | Sort-Object -Unique).Count -ne $names.Count) {
|
||||
throw 'object contains duplicate fields'
|
||||
}
|
||||
$eventName = Assert-String $root 'e'
|
||||
if (-not $exactFields.ContainsKey($eventName)) {
|
||||
throw "event '$eventName' is not in the v1 vocabulary"
|
||||
}
|
||||
$expected = $exactFields[$eventName]
|
||||
if ($names.Count -ne $expected.Count -or
|
||||
[string]::Join("`n", $names) -cne [string]::Join("`n", $expected)) {
|
||||
throw "event '$eventName' fields/order are '$($names -join ',')'; expected '$($expected -join ',')'"
|
||||
}
|
||||
if ((Assert-Int32 $root 'v') -ne 1) { throw 'field v is not 1' }
|
||||
$timestampText = Assert-String $root 't'
|
||||
$timestamp = [DateTimeOffset]::MinValue
|
||||
if (-not [DateTimeOffset]::TryParseExact(
|
||||
$timestampText,
|
||||
'O',
|
||||
[Globalization.CultureInfo]::InvariantCulture,
|
||||
[Globalization.DateTimeStyles]::RoundtripKind,
|
||||
[ref]$timestamp) -or $timestamp.Offset -ne [TimeSpan]::Zero) {
|
||||
throw 'field t is not an exact UTC round-trip timestamp'
|
||||
}
|
||||
if ($timestamp -lt $previousTimestamp) {
|
||||
throw 'timestamp order moved backwards'
|
||||
}
|
||||
$previousTimestamp = $timestamp
|
||||
$lineSessionId = Assert-String $root 'sessionId'
|
||||
if ($null -eq $sessionId) { $sessionId = $lineSessionId }
|
||||
if ($lineSessionId -cne $sessionId) { throw 'sessionId changed within the stream' }
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and
|
||||
$lineSessionId -cne $ExpectedSessionId) {
|
||||
throw 'sessionId does not match -ExpectedSessionId'
|
||||
}
|
||||
if ($terminalSeen) { throw 'an event appears after terminal exited' }
|
||||
|
||||
switch ($eventName) {
|
||||
'characterList' {
|
||||
$null = Assert-String $root 'accountName' $true
|
||||
$slotCount = Assert-Int32 $root 'slotCount'
|
||||
if ($slotCount -lt 0) { throw 'slotCount is negative' }
|
||||
$characters = $root.GetProperty('characters')
|
||||
if ($characters.ValueKind -ne [Text.Json.JsonValueKind]::Array) {
|
||||
throw 'characters is not an array'
|
||||
}
|
||||
foreach ($character in $characters.EnumerateArray()) {
|
||||
if ($character.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
|
||||
throw 'a character is not an object'
|
||||
}
|
||||
$characterNames = @((Get-Properties $character) | ForEach-Object { $_.Name })
|
||||
$characterExpected = @('id', 'name', 'secondsGreyedOut')
|
||||
if ([string]::Join("`n", $characterNames) -cne
|
||||
[string]::Join("`n", $characterExpected)) {
|
||||
throw 'a character fields/order is not id,name,secondsGreyedOut'
|
||||
}
|
||||
$null = Assert-UInt32 $character 'id'
|
||||
$null = Assert-String $character 'name'
|
||||
$null = Assert-UInt32 $character 'secondsGreyedOut'
|
||||
}
|
||||
}
|
||||
'enteredWorld' {
|
||||
$null = Assert-UInt32 $root 'characterId'
|
||||
$null = Assert-String $root 'characterName'
|
||||
}
|
||||
'pluginLoaded' {
|
||||
$loadedPlugins.Add((Assert-String $root 'plugin'))
|
||||
}
|
||||
'pluginFailed' {
|
||||
$null = Assert-String $root 'plugin'
|
||||
$null = Assert-String $root 'error'
|
||||
if (-not $AllowPluginFailure) { throw 'pluginFailed is not allowed for this row' }
|
||||
}
|
||||
'loginCommandFailed' {
|
||||
if ((Assert-Int32 $root 'commandIndex') -lt 0) {
|
||||
throw 'commandIndex is negative'
|
||||
}
|
||||
$null = Assert-String $root 'command' $true
|
||||
$null = Assert-String $root 'error'
|
||||
if (-not $AllowLoginCommandFailure) {
|
||||
throw 'loginCommandFailed is not allowed for this row'
|
||||
}
|
||||
}
|
||||
'disconnected' { $null = Assert-String $root 'reason' }
|
||||
'exited' {
|
||||
$code = Assert-Int32 $root 'code'
|
||||
$reason = Assert-String $root 'reason'
|
||||
if ($code -ne 0) { throw "terminal exit code is $code, expected 0" }
|
||||
$expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' }
|
||||
if ($reason -cne $expectedReason) {
|
||||
throw "terminal reason is '$reason', expected '$expectedReason'"
|
||||
}
|
||||
$terminalSeen = $true
|
||||
}
|
||||
}
|
||||
$eventNames.Add($eventName)
|
||||
}
|
||||
catch {
|
||||
$failures.Add("line ${lineNumber}: $($_.Exception.Message)")
|
||||
}
|
||||
finally { if ($null -ne $document) { $document.Dispose() } }
|
||||
}
|
||||
|
||||
function Require-Count([string]$EventName, [int]$Count) {
|
||||
$actual = @($eventNames | Where-Object { $_ -ceq $EventName }).Count
|
||||
if ($actual -ne $Count) {
|
||||
$failures.Add("event '$EventName' count is $actual, expected $Count")
|
||||
}
|
||||
}
|
||||
function First-Index([string]$EventName) {
|
||||
for ($index = 0; $index -lt $eventNames.Count; $index++) {
|
||||
if ($eventNames[$index] -ceq $EventName) { return $index }
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
Require-Count 'started' 1
|
||||
Require-Count 'connected' 1
|
||||
Require-Count 'characterList' 1
|
||||
Require-Count 'disconnected' 1
|
||||
Require-Count 'exited' 1
|
||||
$expectEnteredWorld = $Mode -ne 'probe' -and -not $ExpectNoEnteredWorld
|
||||
Require-Count 'enteredWorld' $(if ($expectEnteredWorld) { 1 } else { 0 })
|
||||
if ($eventNames.Count -gt 0 -and $eventNames[0] -cne 'started') {
|
||||
$failures.Add('started is not the first event')
|
||||
}
|
||||
if ($eventNames.Count -gt 0 -and $eventNames[-1] -cne 'exited') {
|
||||
$failures.Add('exited is not the final event')
|
||||
}
|
||||
$orderedRequired = if (-not $expectEnteredWorld) {
|
||||
@('started', 'connected', 'characterList', 'disconnected', 'exited')
|
||||
} else {
|
||||
@('started', 'connected', 'characterList', 'enteredWorld', 'disconnected', 'exited')
|
||||
}
|
||||
$last = -1
|
||||
foreach ($name in $orderedRequired) {
|
||||
$next = First-Index $name
|
||||
if ($next -ge 0 -and $next -le $last) {
|
||||
$failures.Add("event '$name' is out of lifecycle order")
|
||||
}
|
||||
$last = $next
|
||||
}
|
||||
$connectedIndex = First-Index 'connected'
|
||||
foreach ($index in 0..([Math]::Max(0, $eventNames.Count - 1))) {
|
||||
if ($eventNames.Count -eq 0) { break }
|
||||
if ($eventNames[$index] -in @('pluginLoaded', 'pluginFailed') -and
|
||||
($index -le 0 -or $index -ge $connectedIndex)) {
|
||||
$failures.Add("plugin event at index $index is outside started-to-connected startup")
|
||||
}
|
||||
}
|
||||
foreach ($plugin in $ExpectedPlugin) {
|
||||
if (-not ($loadedPlugins -ccontains $plugin)) {
|
||||
$failures.Add("expected plugin '$plugin' did not emit pluginLoaded")
|
||||
}
|
||||
}
|
||||
$expectedPluginSet = @($ExpectedPlugin | Sort-Object -Unique)
|
||||
$loadedPluginSet = @($loadedPlugins | Sort-Object -Unique)
|
||||
if ($loadedPlugins.Count -ne $loadedPluginSet.Count) {
|
||||
$failures.Add('a plugin emitted pluginLoaded more than once')
|
||||
}
|
||||
if ([string]::Join("`n", $loadedPluginSet) -cne
|
||||
[string]::Join("`n", $expectedPluginSet)) {
|
||||
$failures.Add(
|
||||
"loaded plugin set has $($loadedPluginSet.Count) member(s), expected $($expectedPluginSet.Count)")
|
||||
}
|
||||
$enteredWorldIndex = First-Index 'enteredWorld'
|
||||
for ($index = 0; $index -lt $eventNames.Count; $index++) {
|
||||
if ($eventNames[$index] -ceq 'loginCommandFailed' -and
|
||||
($enteredWorldIndex -lt 0 -or $index -le $enteredWorldIndex)) {
|
||||
$failures.Add("loginCommandFailed at index $index did not follow enteredWorld")
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $AllowLauncherChildren) {
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
|
||||
do {
|
||||
$children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue)
|
||||
if ($children.Count -eq 0) { break }
|
||||
Start-Sleep -Milliseconds 100
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
if ($children.Count -gt 0) {
|
||||
$failures.Add(
|
||||
"launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')")
|
||||
}
|
||||
}
|
||||
|
||||
$reportDirectory = Split-Path -Parent $ReportPath
|
||||
if (-not [string]::IsNullOrEmpty($reportDirectory)) {
|
||||
$null = New-Item -ItemType Directory -Force -Path $reportDirectory
|
||||
}
|
||||
$report = [ordered]@{
|
||||
schemaVersion = 1
|
||||
kind = 'campaign-la-session-status-validation'
|
||||
success = ($failures.Count -eq 0)
|
||||
mode = $Mode
|
||||
enteredWorldExpected = $expectEnteredWorld
|
||||
statusFile = [IO.Path]::GetFileName($StatusFile)
|
||||
statusSize = (Get-Item -LiteralPath $StatusFile).Length
|
||||
statusSha256 = (Get-FileHash -LiteralPath $StatusFile -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
lineCount = $lines.Count
|
||||
eventNames = @($eventNames)
|
||||
loadedPluginCount = $loadedPlugins.Count
|
||||
terminalObserved = $terminalSeen
|
||||
launcherChildrenAllowed = [bool]$AllowLauncherChildren
|
||||
failures = @($failures)
|
||||
validatedUtc = [DateTime]::UtcNow.ToString('O')
|
||||
}
|
||||
$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM
|
||||
Write-Host "Campaign LA status validation report: $ReportPath"
|
||||
if (-not $report.success) {
|
||||
$failures | ForEach-Object { Write-Error $_ }
|
||||
exit 1
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue