diff --git a/scripts/windows/Test-NamedListeners.ps1 b/scripts/windows/Test-NamedListeners.ps1 index 1ec6b44..9381eaf 100644 --- a/scripts/windows/Test-NamedListeners.ps1 +++ b/scripts/windows/Test-NamedListeners.ps1 @@ -1,6 +1,7 @@ param( [Parameter(Mandatory=$true)][string]$Executable, - [Parameter(Mandatory=$true)][string]$OutputDirectory + [Parameter(Mandatory=$true)][string]$OutputDirectory, + [switch]$SendMarkers ) $ErrorActionPreference='Stop' $exe=(Resolve-Path $Executable).Path @@ -9,29 +10,56 @@ if (Test-Path $directory) { throw 'Use a new evidence directory.' } [IO.Directory]::CreateDirectory($directory) | Out-Null $targets=@('文件传输助手','消息测试专用群组') $processes=@() +$sent=@() +$seconds=if($SendMarkers){45}else{8} try { for($i=0;$i -lt $targets.Count;$i++) { $arguments=@('chat','monitor','--independent','--session',('"'+$targets[$i]+'"'), - '--seconds','8','--state-file',('"'+$directory+'\state-'+$i+'.json"'),'--timeout','20') + '--seconds',$seconds,'--state-file',('"'+$directory+'\state-'+$i+'.json"'),'--timeout','60') $process=Start-Process $exe -ArgumentList $arguments -PassThru -NoNewWindow ` -RedirectStandardOutput "$directory\events-$i.jsonl" -RedirectStandardError "$directory\stderr-$i.txt" $null=$process.Handle # Retain the process handle so PowerShell 5 can retrieve ExitCode after exit. $processes+=$process } + if($SendMarkers){ + $readyDeadline=[DateTime]::UtcNow.AddSeconds(20) + while(!(Test-Path "$directory\state-0.json") -or !(Test-Path "$directory\state-1.json")){ + if([DateTime]::UtcNow -ge $readyDeadline){throw 'Listeners did not establish both baselines; no messages sent.'} + Start-Sleep -Milliseconds 200 + } + foreach($target in $targets){ + $marker='wx-agent-isolation-'+[Guid]::NewGuid().ToString('N') + $message=(& $exe chat send --session $target --text $marker --timeout 30 | Out-String) | ConvertFrom-Json + $code=$LASTEXITCODE + $sent+=@{ExitCode=$code;Fingerprint=$message.Fingerprint} + if($code -ne 0){break} # Never retry a failed send. + } + } $checks=@() for($i=0;$i -lt $processes.Count;$i++) { $process=$processes[$i] - if (!$process.WaitForExit(30000)) { throw 'Named listener exceeded its deadline.' } + if (!$process.WaitForExit(60000)) { throw 'Named listener exceeded its deadline.' } $exitCode=$process.ExitCode $statePath="$directory\state-$i.json" $state=if(Test-Path $statePath){ Get-Content $statePath -Raw | ConvertFrom-Json }else{$null} & $exe inspect-ui --window-title $targets[$i] --output "$directory\ui-$i.json" --timeout 20 | Out-Null $inspectCode=$LASTEXITCODE - $checks+=@{Index=$i;ExitCode=$exitCode;CheckpointSaved=($null -ne $state); + $check=@{Index=$i;ExitCode=$exitCode;CheckpointSaved=($null -ne $state); SessionMatches=($state.session -eq $targets[$i]);FingerprintCount=if($null -ne $state){@($state.fingerprints).Count}else{0};InspectExit=$inspectCode} + if($SendMarkers -and $sent.Count -eq 2){ + $events=@(Get-Content "$directory\events-$i.jsonl" | Where-Object {$_} | ForEach-Object {$_ | ConvertFrom-Json}) + $check.OwnEvents=@($events | Where-Object {$_.message.Fingerprint -eq $sent[$i].Fingerprint -and $_.Session -ceq $targets[$i]}).Count + $check.OtherSessionEvents=@($events | Where-Object {$_.message.Fingerprint -eq $sent[1-$i].Fingerprint}).Count + } + $checks+=$check } $passed=@($checks | Where-Object {$_.ExitCode -ne 0 -or !$_.CheckpointSaved -or !$_.SessionMatches -or $_.FingerprintCount -lt 1 -or $_.InspectExit -ne 0}).Count -eq 0 - $result=@{Checks=$checks;Passed=$passed;MessagesSent=0;Scope='Parallel named-window baseline only; not incoming-message delivery or endurance.'} + if($SendMarkers){ + $passed=$passed -and $sent.Count -eq 2 -and @($sent | Where-Object {$_.ExitCode -ne 0}).Count -eq 0 -and + @($checks | Where-Object {$_.OwnEvents -ne 1 -or $_.OtherSessionEvents -ne 0}).Count -eq 0 + } + $result=@{Checks=$checks;Passed=$passed;AttemptedSends=$sent.Count;MessagesSent=@($sent | Where-Object {$_.ExitCode -eq 0}).Count; + Scope=if($SendMarkers){'Own sent-message events across two independent windows; not peer incoming delivery or endurance.'}else{'Parallel named-window baseline only; not incoming-message delivery or endurance.'}} [IO.File]::WriteAllText("$directory\summary.json",($result | ConvertTo-Json -Depth 5),(New-Object Text.UTF8Encoding($false))) if(!$passed){exit 2} } finally { diff --git a/scripts/windows/Test-NamedTextSend.ps1 b/scripts/windows/Test-NamedTextSend.ps1 new file mode 100644 index 0000000..e5b31c4 --- /dev/null +++ b/scripts/windows/Test-NamedTextSend.ps1 @@ -0,0 +1,23 @@ +param( + [Parameter(Mandatory=$true)][string]$Executable, + [Parameter(Mandatory=$true)][string]$OutputDirectory, + [string]$Session='消息测试专用群组' +) +$ErrorActionPreference='Stop' +$exe=(Resolve-Path $Executable).Path +if(Test-Path $OutputDirectory){throw 'Use a fresh evidence directory.'} +$directory=(New-Item -ItemType Directory $OutputDirectory).FullName +$marker='wx-agent-repeat-'+[Guid]::NewGuid().ToString('N') +$results=@() +for($i=0;$i -lt 2;$i++){ + $result=(& $exe chat send --session $Session --text $marker --timeout 30 | Out-String) | ConvertFrom-Json + $exitCode=$LASTEXITCODE + $results+=@{ExitCode=$exitCode;Fingerprint=$result.Fingerprint;Error=$result.error;Message=$result.message} + if($exitCode -ne 0){break} # Never retry a failed send. +} +$success=($results.Count -eq 2 -and $results[0].ExitCode -eq 0 -and $results[1].ExitCode -eq 0 -and + $null -ne $results[0].Fingerprint -and $null -ne $results[1].Fingerprint -and + $results[0].Fingerprint -ne $results[1].Fingerprint) +@{Success=$success;Results=$results;AttemptedSends=$results.Count;BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash} | + ConvertTo-Json -Depth 5 | Set-Content "$directory\summary.json" -Encoding UTF8 +if(!$success){exit 1} diff --git a/src/WxAgent.Core/WechatNoteWindow.cs b/src/WxAgent.Core/WechatNoteWindow.cs new file mode 100644 index 0000000..8f6251e --- /dev/null +++ b/src/WxAgent.Core/WechatNoteWindow.cs @@ -0,0 +1,16 @@ +namespace WxAgent.Core; + +public sealed record WechatNoteWindowCandidate(long Handle, int ProcessId, string Title, bool IsVisible, bool HasChatSurface); + +public static class WechatNoteWindow +{ + public static long? FindOpened(IEnumerable windows, int processId, IReadOnlySet previousHandles) + { + var candidates = windows.Where(window => window.Handle != 0 && window.ProcessId == processId + && !previousHandles.Contains(window.Handle) && window.IsVisible && !window.HasChatSurface + && window.Title.Contains("笔记", StringComparison.Ordinal)).ToArray(); + if (candidates.Length > 1) + throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Multiple new note windows appeared; refusing ambiguous content."); + return candidates.Length == 1 ? candidates[0].Handle : null; + } +} diff --git a/src/WxAgent.Windows/WechatChatClient.M5.cs b/src/WxAgent.Windows/WechatChatClient.M5.cs index e90f138..d61c77f 100644 --- a/src/WxAgent.Windows/WechatChatClient.M5.cs +++ b/src/WxAgent.Windows/WechatChatClient.M5.cs @@ -309,21 +309,32 @@ public static partial class WechatChatClient var message = FindVisibleMessage(main, fingerprint); if (message.Type != ChatMessageType.Note) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The selected message is not a note."); + var processId = main.Properties.ProcessId.ValueOrDefault; + var previousHandles = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window)) + .Select(window => window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64()).ToHashSet(); FindVisibleMessageElement(main, fingerprint).DoubleClick(); - await Task.Delay(400, cancellationToken).ConfigureAwait(false); - var processIds = Process.GetProcessesByName("Weixin").Select(process => process.Id).ToHashSet(); - var noteWindow = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window)) - .FirstOrDefault(window => processIds.Contains(window.Properties.ProcessId.ValueOrDefault) - && !window.Properties.IsOffscreen.ValueOrDefault - && SafeName(window).Contains("笔记", StringComparison.Ordinal)); - var root = noteWindow ?? main; - var parts = root.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) - .Select(SafeName) - .Where(text => !string.IsNullOrWhiteSpace(text)) - .Distinct(StringComparer.Ordinal) - .Select(text => new WechatNoteContentPart("text", text)) - .ToArray(); - return new WechatNoteContent(parts); + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5); + while (DateTimeOffset.UtcNow < deadline) + { + await Task.Delay(150, cancellationToken).ConfigureAwait(false); + var windows = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window)) + .Where(window => window.Properties.ProcessId.ValueOrDefault == processId).ToArray(); + var handle = WechatNoteWindow.FindOpened(windows.Select(window => new WechatNoteWindowCandidate( + window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64(), window.Properties.ProcessId.ValueOrDefault, + SafeName(window), !window.Properties.IsOffscreen.ValueOrDefault, + FindByAutomationId(window, WechatLocators.MessageList) is not null)), processId, previousHandles); + if (handle is null) continue; + var noteWindow = windows.Single(window => window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64() == handle); + var parts = noteWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) + .Select(SafeName) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .Select(text => new WechatNoteContentPart("text", text)) + .ToArray(); + if (parts.Length == 0) continue; + return new WechatNoteContent(parts); + } + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, + "A unique new note window with readable content was not verified. The main chat and existing windows were not exported."); }, cancellationToken); public static async Task VisibleNoteToMarkdownFileAsync( diff --git a/src/WxAgent.Windows/WechatChatClient.cs b/src/WxAgent.Windows/WechatChatClient.cs index 6fda118..7e47f35 100644 --- a/src/WxAgent.Windows/WechatChatClient.cs +++ b/src/WxAgent.Windows/WechatChatClient.cs @@ -122,9 +122,11 @@ public static partial class WechatChatClient } } - public static async Task SendTextAsync(string text, CancellationToken cancellationToken) + public static async Task SendTextAsync(string text, CancellationToken cancellationToken, + string session = WechatLocators.FileTransferAssistant) { ArgumentException.ThrowIfNullOrWhiteSpace(text); + ArgumentException.ThrowIfNullOrWhiteSpace(session); if (text.Length > 4000 || text.Contains('\r') || text.Contains('\n')) { throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "M1 text messages must be one line and no longer than 4000 characters."); @@ -135,26 +137,31 @@ public static partial class WechatChatClient { using var automation = new UIA3Automation(); var window = AttachWindow(automation, cancellationToken); - await OpenFileTransferAssistantCoreAsync(window, cancellationToken); + await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false); await Task.Delay(500, cancellationToken); + if (!IsListeningSession(window, session, independent: false)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before input."); + var baseline = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal); var input = FindByAutomationId(window, WechatLocators.ChatInput) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found."); - var send = window.FindAllDescendants().FirstOrDefault(element => - SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) - ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found."); - var inputBox = input.AsTextBox(); + if (!string.IsNullOrEmpty(inputBox.Text)) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten."); ExecuteInputStep("set-input-value", () => inputBox.Text = text); await Task.Delay(150, cancellationToken); - if (!string.Equals(inputBox.Text, text, StringComparison.Ordinal)) + if (!IsListeningSession(window, session, independent: false) || !string.Equals(inputBox.Text, text, StringComparison.Ordinal)) { throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The chat input did not contain the exact requested text; send was aborted."); } + // Setting input text can recreate the send control; acquire it only after the input mutation. + var send = window.FindAllDescendants().FirstOrDefault(element => + SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found after input."); ExecuteInputStep("click-send", () => ClickCenter(send)); - var confirmed = await WaitForMessageAsync(text, TimeSpan.FromSeconds(20), cancellationToken); + var confirmed = await WaitForMessageAsync(text, session, baseline, TimeSpan.FromSeconds(20), cancellationToken); return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The text was entered but no matching visible message confirmed the send result."); } finally @@ -667,6 +674,8 @@ public static partial class WechatChatClient private static async Task WaitForMessageAsync( string text, + string session, + IReadOnlySet baseline, TimeSpan timeout, CancellationToken cancellationToken) { @@ -676,9 +685,12 @@ public static partial class WechatChatClient cancellationToken.ThrowIfCancellationRequested(); using var freshAutomation = new UIA3Automation(); var window = WechatDoctor.FindWechatWindow(freshAutomation); + if (window is not null && !IsListeningSession(window, session, independent: false)) + throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed while confirming the send; no retry was attempted."); var match = window is null ? null - : ReadVisible(window).LastOrDefault(message => string.Equals(message.Text, text, StringComparison.Ordinal)); + : ReadVisible(window).LastOrDefault(message => + string.Equals(message.Text, text, StringComparison.Ordinal) && !baseline.Contains(message.Fingerprint)); if (match is not null) { return match; @@ -833,9 +845,14 @@ public static partial class WechatChatClient { action(); } + catch (WxAgentException) + { + throw; + } catch (Exception exception) { - throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, $"UI input failed at {step}.", exception); + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, + $"UI input failed at {step} ({exception.GetType().Name}, HRESULT 0x{exception.HResult:X8}).", exception); } } diff --git a/src/WxAgent.Windows/WechatListenerSmoke.cs b/src/WxAgent.Windows/WechatListenerSmoke.cs index c766482..38c469a 100644 --- a/src/WxAgent.Windows/WechatListenerSmoke.cs +++ b/src/WxAgent.Windows/WechatListenerSmoke.cs @@ -7,7 +7,8 @@ public sealed record WechatListenerSmokeResult(bool Sent, int MatchingEvents, in public static partial class WechatChatClient { - public static async Task ListenerSmokeAsync(string outputDirectory, CancellationToken cancellationToken, bool independentWindow = false) + public static async Task ListenerSmokeAsync(string outputDirectory, CancellationToken cancellationToken, bool independentWindow = false, + string session = WechatLocators.FileTransferAssistant) { var directory = Path.GetFullPath(outputDirectory); Directory.CreateDirectory(directory); @@ -27,7 +28,7 @@ public static partial class WechatChatClient { await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(30), checkpoint, listening.Token, snapshotObserved: () => { Interlocked.Increment(ref snapshots); ready.TrySetResult(); }, - independentWindow: independentWindow)) + session: session, independentWindow: independentWindow)) { if (item.Message?.Text != marker) continue; Interlocked.Increment(ref matches); @@ -39,7 +40,7 @@ public static partial class WechatChatClient try { await ready.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); - await SendTextAsync(marker, cancellationToken).ConfigureAwait(false); // One send only, through the same command queue. + await SendTextAsync(marker, cancellationToken, session).ConfigureAwait(false); // One send only, through the same command queue. await matched.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken).ConfigureAwait(false); } @@ -50,7 +51,7 @@ public static partial class WechatChatClient } var afterRestart = 0; await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(4), checkpoint, cancellationToken, - snapshotObserved: () => Interlocked.Increment(ref snapshots), independentWindow: independentWindow)) + snapshotObserved: () => Interlocked.Increment(ref snapshots), session: session, independentWindow: independentWindow)) { if (item.Message?.Text == marker) afterRestart++; } diff --git a/tests/WxAgent.Core.Tests/WechatNoteWindowTests.cs b/tests/WxAgent.Core.Tests/WechatNoteWindowTests.cs new file mode 100644 index 0000000..a05ecea --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatNoteWindowTests.cs @@ -0,0 +1,37 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatNoteWindowTests +{ + [Fact] + public void OnlyAFreshVisibleNoteInTheExpectedProcessIsSelected() + { + WechatNoteWindowCandidate[] windows = [ + new(1, 10, "笔记", true, false), // Existing note must not be exported instead of the selected message. + new(2, 10, "笔记交流群", true, true), + new(3, 20, "笔记", true, false), + new(4, 10, "笔记", false, false), + new(5, 10, "微信", true, false), + new(6, 10, "笔记", true, false)]; + Assert.Equal(6, WechatNoteWindow.FindOpened(windows, 10, new HashSet { 1 })); + Assert.Null(WechatNoteWindow.FindOpened(windows[..5], 10, new HashSet { 1 })); + } + + [Fact] + public void AmbiguousNewNotesAreRejected() + { + var error = Assert.Throws(() => WechatNoteWindow.FindOpened([ + new(1, 10, "笔记", true, false), new(2, 10, "笔记", true, false)], 10, new HashSet())); + Assert.Equal(WxAgentErrorCode.UiStructureChanged, error.Code); + } + + [Fact] + public void RepeatedNoteParagraphsMustRemainRepeated() + { + var content = new WechatNoteContent([ + new("text", "重复段落"), new("text", "重复段落")]); + Assert.Equal("重复段落\n\n重复段落", content.ToMarkdown().ReplaceLineEndings("\n")); + } +}