Files
StellarX-kaifa/scripts/analyze-visual-probe.ps1
T

411 lines
19 KiB
PowerShell

param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$SessionId,
[Parameter(Mandatory = $true)]
[string]$StartedAtUtc,
[int]$ProbeExitCode = 0
)
$ErrorActionPreference = 'Stop'
function Get-FailureCategory {
param([object]$Case)
if (-not $Case.evidenceComplete) { return 'evidence_missing' }
if (-not $Case.targetChanged) { return 'surface_unreachable' }
if (-not $Case.nonTargetUnchanged) { return 'surface_cross_write' }
if (-not $Case.gdiStateRestored) { return 'gdi_state_not_restored' }
if (-not $Case.wideContentPreservedAfterEasyXFollowUp) {
return 'measurement_relation_invalid'
}
return $null
}
function New-WideTextCases {
param([object]$Raw, [object[]]$EvidenceFiles)
$items = New-Object System.Collections.Generic.List[object]
foreach ($case in @($Raw.result.cases)) {
$caseFailure = Get-FailureCategory -Case $case
$caseEvidence = @($EvidenceFiles | Where-Object {
$_.path -like "evidence/$($case.testCaseId)_*"
})
$phases = @('after_draw')
if ($case.batchState -eq 'inside') { $phases += @('after_flush', 'after_end') }
foreach ($text in @($case.textTests)) {
$textFailure = $caseFailure
if (-not $textFailure -and (-not $text.isValidUtf16 -or
-not $text.getTextExtentPoint32W -or -not $text.textOutW -or
[int]$text.drawTextWHeight -le 0)) {
$textFailure = 'measurement_relation_invalid'
}
foreach ($phase in $phases) {
$fingerprint = switch ($phase) {
'after_draw' { $case.observations.afterDraw; break }
'after_flush' { $case.observations.afterFlush; break }
'after_end' { $case.observations.afterEnd; break }
}
$status = if ($textFailure) { 'fail' } else { 'pass' }
$items.Add([pscustomobject][ordered]@{
testCaseId = "$($case.testCaseId)/$($text.testStringId)/$phase"
testStringId = $text.testStringId
codePoints = @($text.codePoints)
utf16Units = @($text.utf16Units)
surfaceId = $case.surfaceId
batchState = $case.batchState
observationPhase = $phase
hdc = $fingerprint.hdc
hbitmap = $fingerprint.hbitmap
hfont = $fingerprint.hfont
bitmapWidth = $fingerprint.bitmapWidth
bitmapHeight = $fingerprint.bitmapHeight
planes = $fingerprint.planes
bitsPerPixel = $fingerprint.bitsPerPixel
stride = $fingerprint.stride
dibSection = $fingerprint.dibSection
logFont = $fingerprint.logFont
gdiStateBefore = $case.before.gdiState
gdiStateAfter = $fingerprint.gdiState
apiResults = [pscustomobject][ordered]@{
getTextExtentPoint32W = $text.getTextExtentPoint32W
textOutW = $text.textOutW
drawTextWHeight = $text.drawTextWHeight
}
logicalExtent = $text.logicalExtent
textOutInkBounds = $text.textOutInkBounds
drawTextInkBounds = $text.drawTextInkBounds
targetPixelsBefore = $case.before.pixelDigest
targetPixelsAfter = $fingerprint.pixelDigest
controlPixelsBefore = $case.nonTargetBefore.pixelDigest
controlPixelsAfter = $case.nonTargetAfterDraw.pixelDigest
easyXFollowUp = [pscustomobject][ordered]@{
wideContentPreserved = $case.wideContentPreservedAfterEasyXFollowUp
surfaceFingerprint = $case.observations.afterEasyXFollowUp
}
automatedStatus = $status
manualStatus = 'not_reviewed'
failureCategory = $textFailure
evidenceFiles = $caseEvidence
})
}
}
}
return $items.ToArray()
}
function New-DpiCoordinateCases {
param([object]$Raw, [object[]]$EvidenceFiles)
$failure = $null
if ($EvidenceFiles.Count -lt @($Raw.evidenceFiles).Count) { $failure = 'evidence_missing' }
elseif (-not $Raw.result.gdiStateRestored) { $failure = 'gdi_state_not_restored' }
elseif (-not $Raw.result.roundTripsExact) { $failure = 'coordinate_transform_invalid' }
elseif (-not $Raw.result.gdiExtentSuccess -or -not $Raw.result.gdiTextSuccess) {
$failure = 'measurement_relation_invalid'
}
$status = if ($failure) { 'fail' } else { 'pass' }
$decision = if ($Raw.result.implicitScalingObserved) {
'requires_explicit_transform_validation'
}
else {
'direct_hwnd_relative_coordinates_supported_at_observed_dpi'
}
return @([pscustomobject][ordered]@{
testCaseId = 'dpi-coordinate-current-environment'
surfaceId = 'main'
batchState = $Raw.result.batchState
observationPhase = 'after_end'
dpiAwareness = [pscustomobject][ordered]@{
process = $Raw.environment.processAwareness
thread = $Raw.environment.threadAwareness
window = $Raw.environment.windowAwareness
}
windowDpi = $Raw.environment.dpiForWindow
clientRect = $Raw.environment.clientRect
windowScreenRect = $Raw.environment.windowRect
surfaceSize = [pscustomobject]@{ width = 960; height = 720 }
surfaceFingerprint = $Raw.result.surfaceFingerprintAfterDraw
surfaceBounds = $Raw.result.surfaceBounds
clientBounds = $Raw.result.clientBounds
coordinateRoundTrips = @($Raw.result.coordinateRoundTrips)
surfaceMarkerSizesAgree = $Raw.result.surfaceMarkerSizesAgree
clientMatchesSurface = $Raw.result.clientMatchesSurface
implicitScalingObserved = $Raw.result.implicitScalingObserved
coordinateDecision = $decision
automatedStatus = $status
manualStatus = 'not_reviewed'
failureCategory = $failure
evidenceFiles = $EvidenceFiles
})
}
function New-ImePositionCases {
param([object]$Raw, [object[]]$EvidenceFiles)
$items = New-Object System.Collections.Generic.List[object]
foreach ($anchor in @($Raw.result.anchors)) {
$events = @($Raw.result.imeEvents | Where-Object {
$_.syntheticAnchorId -eq $anchor.syntheticAnchorId -and $_.apiAttempted
})
$successful = @($events | Where-Object {
$_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult
})
$preconfigureEvents = @($events | Where-Object {
$_.positionTrigger -eq 'anchor_change' -or $_.stage -eq 'anchor_preconfigure'
})
$successfulPreconfigure = @($preconfigureEvents | Where-Object {
$_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult
})
$lifecycleEvents = @($events | Where-Object {
$_.positionTrigger -eq 'ime_lifecycle' -or
$_.stage -eq 'ime_start' -or $_.stage -eq 'ime_composition'
})
$successfulLifecycle = @($lifecycleEvents | Where-Object {
$_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult
})
$latest = $events | Select-Object -Last 1
$failure = if ($successful.Count -gt 0) { $null } else { 'ime_position_invalid' }
$status = if ($failure) { 'fail' } else { 'pass' }
$anchorEvidence = @($EvidenceFiles | Where-Object {
$_.path -like "evidence/$($anchor.syntheticAnchorId)_*" -or
$_.path -like "manual-evidence/$($anchor.syntheticAnchorId)_*"
})
$items.Add([pscustomobject][ordered]@{
testCaseId = "ime-position/$($anchor.syntheticAnchorId)"
syntheticAnchorId = $anchor.syntheticAnchorId
anchorCoordinates = [pscustomobject][ordered]@{
controlOrigin = $anchor.controlOrigin
controlLocalCaretTopLeft = $anchor.controlLocalCaretTopLeft
controlLocalCaretBottom = $anchor.controlLocalCaretBottom
controlLocalLineRect = $anchor.controlLocalLineRect
easyxSurfaceCaretTopLeft = $anchor.easyxSurfaceCaretTopLeft
easyxSurfaceCaretBottom = $anchor.easyxSurfaceCaretBottom
easyxSurfaceLineRect = $anchor.easyxSurfaceLineRect
targetHwndRelativePoint = $anchor.targetHwndRelativePoint
diagnosticScreenPoint = if ($latest) { $latest.diagnosticScreenPoint } else { $null }
}
compositionForm = if ($latest) { $latest.compositionForm } else { $null }
candidateForm = if ($latest) { $latest.candidateForm } else { $null }
imeId = if ($latest) { $latest.imeDescriptionUtf16 } else { @() }
imeFileName = if ($latest) { $latest.imeFileNameUtf16 } else { @() }
hkl = if ($latest) { $latest.hkl } else { $null }
apiAttemptCount = $events.Count
successfulApiAttemptCount = $successful.Count
preconfigureApiAttemptCount = $preconfigureEvents.Count
successfulPreconfigureApiAttemptCount = $successfulPreconfigure.Count
lifecycleApiAttemptCount = $lifecycleEvents.Count
successfulLifecycleApiAttemptCount = $successfulLifecycle.Count
positionDisposition = 'not_observable'
automatedStatus = $status
manualStatus = 'not_reviewed'
failureCategory = $failure
evidenceFiles = $anchorEvidence
})
}
return $items.ToArray()
}
$resolved = Resolve-Path -LiteralPath $Path
$item = Get-Item -LiteralPath $resolved
$directory = if ($item.PSIsContainer) { $item.FullName } else { $item.DirectoryName }
$rawPath = if ($item.PSIsContainer) { Join-Path $directory 'raw-result.json' } else { $item.FullName }
if (-not (Test-Path -LiteralPath $rawPath)) { throw "Raw result not found: $rawPath" }
$raw = Get-Content -Raw -LiteralPath $rawPath | ConvertFrom-Json
if ([int]$raw.rawSchemaVersion -ne 1) { throw "Unsupported raw schema: $($raw.rawSchemaVersion)" }
if ($raw.mode -notin @('wide-text', 'dpi-coordinate', 'ime-position')) {
throw "Unsupported probe mode: $($raw.mode)"
}
$evidence = New-Object System.Collections.Generic.List[object]
foreach ($entry in @($raw.evidenceFiles)) {
$relativePath = [string]$entry.path
$nativeRelative = $relativePath.Replace('/', [IO.Path]::DirectorySeparatorChar)
$fullPath = Join-Path $directory $nativeRelative
if (-not (Test-Path -LiteralPath $fullPath)) { continue }
$file = Get-Item -LiteralPath $fullPath
$evidence.Add([pscustomobject][ordered]@{
path = $relativePath
purpose = $entry.purpose
captureBackend = $entry.captureBackend
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash
byteLength = $file.Length
crop = $null
})
}
$rawEvidencePresent = $evidence.Count
$manualEvidenceDirectory = Join-Path $directory 'manual-evidence'
if (Test-Path -LiteralPath $manualEvidenceDirectory) {
Get-ChildItem -LiteralPath $manualEvidenceDirectory -Filter '*.png' -File | ForEach-Object {
$evidence.Add([pscustomobject][ordered]@{
path = 'manual-evidence/' + $_.Name
purpose = 'manually cropped IME position evidence'
captureBackend = 'manual_crop'
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash
byteLength = $_.Length
crop = 'user_supplied'
})
}
}
$evidenceArray = $evidence.ToArray()
$testCases = if ($raw.mode -eq 'wide-text') {
New-WideTextCases -Raw $raw -EvidenceFiles $evidenceArray
}
elseif ($raw.mode -eq 'dpi-coordinate') {
New-DpiCoordinateCases -Raw $raw -EvidenceFiles $evidenceArray
}
else {
New-ImePositionCases -Raw $raw -EvidenceFiles $evidenceArray
}
$overallManualStatus = 'not_reviewed'
$sourceManualReview = $null
$manualReviewSummary = $null
$manualReviewPath = Join-Path $directory 'manual-review-v1.json'
if (Test-Path -LiteralPath $manualReviewPath) {
$manualReview = Get-Content -Raw -LiteralPath $manualReviewPath | ConvertFrom-Json
if ([int]$manualReview.manualReviewSchemaVersion -ne 1) {
throw "Unsupported manual review schema: $($manualReview.manualReviewSchemaVersion)"
}
if ([string]$manualReview.mode -ne [string]$raw.mode) {
throw "Manual review mode does not match raw result: $($manualReview.mode)"
}
$allowedManualStatuses = @('pass', 'fail', 'inconclusive', 'not_reviewed', 'not_available')
$allowedPositionDispositions = @('as_requested', 'system_adjusted', 'not_observable')
$reviewedIds = New-Object System.Collections.Generic.List[string]
foreach ($reviewCase in @($manualReview.testCases)) {
$testCaseId = [string]$reviewCase.testCaseId
if ($reviewedIds.Contains($testCaseId)) { throw "Duplicate manual review case: $testCaseId" }
$reviewedIds.Add($testCaseId)
$matchingCases = @($testCases | Where-Object testCaseId -eq $testCaseId)
if ($matchingCases.Count -ne 1) { throw "Unknown manual review case: $testCaseId" }
$manualStatus = [string]$reviewCase.manualStatus
$positionDisposition = [string]$reviewCase.positionDisposition
if ($manualStatus -notin $allowedManualStatuses) {
throw "Unsupported manual status for ${testCaseId}: $manualStatus"
}
if ($positionDisposition -notin $allowedPositionDispositions) {
throw "Unsupported position disposition for ${testCaseId}: $positionDisposition"
}
$targetCase = $matchingCases[0]
if ($manualStatus -eq 'pass' -and $targetCase.automatedStatus -ne 'pass') {
throw "Manual pass cannot override automated failure: $testCaseId"
}
$reviewEvidence = @($reviewCase.evidenceFiles)
if ($manualStatus -eq 'pass' -and $reviewEvidence.Count -eq 0) {
throw "Manual pass requires explicit evidence files: $testCaseId"
}
foreach ($relativePath in $reviewEvidence) {
$relative = [string]$relativePath
if ($relative -notlike 'manual-evidence/*') {
throw "Manual review evidence must be under manual-evidence: $relative"
}
$matchingEvidence = @($targetCase.evidenceFiles | Where-Object path -eq $relative)
if ($matchingEvidence.Count -ne 1) {
throw "Manual review evidence is missing or belongs to another case: $relative"
}
}
if ($manualStatus -eq 'pass' -and $positionDisposition -eq 'not_observable') {
throw "Manual pass requires an observable position disposition: $testCaseId"
}
$targetCase.manualStatus = $manualStatus
$targetCase.positionDisposition = $positionDisposition
$targetCase | Add-Member -NotePropertyName manualReviewNotes `
-NotePropertyValue ([string]$reviewCase.notes) -Force
}
if ($reviewedIds.Count -ne @($testCases).Count) {
throw 'Manual review must cover every manifest test case'
}
$caseManualStatuses = @($testCases | ForEach-Object { $_.manualStatus })
$derivedManualStatus = if ($caseManualStatuses -contains 'fail') {
'fail'
}
elseif ($caseManualStatuses -contains 'inconclusive') {
'inconclusive'
}
elseif ($caseManualStatuses -contains 'not_reviewed') {
'not_reviewed'
}
elseif ($caseManualStatuses -contains 'not_available') {
if (@($caseManualStatuses | Where-Object { $_ -ne 'not_available' }).Count -eq 0) {
'not_available'
}
else {
'inconclusive'
}
}
else {
'pass'
}
if ([string]$manualReview.overallStatus -ne $derivedManualStatus) {
throw "Manual review overall status mismatch: expected $derivedManualStatus"
}
$overallManualStatus = $derivedManualStatus
$sourceManualReview = [pscustomobject][ordered]@{
path = 'manual-review-v1.json'
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $manualReviewPath).Hash
}
$manualReviewSummary = [pscustomobject][ordered]@{
reviewedAtUtc = $manualReview.reviewedAtUtc
reviewer = $manualReview.reviewer
expectedImeName = $manualReview.expectedImeName
actualImeName = $manualReview.actualImeName
identitySource = $manualReview.identitySource
notes = $manualReview.notes
}
}
$failedCases = @($testCases | Where-Object automatedStatus -eq 'fail')
$allEvidencePresent = $rawEvidencePresent -eq @($raw.evidenceFiles).Count
$overallFailure = $null
if (-not $allEvidencePresent) { $overallFailure = 'evidence_missing' }
elseif ($failedCases.Count -gt 0) { $overallFailure = $failedCases[0].failureCategory }
elseif (-not $raw.completed -or $ProbeExitCode -ne 0) { $overallFailure = 'inconclusive_probe_exit' }
$overallStatus = if ($overallFailure) { 'fail' } else { 'pass' }
$manifest = [pscustomobject][ordered]@{
visualProbeSchemaVersion = 1
sessionId = $SessionId
startedAtUtc = $StartedAtUtc
completedAtUtc = [DateTime]::UtcNow.ToString('o')
mode = $raw.mode
processId = $raw.environment.processId
threadId = $raw.environment.threadId
osBuild = $raw.environment.osBuild
easyxVersion = $raw.environment.easyxVersion
probeExecutableExitCode = $ProbeExitCode
dpiConfiguration = $raw.dpiConfiguration
environment = $raw.environment
automatedStatus = $overallStatus
manualStatus = $overallManualStatus
failureCategory = $overallFailure
sourceRawResult = [pscustomobject][ordered]@{
path = 'raw-result.json'
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $rawPath).Hash
}
sourceManualReview = $sourceManualReview
manualReview = $manualReviewSummary
evidenceFiles = $evidenceArray
testCases = @($testCases)
}
$manifestPath = Join-Path $directory 'manifest-v1.json'
$manifestJson = $manifest | ConvertTo-Json -Depth 100
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[IO.File]::WriteAllText($manifestPath, $manifestJson + [Environment]::NewLine, $utf8Bom)
$manifestHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $manifestPath).Hash
$sidecarPath = $manifestPath + '.sha256'
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[IO.File]::WriteAllText($sidecarPath, "$manifestHash *manifest-v1.json`r`n", $utf8NoBom)
[pscustomobject][ordered]@{
mode = $raw.mode
manifestPath = $manifestPath
manifestSha256 = $manifestHash
automatedStatus = $overallStatus
manualStatus = $overallManualStatus
testCaseCount = @($testCases).Count
evidenceCount = $evidence.Count
dpiForWindow = $raw.environment.dpiForWindow
windowAwareness = $raw.environment.windowAwareness
}