feat(scanner): complete localized artifact quality checkpoint
This commit is contained in:
+262
-25
@@ -25,22 +25,44 @@ function Invoke-DevJson([string]$Path) {
|
||||
try {
|
||||
Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec 90
|
||||
} catch {
|
||||
$response = $_.Exception.Response
|
||||
if ($response) {
|
||||
$stream = $response.GetResponseStream()
|
||||
if ($stream) {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
$body = $reader.ReadToEnd()
|
||||
if (-not [string]::IsNullOrWhiteSpace($body)) {
|
||||
try {
|
||||
return $body | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $body"
|
||||
}
|
||||
}
|
||||
$requestError = $_
|
||||
$errorBody = [string]$requestError.ErrorDetails.Message
|
||||
if (-not [string]::IsNullOrWhiteSpace($errorBody)) {
|
||||
try {
|
||||
return $errorBody | ConvertFrom-Json
|
||||
} catch {
|
||||
# PowerShell 7 can put a generic message here while the JSON body remains on Response.Content.
|
||||
}
|
||||
}
|
||||
throw
|
||||
|
||||
$body = ""
|
||||
$response = $requestError.Exception.Response
|
||||
if ($response) {
|
||||
if ($response.PSObject.Methods.Name -contains "GetResponseStream") {
|
||||
$stream = $response.GetResponseStream()
|
||||
if ($stream) {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
try {
|
||||
$body = $reader.ReadToEnd()
|
||||
} finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
}
|
||||
} elseif ($response.Content) {
|
||||
$body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($body)) {
|
||||
try {
|
||||
return $body | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $body"
|
||||
}
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($errorBody)) {
|
||||
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $errorBody"
|
||||
}
|
||||
throw $requestError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +73,15 @@ function Save-Json([string]$Name, [object]$Payload) {
|
||||
}
|
||||
|
||||
function Test-ProbeSucceeded([object]$ProbePayload) {
|
||||
if ($ProbePayload.ok) { return $true }
|
||||
if ($ProbePayload.changed) { return $true }
|
||||
if ($ProbePayload.click -and $ProbePayload.click.clicked -and $ProbePayload.click.moved -and -not $ProbePayload.click.inputBlocked) { return $true }
|
||||
return $false
|
||||
return [bool](
|
||||
$ProbePayload.ok `
|
||||
-and $ProbePayload.changed `
|
||||
-and $ProbePayload.click `
|
||||
-and $ProbePayload.click.ok `
|
||||
-and $ProbePayload.click.clicked `
|
||||
-and $ProbePayload.click.moved `
|
||||
-and -not $ProbePayload.click.inputBlocked
|
||||
)
|
||||
}
|
||||
|
||||
function Get-NativeScanner([object]$StatusPayload) {
|
||||
@@ -63,6 +90,31 @@ function Get-NativeScanner([object]$StatusPayload) {
|
||||
return $null
|
||||
}
|
||||
|
||||
function Stop-NativeScannerAfterError([int]$WaitSeconds = 10) {
|
||||
$stop = Invoke-DevJson "/scanner/stop"
|
||||
Save-Json "99-native-stop-after-error" $stop | Out-Null
|
||||
$lastPayload = $stop
|
||||
$lastScanner = Get-NativeScanner $stop
|
||||
$deadline = (Get-Date).AddSeconds($WaitSeconds)
|
||||
|
||||
while (($null -eq $lastScanner -or [bool]$lastScanner.running) -and (Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
$lastPayload = Invoke-DevJson "/scanner/status"
|
||||
$lastScanner = Get-NativeScanner $lastPayload
|
||||
}
|
||||
|
||||
Save-Json "99-native-stop-final-status" $lastPayload | Out-Null
|
||||
$stopped = $null -ne $lastScanner -and -not [bool]$lastScanner.running
|
||||
return [pscustomobject]@{
|
||||
requested = $true
|
||||
endpointOk = [bool]$stop.ok
|
||||
stopped = [bool]$stopped
|
||||
ok = [bool]($stop.ok -and $stopped)
|
||||
status = if ($lastScanner) { [string]$lastScanner.status } else { "missing" }
|
||||
runId = if ($lastScanner) { [string]$lastScanner.runId } else { "" }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ExpectedAppSignature() {
|
||||
$mainPath = Join-Path (Resolve-Path -LiteralPath ".").Path "electron\main.ts"
|
||||
$mainSource = Get-Content -LiteralPath $mainPath -Raw
|
||||
@@ -97,9 +149,16 @@ $summary = [ordered]@{
|
||||
finalStatus = $null
|
||||
process = $null
|
||||
results = $null
|
||||
timing = $null
|
||||
contract = $null
|
||||
cleanupStop = $null
|
||||
errors = @()
|
||||
}
|
||||
|
||||
$scannerStartAttempted = $false
|
||||
$scannerFinished = $false
|
||||
$startedRunId = ""
|
||||
|
||||
try {
|
||||
$expectedSignature = Get-ExpectedAppSignature
|
||||
$summary.expectedSignature = $expectedSignature
|
||||
@@ -109,7 +168,7 @@ try {
|
||||
signature = if ($health.appBuild) { [string]$health.appBuild.signature } else { "" }
|
||||
}
|
||||
Save-Json "01-health" $health | Out-Null
|
||||
if (-not $health.appBuild -or [string]$health.appBuild.signature -ne $expectedSignature) {
|
||||
if (-not $health.ok -or -not $health.appBuild -or [string]$health.appBuild.signature -ne $expectedSignature) {
|
||||
throw "Dev endpoint is stale: /health signature '$($summary.health.signature)' does not match source '$expectedSignature'. Restart the elevated app."
|
||||
}
|
||||
|
||||
@@ -121,7 +180,7 @@ try {
|
||||
valid = [bool]$data.status.valid
|
||||
}
|
||||
Save-Json "02-native-data" $data | Out-Null
|
||||
if (-not $data.ok) {
|
||||
if (-not $data.ok -or -not $data.status -or -not [bool]$data.status.valid) {
|
||||
throw "Native IK data check failed."
|
||||
}
|
||||
|
||||
@@ -142,7 +201,15 @@ try {
|
||||
blockReason = [string]$preflight.status.blockReason
|
||||
}
|
||||
Save-Json "03-native-preflight" $preflight | Out-Null
|
||||
if (-not $preflight.ok) {
|
||||
if (
|
||||
-not $preflight.ok `
|
||||
-or -not $preflight.status `
|
||||
-or -not [bool]$preflight.status.ready `
|
||||
-or -not [bool]$preflight.status.categoryReady `
|
||||
-or [string]$preflight.status.category -ne $Category `
|
||||
-or -not [bool]$preflight.status.genshinFound `
|
||||
-or -not [bool]$preflight.status.isSixteenNine
|
||||
) {
|
||||
$preflightReason = if ($preflight.status.blockReason) { [string]$preflight.status.blockReason } else { "unknown preflight block reason" }
|
||||
throw "Native scanner preflight is not ready for category '$Category': $preflightReason"
|
||||
}
|
||||
@@ -160,12 +227,24 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
$scannerStartAttempted = $true
|
||||
$start = Invoke-DevJson "/scanner/start?limit=$safeLimit&category=$Category"
|
||||
Save-Json "05-native-start" $start | Out-Null
|
||||
$startedScanner = Get-NativeScanner $start
|
||||
if ($null -eq $startedScanner) {
|
||||
if (-not $start.ok -or $null -eq $startedScanner) {
|
||||
throw "Native scanner start returned no scanner payload."
|
||||
}
|
||||
$startedRunId = [string]$startedScanner.runId
|
||||
if ([string]::IsNullOrWhiteSpace($startedRunId)) {
|
||||
throw "Native scanner start returned no runId."
|
||||
}
|
||||
if (-not [bool]$startedScanner.running) {
|
||||
$scannerFinished = $true
|
||||
throw "Native scanner did not enter running state: status='$($startedScanner.status)', message='$($startedScanner.message)'."
|
||||
}
|
||||
if ([int]$startedScanner.target -ne $safeLimit -or [string]$startedScanner.category -ne $Category) {
|
||||
throw "Native scanner start contract mismatch: target=$($startedScanner.target), category='$($startedScanner.category)', expected=$safeLimit/$Category."
|
||||
}
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$polls = @()
|
||||
@@ -195,27 +274,61 @@ try {
|
||||
}
|
||||
|
||||
$summary.finalStatus = @{
|
||||
runId = [string]$finalScanner.runId
|
||||
category = [string]$finalScanner.category
|
||||
target = [int]$finalScanner.target
|
||||
status = [string]$finalScanner.status
|
||||
running = [bool]$finalScanner.running
|
||||
runDir = [string]$finalScanner.runDir
|
||||
captured = [int]$finalScanner.captured
|
||||
queued = [int]$finalScanner.queued
|
||||
clicked = [int]$finalScanner.clicked
|
||||
pages = [int]$finalScanner.pages
|
||||
initialTopResetMs = [int]$finalScanner.initialTopResetMs
|
||||
initialTopResetCompleted = [bool]$finalScanner.initialTopResetCompleted
|
||||
activeMs = [int]$finalScanner.activeMs
|
||||
totalMs = [int]$finalScanner.totalMs
|
||||
message = [string]$finalScanner.message
|
||||
}
|
||||
|
||||
if ($finalScanner.running) {
|
||||
throw "Native scanner did not finish before timeout."
|
||||
}
|
||||
if ([string]$finalScanner.runId -ne $startedRunId) {
|
||||
throw "Native scanner final status belongs to run '$($finalScanner.runId)', expected '$startedRunId'."
|
||||
}
|
||||
$scannerFinished = $true
|
||||
if ([string]$finalScanner.status -ne "done") {
|
||||
throw "Native scanner finished with status '$($finalScanner.status)': $($finalScanner.message)"
|
||||
}
|
||||
if ([int]$finalScanner.captured -lt $safeLimit) {
|
||||
throw "Native scanner captured $($finalScanner.captured), expected at least $safeLimit."
|
||||
if ([int]$finalScanner.target -ne $safeLimit -or [string]$finalScanner.category -ne $Category) {
|
||||
throw "Native scanner final contract mismatch: target=$($finalScanner.target), category='$($finalScanner.category)', expected=$safeLimit/$Category."
|
||||
}
|
||||
if ([int]$finalScanner.captured -ne $safeLimit) {
|
||||
throw "Native scanner captured $($finalScanner.captured), expected exactly $safeLimit."
|
||||
}
|
||||
if ([int]$finalScanner.queued -ne $safeLimit) {
|
||||
throw "Native scanner queued $($finalScanner.queued), expected exactly $safeLimit."
|
||||
}
|
||||
if ([int]$finalScanner.clicked -ne $safeLimit) {
|
||||
throw "Native scanner clicked $($finalScanner.clicked), expected exactly $safeLimit."
|
||||
}
|
||||
if (-not [bool]$finalScanner.initialTopResetCompleted) {
|
||||
throw "Native scanner did not confirm the bounded initial inventory top reset."
|
||||
}
|
||||
if ([int]$finalScanner.initialTopResetMs -le 0) {
|
||||
throw "Native scanner reported invalid initialTopResetMs=$($finalScanner.initialTopResetMs)."
|
||||
}
|
||||
if ([int]$finalScanner.totalMs -lt [int]$finalScanner.activeMs -or [int]$finalScanner.totalMs -lt [int]$finalScanner.initialTopResetMs) {
|
||||
throw "Native scanner timing contract is invalid: reset=$($finalScanner.initialTopResetMs), active=$($finalScanner.activeMs), total=$($finalScanner.totalMs)."
|
||||
}
|
||||
|
||||
$runDirParam = UriEscape([string]$finalScanner.runDir)
|
||||
if ([string]::IsNullOrWhiteSpace([string]$finalScanner.runDir) -or -not (Test-Path -LiteralPath ([string]$finalScanner.runDir) -PathType Container)) {
|
||||
throw "Native scanner returned an invalid run directory '$($finalScanner.runDir)'."
|
||||
}
|
||||
$resolvedNativeRunDir = (Resolve-Path -LiteralPath ([string]$finalScanner.runDir)).Path
|
||||
|
||||
$runDirParam = UriEscape($resolvedNativeRunDir)
|
||||
$persistParam = if ($Persist) { "1" } else { "0" }
|
||||
$process = Invoke-DevJson "/scanner/native/process?runDir=$runDirParam&limit=$safeLimit&persist=$persistParam"
|
||||
Save-Json "08-native-process" $process | Out-Null
|
||||
@@ -233,6 +346,36 @@ try {
|
||||
if (-not $process.ok) {
|
||||
throw "Native post-capture processing failed."
|
||||
}
|
||||
if ([int]$process.status.processed -ne $safeLimit) {
|
||||
throw "Native processing handled $($process.status.processed), expected exactly $safeLimit."
|
||||
}
|
||||
if ([int]$process.status.parsed -ne $safeLimit) {
|
||||
throw "Native processing parsed $($process.status.parsed), expected exactly $safeLimit."
|
||||
}
|
||||
if ([int]$process.status.errors -ne 0) {
|
||||
throw "Native processing reported $($process.status.errors) errors."
|
||||
}
|
||||
$processResults = @($process.status.results)
|
||||
$actualParsed = @($processResults | Where-Object { [bool]$_.parsed }).Count
|
||||
$actualReview = @($processResults | Where-Object { [bool]$_.needsReview }).Count
|
||||
$actualErrors = @($processResults | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.error) }).Count
|
||||
if ($processResults.Count -ne $safeLimit) {
|
||||
throw "Native processing returned $($processResults.Count) result rows, expected exactly $safeLimit."
|
||||
}
|
||||
if ($actualParsed -ne [int]$process.status.parsed -or $actualReview -ne [int]$process.status.review -or $actualErrors -ne [int]$process.status.errors) {
|
||||
throw "Native processing summary does not match result rows (parsed=$($process.status.parsed)/$actualParsed, review=$($process.status.review)/$actualReview, errors=$($process.status.errors)/$actualErrors)."
|
||||
}
|
||||
if ([int]$process.status.review -lt 0 -or [int]$process.status.review -gt $safeLimit) {
|
||||
throw "Native processing returned invalid review count $($process.status.review) for limit $safeLimit."
|
||||
}
|
||||
$reviewRate = if ($safeLimit -gt 0) { [double]$process.status.review / $safeLimit } else { 0 }
|
||||
if ($reviewRate -gt 0.15) {
|
||||
throw "Native processing review rate $([Math]::Round($reviewRate * 100, 1))% exceeds 15%."
|
||||
}
|
||||
$persistedProcessRows = @($processResults | Where-Object { [bool]$_.persisted })
|
||||
if (-not $Persist -and ([bool]$process.status.persisted -or [int]$process.status.stored -ne 0 -or $persistedProcessRows.Count -ne 0)) {
|
||||
throw "Dry native smoke wrote to the store (stored=$($process.status.stored), persisted=$($process.status.persisted), persistedRows=$($persistedProcessRows.Count))."
|
||||
}
|
||||
|
||||
$results = Invoke-DevJson "/scanner/native/results?runDir=$runDirParam&limit=$safeLimit"
|
||||
Save-Json "09-native-results" $results | Out-Null
|
||||
@@ -244,10 +387,104 @@ try {
|
||||
if (-not $results.ok) {
|
||||
throw "Native scan results could not be loaded."
|
||||
}
|
||||
$loadedResults = @($results.status.results)
|
||||
if ([int]$results.status.total -ne $safeLimit -or $loadedResults.Count -ne $safeLimit) {
|
||||
throw "Native result contract mismatch: total=$($results.status.total), loaded=$($loadedResults.Count), expected=$safeLimit."
|
||||
}
|
||||
$timing = $results.status.timing
|
||||
$summary.timing = @{
|
||||
requestStartedAt = if ($timing) { [string]$timing.requestStartedAt } else { "" }
|
||||
captureCompletedAt = if ($timing) { [string]$timing.captureCompletedAt } else { "" }
|
||||
processingCompletedAt = if ($timing) { [string]$timing.processingCompletedAt } else { "" }
|
||||
resultsDurableAt = if ($timing) { [string]$timing.resultsDurableAt } else { "" }
|
||||
resultsReconciledAt = if ($timing) { [string]$timing.resultsReconciledAt } else { "" }
|
||||
requestToResultsDurableMs = if ($timing) { [int64]$timing.requestToResultsDurableMs } else { -1 }
|
||||
requestToResultsReconciledMs = if ($timing) { [int64]$timing.requestToResultsReconciledMs } else { -1 }
|
||||
}
|
||||
$requiredTimingFields = @(
|
||||
"requestStartedAt",
|
||||
"captureCompletedAt",
|
||||
"processingCompletedAt",
|
||||
"resultsDurableAt",
|
||||
"resultsReconciledAt"
|
||||
)
|
||||
$missingTimingFields = @($requiredTimingFields | Where-Object { [string]::IsNullOrWhiteSpace([string]$timing.$_) })
|
||||
if ($null -eq $timing -or $missingTimingFields.Count -ne 0) {
|
||||
throw "Native run timing contract is incomplete: missing=$($missingTimingFields -join ', ')."
|
||||
}
|
||||
if ([int64]$timing.requestToResultsDurableMs -lt 0 -or [int64]$timing.requestToResultsReconciledMs -lt [int64]$timing.requestToResultsDurableMs) {
|
||||
throw "Native run timing contract has invalid derived durations: durable=$($timing.requestToResultsDurableMs), reconciled=$($timing.requestToResultsReconciledMs)."
|
||||
}
|
||||
$persistedLoadedRows = @($loadedResults | Where-Object {
|
||||
[bool]$_.persistedArtifact -or -not [string]::IsNullOrWhiteSpace([string]$_.artifactRecordId)
|
||||
})
|
||||
if (-not $Persist -and $persistedLoadedRows.Count -ne 0) {
|
||||
throw "Dry native smoke loaded $($persistedLoadedRows.Count) result rows marked as persisted."
|
||||
}
|
||||
|
||||
$jobsPath = Join-Path $resolvedNativeRunDir "capture-jobs.jsonl"
|
||||
$pngFiles = @(Get-ChildItem -LiteralPath $resolvedNativeRunDir -Filter "artifact-*.png" -File)
|
||||
$jobs = @(
|
||||
Get-Content -LiteralPath $jobsPath |
|
||||
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
||||
ForEach-Object { $_ | ConvertFrom-Json }
|
||||
)
|
||||
$expectedPngNames = @(1..$safeLimit | ForEach-Object { "artifact-{0:D4}.png" -f $_ })
|
||||
$actualPngNames = @($pngFiles.Name | Sort-Object)
|
||||
$pngNameMismatches = @(Compare-Object -ReferenceObject $expectedPngNames -DifferenceObject $actualPngNames)
|
||||
$badSequenceJobs = @($jobs | Where-Object { [int]$_.sequence -lt 1 -or [int]$_.sequence -gt $safeLimit })
|
||||
$duplicateSequenceJobs = @($jobs | Group-Object sequence | Where-Object { $_.Count -ne 1 })
|
||||
$badClickJobs = @($jobs | Where-Object { [int]$_.clickEventsSent -ne 2 })
|
||||
$badPathJobs = @($jobs | Where-Object {
|
||||
$expectedName = "artifact-{0:D4}.png" -f [int]$_.sequence
|
||||
$expectedAbsolutePath = [System.IO.Path]::GetFullPath((Join-Path $resolvedNativeRunDir $expectedName))
|
||||
[System.IO.Path]::GetFileName([string]$_.relativePath) -ne $expectedName `
|
||||
-or [System.IO.Path]::GetFullPath([string]$_.absolutePath) -ne $expectedAbsolutePath
|
||||
})
|
||||
$emptyPngFiles = @($pngFiles | Where-Object { $_.Length -le 0 })
|
||||
$summary.contract = @{
|
||||
jobs = $jobs.Count
|
||||
pngs = $pngFiles.Count
|
||||
pngNameMismatches = $pngNameMismatches.Count
|
||||
badSequenceJobs = $badSequenceJobs.Count
|
||||
duplicateSequenceJobs = $duplicateSequenceJobs.Count
|
||||
badClickJobs = $badClickJobs.Count
|
||||
badPathJobs = $badPathJobs.Count
|
||||
emptyPngs = $emptyPngFiles.Count
|
||||
persistedProcessRows = $persistedProcessRows.Count
|
||||
persistedLoadedRows = $persistedLoadedRows.Count
|
||||
reviewRatePct = [Math]::Round($reviewRate * 100, 1)
|
||||
}
|
||||
if ($jobs.Count -ne $safeLimit -or $pngFiles.Count -ne $safeLimit) {
|
||||
throw "Native file contract mismatch: jobs=$($jobs.Count), pngs=$($pngFiles.Count), expected=$safeLimit."
|
||||
}
|
||||
if ($pngNameMismatches.Count -ne 0 -or $emptyPngFiles.Count -ne 0) {
|
||||
throw "Native PNG contract mismatch: names=$($pngNameMismatches.Count), empty=$($emptyPngFiles.Count)."
|
||||
}
|
||||
if ($badSequenceJobs.Count -ne 0 -or $duplicateSequenceJobs.Count -ne 0 -or $badPathJobs.Count -ne 0) {
|
||||
throw "Native job contract mismatch: invalidSequences=$($badSequenceJobs.Count), duplicateSequences=$($duplicateSequenceJobs.Count), invalidPaths=$($badPathJobs.Count)."
|
||||
}
|
||||
if ($badClickJobs.Count -ne 0) {
|
||||
throw "Native run contains $($badClickJobs.Count) jobs without exactly two click events."
|
||||
}
|
||||
|
||||
$summary.ok = $true
|
||||
} catch {
|
||||
$summary.errors += [string]$_.Exception.Message
|
||||
if ($scannerStartAttempted -and -not $scannerFinished) {
|
||||
try {
|
||||
$summary.cleanupStop = Stop-NativeScannerAfterError
|
||||
if (-not $summary.cleanupStop.ok) {
|
||||
$summary.errors += "Native scanner cleanup stop did not reach a confirmed stopped state."
|
||||
}
|
||||
} catch {
|
||||
$summary.cleanupStop = @{
|
||||
requested = $true
|
||||
ok = $false
|
||||
error = [string]$_.Exception.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
throw
|
||||
} finally {
|
||||
$summaryPath = Save-Json "native-live-smoke-summary" ([pscustomobject]$summary)
|
||||
|
||||
Reference in New Issue
Block a user