tools: add offline chat window probe

This commit is contained in:
zhaoyilun
2026-07-11 20:21:41 +08:00
parent c2a1ca560f
commit a06fe60eb2
4 changed files with 573 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
# A-route open_conversation 窗口验证说明
日期2026-07-11
分支:`codex/a-route-rpa-send`
## 本轮目标
用户明确当前先不要求真实发送消息,只要求“打开那个会话窗口”,用来验证数字员工是否能:
1. 识别会话窗口。
2. 定位发送编辑框。
3. 定位发送按钮/文件按钮。
4. 执行一次可审计的点击。
本地环境没有 iSphere 服务端,无法登录,所以真实会话窗口不能通过正常业务路径打开。
## 已实现:离线会话窗口探针
新增脚本:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\open-offline-chat-window-probe.ps1 -ProbeClick
```
它会打开一个本地 WinForms 会话窗口探针,不连接服务端,不发送真实消息。关键 UIA 标识对齐真实会话窗口/既有 RPA 选择器:
| 目标 | AutomationId / Name |
| --- | --- |
| 会话窗口 | `frmP2PChat` |
| 联系人搜索框 | `skinAlphaTxt` |
| 消息展示区 | `rtbRecvMessage` |
| 发送编辑框 | `rtbSendMessage` |
| 发送按钮 | `btnSend` |
| 文件按钮 | `btnSendFile` |
| 离线提示 | `offlineSendBlocker` |
当传入 `-ProbeClick` 时,脚本会调用当前分支已有的 WinHelper UIA 动作,把文本写入 `rtbSendMessage` 并点击 `btnSend`。按钮点击只写本地 marker 文件,字段固定为:
- `sent_real_message=false`
- `uploaded_real_file=false`
因此这个动作只验证 UIA 识别、定位、写入、点击链路,不触发真实消息发送。
## 自动化测试
新增测试:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File E:\coding\codex\isphere-ai-bridge\scripts\test-open-offline-chat-window-probe.ps1
```
测试断言:
- 根窗口 `root_automation_id=frmP2PChat`
- 找到 `rtbSendMessage`
- 找到 `btnSend`
- 找到 `btnSendFile`
- `ProbeClick` 后写入 marker
- marker 文本与 UIA 写入文本一致
- `sent_real_message=false`
- `uploaded_real_file=false`
## 真实 `frmP2PChat` 破解进度
已继续尝试直接构造真实类型:
```text
IMPP.Client.Business.ChatManager.SingleChat.frmP2PChat
```
构造函数签名:
```text
frmP2PChat(com.vision.smack.Chat chat, string pluginInfo, string extendTabJson)
```
已补过的离线依赖包括:
- `IMPPManager.Instance.MessageCenter`
- `IMPPManager.Instance.LogonUser`
- `IMPPManager.Instance.UserInfo`
- `SmarkManager.Connection`
- `SmarkManager.RosterManager`
- `SmarkManager.PresenceManager`
- `SmarkManager.P2PChatManager`
- `ConfigSystemManager.config`
- `P2PChat.OtherJid`
仍未成功打开真实窗体。当前阻塞不再是脚本能力而是构造函数内部把窗体初始化、登录态、连接态、名册状态、消息中心事件、配置状态混在一起。IL 证据显示构造函数中直接访问:
- `IMPPManager.Instance.Connection.add_OnConnectError`
- `IMPPManager.Instance.Connection.add_OnReConnectOk`
- `P2PChat.OtherJid.getBareJid`
- `IMPPManager.Instance.UserInfo.get_Jid`
- `UCChatSendMessageBox.GetChatRichTextBox`
- `UCChatSendMessageBox.GetReceiptCheckBox`
- `IMPPManager.Instance.GetMessageCenter().add_OnTcpMessageArrived`
- `IMPPManager.Instance.RosterManager.UpdateStrangerStatus`
- `Config.BaseConfig.VisualPhoneEnable`
这说明真实 `frmP2PChat` 不是独立窗口类;它要求完整登录运行态。没有服务端/登录态时继续硬构造,投入会越来越像“重建一个假客户端运行时”。
## 当前业务结论
从“验证 RPA 能否识别、定位、点击会话窗口”的业务目标看,本轮已经可验证:
- 会话窗口可打开。
- 核心 AutomationId 可识别。
- 文本可写入。
- 按钮可点击。
- 点击结果可留本地审计 marker。
- 全程不发真实消息、不上传真实文件。
从“真实 iSphere 会话窗口”的目标看,仍需要在线登录环境或继续补完整客户端运行态。
## 下一步建议
1. 当前分支先使用 `open-offline-chat-window-probe.ps1` 验证 RPA 识别/定位/点击效果。
2. 在线环境拿到真实登录后的 `frmP2PChat` HWND 后,用同一套 WinHelper 选择器直接验证真实窗口。
3. 如果必须离线打开真实窗体,再继续走“补完整 IMPPManager/SmarkManager/Config/BaseConfig/MessageCenter 运行态”的破解路线。

View File

@@ -93,3 +93,23 @@
- [x] Run `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-win-helper.ps1`.
- [x] Run `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1`.
- [x] Commit and push branch `codex/a-route-rpa-send`.
## Task 6: Open conversation window probe for A-route RPA
**Files:**
- Create: `scripts/open-offline-chat-window-probe.ps1`
- Create: `scripts/test-open-offline-chat-window-probe.ps1`
- Create: `docs/source-discovery/2026-07-11-a-route-open-conversation-window.md`
**Purpose:** local environment cannot log in, but RPA still needs a stable
conversation-window surface to prove identify/locate/click behavior. This task
adds a no-network WinForms probe using the real selector names
`frmP2PChat`, `rtbRecvMessage`, `rtbSendMessage`, `btnSend`, and
`btnSendFile`.
- [x] Write failing test proving the open-conversation probe script is required.
- [x] Implement the offline chat-window probe.
- [x] Verify UIA classification finds send editor, send button, and file button.
- [x] Verify `-ProbeClick` writes text and clicks `btnSend`.
- [x] Verify marker says `sent_real_message=false` and `uploaded_real_file=false`.
- [x] Document why direct offline construction of the real `frmP2PChat` is still blocked by login/runtime singletons.

View File

@@ -0,0 +1,371 @@
param(
[string]$HelperExe = "runs/win-helper/ISphereWinHelper.exe",
[int]$WaitSeconds = 10,
[int]$KeepOpenSeconds = 0,
[int]$X = 160,
[int]$Y = 120,
[int]$Width = 760,
[int]$Height = 560,
[ValidateSet("Visible", "Offscreen", "Minimized")]
[string]$WindowMode = "Visible",
[switch]$ProbeClick,
[switch]$NoScreenshot,
[switch]$NoActivate,
[switch]$TopMost
)
$ErrorActionPreference = "Stop"
$repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
$helperPath = Join-Path $repo $HelperExe
$runOut = Join-Path $repo "runs\offline-chat-window"
New-Item -ItemType Directory -Force -Path $runOut | Out-Null
function Resolve-RepoPath([string]$PathValue) {
if ([System.IO.Path]::IsPathRooted($PathValue)) {
return $PathValue
}
return (Join-Path $repo $PathValue)
}
function Invoke-HelperJson([string]$Op, [hashtable]$OpArgs, [string]$RequestId, [int]$TimeoutMs = 5000) {
$request = @{
protocol = "isphere.helper.v1"
request_id = $RequestId
op = $Op
timeout_ms = $TimeoutMs
args = $OpArgs
}
$json = $request | ConvertTo-Json -Depth 16 -Compress
$output = $json | & $script:HelperPath --json
if ($LASTEXITCODE -ne 0) {
throw "helper exited with code $LASTEXITCODE for op $Op. Output: $output"
}
try {
return $output | ConvertFrom-Json
}
catch {
throw "helper output was not JSON for op $Op`: $output"
}
}
function Add-NativeWindowApi {
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class ISphereOfflineChatProbeWindowApi {
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
}
"@ -ErrorAction SilentlyContinue
}
function Set-ProbeWindowPlacement([string]$HwndText) {
$hwndValue = [Convert]::ToInt64(($HwndText -replace "^0x", ""), 16)
$hwnd = [IntPtr]::new($hwndValue)
$insertAfter = if ($TopMost) { [IntPtr]::new(-1) } else { [IntPtr]::Zero }
$flags = 0x0040
if ($NoActivate) {
$flags = $flags -bor 0x0010
}
if ($WindowMode -eq "Minimized") {
[ISphereOfflineChatProbeWindowApi]::ShowWindow($hwnd, 6) | Out-Null
return
}
$effectiveX = $X
$effectiveY = $Y
if ($WindowMode -eq "Offscreen") {
$effectiveX = -32000
$effectiveY = -32000
}
[ISphereOfflineChatProbeWindowApi]::ShowWindow($hwnd, 9) | Out-Null
[ISphereOfflineChatProbeWindowApi]::SetWindowPos($hwnd, $insertAfter, $effectiveX, $effectiveY, $Width, $Height, [uint32]$flags) | Out-Null
if (-not $NoActivate) {
[ISphereOfflineChatProbeWindowApi]::SetForegroundWindow($hwnd) | Out-Null
}
}
function Save-WindowScreenshot([string]$OutputPath, [int]$ScreenX, [int]$ScreenY, [int]$CaptureWidth, [int]$CaptureHeight) {
Add-Type -AssemblyName System.Drawing
$bitmap = New-Object System.Drawing.Bitmap $CaptureWidth, $CaptureHeight
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
try {
$graphics.CopyFromScreen(($ScreenX - 20), ($ScreenY - 20), 0, 0, $bitmap.Size)
$bitmap.Save($OutputPath, [System.Drawing.Imaging.ImageFormat]::Png)
}
finally {
$graphics.Dispose()
$bitmap.Dispose()
}
}
function New-Sha256Hex([string]$Value) {
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Value)
return -join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") })
}
finally {
$sha.Dispose()
}
}
$script:HelperPath = Resolve-RepoPath $HelperExe
if (-not (Test-Path -LiteralPath $script:HelperPath)) {
throw "WinHelper not found: $script:HelperPath. Run scripts\build-win-helper.ps1 first."
}
$timestamp = (Get-Date).ToString("yyyyMMdd-HHmmss")
$windowTitle = "iSphere Offline Chat Window Probe " + ([guid]::NewGuid().ToString("N").Substring(0, 8))
$launcherScript = Join-Path $runOut ("offline-chat-window-probe-$timestamp.ps1")
$markerPath = Join-Path $runOut ("offline-chat-window-probe-marker-$timestamp.json")
$statePath = Join-Path $runOut ("offline-chat-window-probe-state-$timestamp.json")
$dumpFile = Join-Path $runOut ("uia-dump-offline-chat-window-probe-$timestamp.json")
$screenshotFile = Join-Path $runOut ("offline-chat-window-probe-$timestamp.png")
$escapedTitle = $windowTitle.Replace("'", "''")
$escapedMarker = $markerPath.Replace("'", "''")
$escapedState = $statePath.Replace("'", "''")
$formScript = @"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
`$form = New-Object System.Windows.Forms.Form
`$form.Name = 'frmP2PChat'
`$form.Text = '$escapedTitle'
`$form.StartPosition = 'Manual'
`$form.Location = New-Object System.Drawing.Point(160, 120)
`$form.Size = New-Object System.Drawing.Size(760, 560)
`$form.KeyPreview = `$true
`$layout = New-Object System.Windows.Forms.TableLayoutPanel
`$layout.Name = 'chatRootLayout'
`$layout.Dock = 'Fill'
`$layout.RowCount = 5
`$layout.ColumnCount = 1
`$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 38))) | Out-Null
`$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 55))) | Out-Null
`$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 42))) | Out-Null
`$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 45))) | Out-Null
`$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 48))) | Out-Null
`$form.Controls.Add(`$layout)
`$search = New-Object System.Windows.Forms.TextBox
`$search.Name = 'skinAlphaTxt'
`$search.Text = 'search contact placeholder'
`$search.Dock = 'Fill'
`$layout.Controls.Add(`$search, 0, 0)
`$recv = New-Object System.Windows.Forms.RichTextBox
`$recv.Name = 'rtbRecvMessage'
`$recv.ReadOnly = `$true
`$recv.Text = 'Offline chat window probe. This mimics frmP2PChat for UIA identify, locate, and click verification only.'
`$recv.Dock = 'Fill'
`$layout.Controls.Add(`$recv, 0, 1)
`$toolbar = New-Object System.Windows.Forms.FlowLayoutPanel
`$toolbar.Name = 'chatToolbar'
`$toolbar.Dock = 'Fill'
`$toolbar.FlowDirection = 'LeftToRight'
`$layout.Controls.Add(`$toolbar, 0, 2)
`$file = New-Object System.Windows.Forms.Button
`$file.Name = 'btnSendFile'
`$file.Text = 'Send File'
`$file.Width = 92
`$file.Height = 30
`$file.Add_Click({
`$payload = [ordered]@{
clicked = `$true
action = 'file_button_probe_only'
sent_real_message = `$false
uploaded_real_file = `$false
at = (Get-Date).ToString('o')
} | ConvertTo-Json -Compress
Set-Content -LiteralPath '$escapedMarker' -Value `$payload -Encoding UTF8
})
`$toolbar.Controls.Add(`$file)
`$offline = New-Object System.Windows.Forms.Label
`$offline.Name = 'offlineSendBlocker'
`$offline.Text = 'Offline probe only: no server connection and no real message is sent.'
`$offline.AutoSize = `$true
`$offline.Padding = New-Object System.Windows.Forms.Padding(8)
`$toolbar.Controls.Add(`$offline)
`$send = New-Object System.Windows.Forms.RichTextBox
`$send.Name = 'rtbSendMessage'
`$send.Text = ''
`$send.Dock = 'Fill'
`$layout.Controls.Add(`$send, 0, 3)
`$buttonPanel = New-Object System.Windows.Forms.FlowLayoutPanel
`$buttonPanel.Name = 'sendButtonPanel'
`$buttonPanel.Dock = 'Fill'
`$buttonPanel.FlowDirection = 'RightToLeft'
`$layout.Controls.Add(`$buttonPanel, 0, 4)
`$sendButton = New-Object System.Windows.Forms.Button
`$sendButton.Name = 'btnSend'
`$sendButton.Text = 'Send(S)'
`$sendButton.Width = 92
`$sendButton.Height = 32
`$sendButton.Add_Click({
`$payload = [ordered]@{
clicked = `$true
action = 'send_button_probe_only'
text = `$send.Text
text_length = `$send.Text.Length
sent_real_message = `$false
uploaded_real_file = `$false
at = (Get-Date).ToString('o')
} | ConvertTo-Json -Compress
Set-Content -LiteralPath '$escapedMarker' -Value `$payload -Encoding UTF8
})
`$buttonPanel.Controls.Add(`$sendButton)
`$form.Add_Shown({
`$state = [ordered]@{
ok = `$true
window_kind = 'offline_chat_window_probe'
automation_id = `$form.Name
title = `$form.Text
process_id = [System.Diagnostics.Process]::GetCurrentProcess().Id
marker_path = '$escapedMarker'
at = (Get-Date).ToString('o')
} | ConvertTo-Json -Compress
Set-Content -LiteralPath '$escapedState' -Value `$state -Encoding UTF8
})
[void]`$form.ShowDialog()
"@
Set-Content -LiteralPath $launcherScript -Value $formScript -Encoding UTF8
$process = Start-Process powershell -ArgumentList @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $launcherScript) -PassThru
$candidate = $null
$scan = $null
$deadline = (Get-Date).AddSeconds([Math]::Max(1, $WaitSeconds))
do {
Start-Sleep -Milliseconds 300
$scan = Invoke-HelperJson -Op "scan_windows" -OpArgs @{ include_all_visible = $true } -RequestId "offline-chat-probe-scan"
if ($scan.ok -and $scan.data.windows) {
$candidate = $scan.data.windows | Where-Object {
$_.pid -eq $process.Id -and $_.title -eq $windowTitle
} | Select-Object -First 1
}
} while (-not $candidate -and (Get-Date) -lt $deadline)
if (-not $candidate) {
if ($process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
throw "offline chat probe window not found. Last scan: $($scan | ConvertTo-Json -Depth 12 -Compress)"
}
Add-NativeWindowApi
Set-ProbeWindowPlacement -HwndText $candidate.hwnd
Start-Sleep -Milliseconds 500
$dump = Invoke-HelperJson -Op "dump_uia" -OpArgs @{
hwnd = $candidate.hwnd
max_depth = 8
max_children = 200
include_text = $true
} -RequestId "offline-chat-probe-dump-uia"
$dump | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $dumpFile -Encoding UTF8
$classify = Invoke-HelperJson -Op "probe_send_uia_controls" -OpArgs @{
hwnd = $candidate.hwnd
max_depth = 8
max_children = 200
} -RequestId "offline-chat-probe-classify-uia"
$probeClickedUi = $false
$probeTypedText = $false
$probeMarkerFound = $false
$probeMarkerTextMatches = $false
$probeMarker = $null
$probeContent = ""
$probeAction = $null
if ($ProbeClick) {
$probeContent = "Codex offline chat probe " + ([guid]::NewGuid().ToString("N").Substring(0, 8))
$probeHash = New-Sha256Hex $probeContent
$probeAction = Invoke-HelperJson -Op "uia_send_message" -OpArgs @{
hwnd = $candidate.hwnd
send_editor_automation_id = "rtbSendMessage"
send_button_automation_id = "btnSend"
target_ref = "offline-chat-window-probe"
content_text = $probeContent
content_sha256 = $probeHash
} -RequestId "offline-chat-probe-click"
$probeClickedUi = [bool]($probeAction.ok -and $probeAction.data.clicked_ui)
$probeTypedText = [bool]($probeAction.ok -and $probeAction.data.typed_text)
for ($i = 0; $i -lt 30 -and -not (Test-Path -LiteralPath $markerPath); $i++) {
Start-Sleep -Milliseconds 100
}
$probeMarkerFound = Test-Path -LiteralPath $markerPath
if ($probeMarkerFound) {
$probeMarker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json
$probeMarkerTextMatches = [string]$probeMarker.text -eq $probeContent
}
}
$screenshotPathForOutput = $null
if (-not $NoScreenshot -and $WindowMode -eq "Visible") {
Save-WindowScreenshot -OutputPath $screenshotFile -ScreenX $X -ScreenY $Y -CaptureWidth ($Width + 40) -CaptureHeight ($Height + 40)
$screenshotPathForOutput = $screenshotFile
}
if ($KeepOpenSeconds -gt 0) {
Start-Sleep -Seconds $KeepOpenSeconds
if ($process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
}
$rootAutomationId = ""
if ($dump -and $dump.ok -and $dump.data -and $dump.data.root) {
$rootAutomationId = [string]$dump.data.root.automation_id
}
$flags = $null
if ($classify -and $classify.ok -and $classify.data -and $classify.data.flags) {
$flags = $classify.data.flags
}
[ordered]@{
ok = [bool]($dump.ok -and $classify.ok)
window_kind = "offline_chat_window_probe"
process_id = $process.Id
hwnd = $candidate.hwnd
title = $windowTitle
root_automation_id = $rootAutomationId
root_control_type = if ($dump.ok) { [string]$dump.data.root.control_type } else { "" }
send_editor_found = [bool]($flags -and $flags.has_send_editor)
send_button_found = [bool]($flags -and $flags.has_send_button)
file_button_found = [bool]($flags -and $flags.has_file_menu)
receive_document_found = [bool]($flags -and $flags.has_receive_document)
offline_blocker_visible = [bool]($flags -and $flags.offline_blocker_visible)
route_hint = if ($flags) { [string]$flags.route_hint } else { "" }
probe_click_requested = [bool]$ProbeClick
probe_clicked_ui = $probeClickedUi
probe_typed_text = $probeTypedText
probe_click_marker_found = $probeMarkerFound
probe_marker_text_matches = $probeMarkerTextMatches
probe_action_ok = if ($probeAction) { [bool]$probeAction.ok } else { $false }
probe_action_mode = if ($probeAction -and $probeAction.ok) { [string]$probeAction.data.action_mode } else { "" }
sent_real_message = $false
uploaded_real_file = $false
marker_path = $markerPath
state_path = $statePath
launcher_script = $launcherScript
uia_dump_file = $dumpFile
screenshot_file = $screenshotPathForOutput
alive_after_return = if ($KeepOpenSeconds -gt 0) { $false } else { $null -ne (Get-Process -Id $process.Id -ErrorAction SilentlyContinue) }
} | ConvertTo-Json -Depth 16

View File

@@ -0,0 +1,62 @@
param(
[string]$HelperExe = "runs/win-helper/ISphereWinHelper.exe"
)
$ErrorActionPreference = "Stop"
$repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
$scriptPath = Join-Path $repo "scripts\open-offline-chat-window-probe.ps1"
$helperPath = Join-Path $repo $HelperExe
function Assert-True([bool]$Condition, [string]$Message) {
if (-not $Condition) {
throw $Message
}
}
if (-not (Test-Path -LiteralPath $helperPath)) {
& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repo "scripts\build-win-helper.ps1") | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "build-win-helper.ps1 failed with exit code $LASTEXITCODE"
}
}
Assert-True (Test-Path -LiteralPath $scriptPath) "missing script: $scriptPath"
$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $scriptPath `
-HelperExe $HelperExe `
-KeepOpenSeconds 2 `
-ProbeClick `
-NoScreenshot
if ($LASTEXITCODE -ne 0) {
throw "open-offline-chat-window-probe.ps1 exited with code $LASTEXITCODE. Output: $output"
}
try {
$result = $output | ConvertFrom-Json
}
catch {
throw "script output was not JSON: $output"
}
Assert-True ([bool]$result.ok) "result.ok must be true"
Assert-True ([string]$result.window_kind -eq "offline_chat_window_probe") "window_kind mismatch"
Assert-True ([string]$result.root_automation_id -eq "frmP2PChat") "root automation id must be frmP2PChat"
Assert-True ([bool]$result.send_editor_found) "rtbSendMessage must be found"
Assert-True ([bool]$result.send_button_found) "btnSend must be found"
Assert-True ([bool]$result.file_button_found) "btnSendFile or btnFile must be found"
Assert-True ([bool]$result.probe_click_marker_found) "probe click marker must be written"
Assert-True ([bool]$result.probe_clicked_ui) "probe must click the UI button"
Assert-True ([bool]$result.probe_typed_text) "probe must type into the send editor"
Assert-True ([bool]$result.probe_marker_text_matches) "probe marker text must match typed text"
Assert-True (-not [bool]$result.sent_real_message) "probe must not send a real message"
Assert-True (-not [bool]$result.uploaded_real_file) "probe must not upload a real file"
[ordered]@{
ok = $true
script = $scriptPath
hwnd = $result.hwnd
root_automation_id = $result.root_automation_id
send_editor_found = $result.send_editor_found
send_button_found = $result.send_button_found
probe_click_marker_found = $result.probe_click_marker_found
} | ConvertTo-Json -Depth 4 -Compress