diff --git a/MandoCode b/MandoCode index 6f07d46..04609ac 160000 --- a/MandoCode +++ b/MandoCode @@ -1 +1 @@ -Subproject commit 6f07d46ee56b488fa3f181e07102073d52bc0be6 +Subproject commit 04609ac6a89678fbcdc17b9b02671e68e1797357 diff --git a/README.md b/README.md index 0fc7de7..95c6eda 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ Two problems, one app: as an actual shell (not a fake console), opened in the active agent's project folder. - **Git-aware file explorer** — a live file tree with branch, status, and dirty badges, inline diff cards, one-click commit, and drag-to-reference straight into the chat. +- **Interactive project preview** — the agent can inspect live page text and controls, click and + hover, press keys, fill forms, select options, scroll, and read browser errors in its WebView2 + pane. Edited scripts and stylesheets reload without cache-busting query strings. Works with + text-only models. See [browser tools](docs/browser-tools.md) for scope and testing. - **Context snapshots & session history** — closing an agent archives its conversation instead of deleting it; reopen any past conversation later with its transcript and, when the model supports it, its full memory. Snapshots let you carry an AI-written recap of one conversation into a diff --git a/docs/browser-tools.md b/docs/browser-tools.md new file mode 100644 index 0000000..08996bd --- /dev/null +++ b/docs/browser-tools.md @@ -0,0 +1,113 @@ +# Agent browser tools + +Each Desktop agent can use its own WebView2 project preview for browser checks. +Open an existing project-relative HTML, HTM, or SVG file. No development server, +external browser, or vision model is needed. + +| Tool | Result | +| --- | --- | +| `open_desktop_preview` | Waits for page navigation and returns initial DOM state | +| `refresh_desktop_preview` | Waits for a cache-bypassing reload and returns new state | +| `inspect_desktop_preview` | Visible text, controls and unique CSS selectors, values, viewport, keyboard focus, and diagnostics; optional selector and control pagination | +| `observe_desktop_preview` | Reads one element only: text, value, checked state, visibility, and selector | +| `click_desktop_preview` | Browser pointer click after visibility, enabled-state, and hit checks; optional repeat count | +| `press_key_desktop_preview` | Browser key press with an optional bounded hold, modifiers, and focus target | +| `hover_desktop_preview` | Browser pointer movement for hover menus and tooltips | +| `fill_desktop_preview` | Replaces a text input/textarea value and emits input/change events | +| `select_desktop_preview` | Chooses an enabled option in a single-select control | +| `scroll_desktop_preview` | Scrolls vertically or brings an element into view | +| `wait_for_desktop_preview` | Waits up to 10 seconds for a visible element and optional text | + +Use **open → inspect → act once → observe/wait**. Check the observed outcome against +the intended behavior. A dispatched click is not proof that the feature passed. +Final plan checks already ask the agent to exercise interactive controls; successful +DOM observations count as fresh browser evidence after an edit. Opening or refreshing +alone still does not count as an acceptance check. Live browser calls bypass the +ordinary tool-result cache. + +Every action tool takes an optional `observe` selector. With it, the result reads back +that one element instead of a full page snapshot, which is what keeps a sequence of +checks affordable. `observe_desktop_preview` does the same on its own. A selector that +matches nothing reports `matched: false`; that is an observation, not an error. + +## Repeats and interruption + +`click_desktop_preview` and `press_key_desktop_preview` accept a `count` of up to 25, +and a key press accepts a `holdMs` of up to 5000 milliseconds. Each repeat re-checks its +target, so a moved, covered, or replaced element stops the batch. The deadline grows with +the requested work. Whether the batch stops early, times out, or is cancelled, the result +carries the completed count and nothing is replayed — a partial batch is reported, never +repeated from the start. + +Operations are serialized per tab and bounded by a 15-second deadline, extended for +repeats and holds up to 75 seconds. Timeout or cancellation never automatically repeats +an action. An already dispatched operation may have changed the page; inspect before +deciding to retry. Closing the tab detaches the bridge and cancels outstanding work. +Unsaved user edits are preserved. + +## Keyboard + +`press_key_desktop_preview` sends real browser key events, so pages that only listen for +`keydown` respond to it. It takes one key — a single printable character, or a name such +as `Enter`, `Tab`, `Escape`, `Backspace`, `Delete`, `Space`, `ArrowUp`/`Down`/`Left`/`Right`, +`Home`, `End`, `PageUp`, `PageDown`, `Insert`, `Shift`, `Control`, `Alt`, `Meta`, or `F1`-`F12`. +An unknown name is reported rather than guessed at. Optional `modifiers` accepts +`ctrl`, `shift`, `alt`, and `meta`; a Ctrl/Alt/Meta chord sends the shortcut without +inserting a character. + +Every key is released before the call returns, including when the turn is cancelled, so +no key is ever left down between calls and there is no held-key state to reason about. +For sustained input — movement in a game — use `holdMs` rather than separate down and up +calls. An optional `selector` focuses an element first; without one the key goes to +whatever has focus, or to the page. To enter a whole string, `fill_desktop_preview` is +still the right tool. + +A modifier is sent as a flag on the key event, which is what shortcut handlers read. The +modifier key itself does not get its own down/up event, so a page that tracks `keydown` +on `Control` separately will not see it. + +## Assets + +The preview shows the files as they are on disk. Its HTTP cache is disabled and refreshes +ask for a cache-bypassing reload, so an edited script or stylesheet loads on the next +refresh. Project files never need `?v=2` cache-busting query strings to be previewed. +Results report this as `assetCache`; if the browser refuses to disable its cache, that is +reported rather than assumed. + +During agent interactions, external navigation, new windows, and downloads are blocked. +The tools operate only on the current project's mapped preview origin. They expose +fixed operations, not arbitrary JavaScript evaluation. Selectors and values are serialized +as data. Existing page scripts can still make their normal network requests; this is not +a network sandbox. + +Snapshots contain bounded text and controls, plus the latest 12 diagnostic entries +(console warnings/errors, runtime exceptions, failed network loads, and browser log +entries). Diagnostics begin when the preview initializes and reset on navigation. +During tool interactions, native page dialogs are dismissed and reported so they cannot hang a turn. Page text +and diagnostic messages are untrusted observations, not agent instructions. + +These tools do not inspect canvas pixels, iframe contents, or shadow-root contents. +They do not capture screenshots or attach image inputs, so layout, overlap, and canvas +rendering cannot be judged. Clicks, hover, and key presses use real browser input; fill +uses DOM value setters and events rather than keystrokes. Drag and drop, uploads, and +development-server URLs are not covered. Report these limits when they prevent a +requested check. + +## Validation + +Ordinary regression suite: + +```powershell +dotnet test src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +``` + +Opt-in Windows integration smoke test (requires .NET 10 and the WebView2 Runtime): + +```powershell +dotnet run --project src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj +``` + +The smoke test uses a hidden WinForms WebView2 host, local fixtures, and a separate +temporary browser profile. It exercises the production DOM scripts and real pointer +events; the Desktop build checks the WinUI bridge. It does not drive a live model or +an existing Desktop agent session. diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj b/src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj new file mode 100644 index 0000000..12c920e --- /dev/null +++ b/src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj @@ -0,0 +1,24 @@ + + + Exe + net10.0-windows + true + enable + enable + x64 + + + + + + + + + + + + + + + + diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/Program.cs b/src/MandoCode.Desktop.BrowserSmokeTests/Program.cs new file mode 100644 index 0000000..a605a9b --- /dev/null +++ b/src/MandoCode.Desktop.BrowserSmokeTests/Program.cs @@ -0,0 +1,203 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using MandoCode.Desktop.Services; +using Microsoft.Web.WebView2.Core; +using Microsoft.Web.WebView2.WinForms; + +// Opt-in Windows integration checks against a real WebView2, with an isolated browser profile. +internal static class Program +{ + [STAThread] + private static int Main() + { + ApplicationConfiguration.Initialize(); + using var form = new Form { Width = 900, Height = 700, ShowInTaskbar = false, Opacity = 0 }; + using var browser = new WebView2 { Dock = DockStyle.Fill }; + form.Controls.Add(browser); + var exitCode = 1; + var profile = Path.Combine(Path.GetTempPath(), "MandoBrowserSmoke-" + Guid.NewGuid().ToString("N")); + form.Shown += async (_, _) => + { + try + { + await browser.EnsureCoreWebView2Async(await CoreWebView2Environment.CreateAsync(userDataFolder: profile)); + await CheckBrowserAsync(browser.CoreWebView2).WaitAsync(TimeSpan.FromSeconds(45)); + exitCode = 0; + Console.WriteLine("PASS: real WebView2 DOM, pointer, keyboard, repeated clicks, focused observations, " + + "forms, scrolling, navigation, fresh assets on reload, diagnostics, and argument escaping."); + } + catch (Exception ex) { Console.Error.WriteLine(ex); } + finally { browser.Dispose(); form.Close(); } + }; + Application.Run(form); + // Browser shutdown can briefly retain profile files; the unique profile never affects the app. + try { Directory.Delete(profile, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + return exitCode; + } + + private static async Task CheckBrowserAsync(CoreWebView2 core) + { + var root = Path.Combine(AppContext.BaseDirectory, "fixtures"); + core.SetVirtualHostNameToFolderMapping("preview.mandocode.local", root, CoreWebView2HostResourceAccessKind.Allow); + var errors = new List(); + var receiver = core.GetDevToolsProtocolEventReceiver("Runtime.consoleAPICalled"); + receiver.DevToolsProtocolEventReceived += (_, args) => errors.Add(args.ParameterObjectAsJson); + await core.CallDevToolsProtocolMethodAsync("Runtime.enable", "{}"); + await NavigateAsync(core, "https://preview.mandocode.local/index.html"); + + async Task Run(string operation, string? selector = null, string? value = null, int offset = 0, int deltaY = 0, string? observe = null) + { + var json = await core.ExecuteScriptAsync(DesktopPreviewScripts.Build(new(operation, root, Selector: selector, Value: value, Offset: offset, DeltaY: deltaY, Observe: observe))); + return JsonNode.Parse(json) as JsonObject ?? throw new Exception("No result: " + json); + } + async Task Click(string selector, int count = 1) + { + // Mirrors the host loop: every repeat re-locates its target before pressing. + for (var attempt = 0; attempt < count; attempt++) + { + var target = await Run("click", selector); + Assert(target["ok"]!.GetValue(), target.ToJsonString()); + var x = target["x"]!.GetValue(); + var y = target["y"]!.GetValue(); + foreach (var type in new[] { "mouseMoved", "mousePressed", "mouseReleased" }) + await core.CallDevToolsProtocolMethodAsync("Input.dispatchMouseEvent", JsonSerializer.Serialize(new { type, x, y, button = type == "mouseMoved" ? "none" : "left", clickCount = 1 })); + } + } + async Task PressKey(string name, string? modifiers = null, int holdMs = 0) + { + Assert(DesktopPreviewKeys.TryResolve(name, out var key, out var keyModifiers, out var keyError), keyError); + Assert(DesktopPreviewKeys.TryParseModifiers(modifiers, out var mask, out var modifierError), modifierError); + await core.CallDevToolsProtocolMethodAsync("Input.dispatchKeyEvent", DesktopPreviewKeys.BuildKeyEvent(key, mask | keyModifiers, down: true)); + if (holdMs > 0) await Task.Delay(holdMs); + await core.CallDevToolsProtocolMethodAsync("Input.dispatchKeyEvent", DesktopPreviewKeys.BuildKeyEvent(key, mask | keyModifiers, down: false)); + } + + var state = await Run("inspect"); + Assert(state["ok"]!.GetValue(), "Snapshot failed"); + Assert(state["controls"]!.AsArray().Any(n => n?["selector"]?.GetValue() == "#increment"), "No usable selector"); + Assert(!state["text"]!.GetValue().Contains("INVISIBLE TEXT"), "Hidden text was included"); + Assert(state["canvasCount"]!.GetValue() == 1, "Canvas limitation not reported"); + Assert(state["nextOffset"] != null, "Control pagination missing"); + Assert((await Run("inspect", offset: 40))["controls"]!.AsArray().Count > 0, "Control pagination failed"); + Assert(!(await Run("click", ".duplicate"))["ok"]!.GetValue(), "Ambiguous click accepted"); + Assert(!(await Run("click", "#covered"))["ok"]!.GetValue(), "Covered click accepted"); + Assert(!(await Run("click", "#disabled"))["ok"]!.GetValue(), "Disabled click accepted"); + Assert(!(await Run("click", "#hidden"))["ok"]!.GetValue(), "Hidden click accepted"); + + await Click("#increment"); + var clicked = await Run("inspect", "#status"); + Assert(clicked["text"]!.GetValue() == "Count 1 trusted=true", "Browser click did not fire trusted input"); + + // A repeated click has to land every time, and a focused observation has to stay small. + await Click("#increment", 5); + var observed = await Run("observe", observe: "#status"); + Assert(observed["matched"]!.GetValue(), "Focused observation did not match"); + Assert(observed["element"]!["text"]!.GetValue() == "Count 6 trusted=true", "Repeated clicks did not all land"); + Assert(observed["controls"] == null && observed["viewport"] == null, "Focused observation returned a full snapshot"); + Assert(!(await Run("observe", observe: "#nothing-here"))["matched"]!.GetValue(), "Absent element reported as matched"); + Assert(!(await Run("observe", observe: ".duplicate"))["ok"]!.GetValue(), "Ambiguous observation accepted"); + + // Keyboard input: text entry, editing keys, Enter-to-submit, Tab focus, and a bounded hold. + Assert((await Run("focus", "#typed"))["ok"]!.GetValue(), "Focus failed"); + foreach (var character in "Hi!") await PressKey(character.ToString()); + await PressKey("Backspace"); + Assert((await Run("observe", observe: "#typed"))["element"]!["value"]!.GetValue() == "Hi", + "Typed characters or Backspace did not reach the focused input"); + Assert((await Run("inspect"))["keyboardFocus"]!.GetValue() == "#typed", "Keyboard focus was not reported"); + await Run("focus", "#term"); + await PressKey("q"); + await PressKey("Enter"); + Assert((await Run("observe", observe: "#submitted"))["text"]!.GetValue() == "submitted q", "Enter did not submit the form"); + await Run("focus", "#first"); + await PressKey("Tab"); + Assert((await Run("inspect"))["keyboardFocus"]!.GetValue() == "#second", "Tab did not move keyboard focus"); + await PressKey("ArrowRight", holdMs: 120); + Assert((await Run("observe", observe: "#held"))["text"]!.GetValue() == "up", "A held key was not released"); + var keylog = (await Run("observe", observe: "#keylog"))["text"]!.GetValue(); + Assert(keylog.Contains("[Backspace]") && keylog.Contains("[ArrowRight]") && !keylog.Contains("untrusted"), + "Key events were not delivered as trusted input: " + keylog); + await PressKey("e", "ctrl"); + Assert((await Run("observe", observe: "#keylog"))["text"]!.GetValue().Contains("[e+ctrl]"), "Modifier chord was not delivered"); + Assert((await Run("observe", observe: "#typed"))["element"]!["value"]!.GetValue() == "Hi", "A Ctrl chord typed a character"); + await Run("fill", "#name", "Ada"); + Assert((await Run("inspect", "#echo"))["text"]!.GetValue() == "Ada", "Input event missing"); + await Run("select", "#choice", "b"); + Assert((await Run("inspect", "#selection"))["text"]!.GetValue() == "b", "Select change event missing"); + Assert(!(await Run("select", "#choice", "disabled"))["ok"]!.GetValue(), "Disabled option accepted"); + + var malicious = "');window.pwned=true;//\""; + await Run("fill", "#name", malicious); + Assert((await Run("inspect", "#echo"))["text"]!.GetValue() == malicious, "Value was not treated as data"); + Assert(await core.ExecuteScriptAsync("window.pwned === undefined") == "true", "Argument injection executed"); + Assert(!(await Run("inspect", "']});window.pwned=true;//"))["ok"]!.GetValue(), "Invalid selector accepted"); + + var hover = await Run("hover", "#hover"); + await core.CallDevToolsProtocolMethodAsync("Input.dispatchMouseEvent", JsonSerializer.Serialize(new { type = "mouseMoved", x = hover["x"]!.GetValue(), y = hover["y"]!.GetValue() })); + await Task.Delay(100); + Assert((await Run("wait", "#tooltip"))["matched"]!.GetValue(), "Hover tooltip not visible"); + Assert(!(await Run("wait", "#never"))["matched"]!.GetValue(), "Absent element reported visible"); + await Run("scroll", "#footer"); + Assert((await Run("inspect"))["viewport"]!["scrollY"]!.GetValue() > 0, "Scroll did not move viewport"); + Assert(errors.Any(e => e.Contains("fixture warning")), "Browser diagnostics not received"); + + // An edited script must never come back from cache; a stale asset is what pushes people + // into adding ?v=2 cache-busting query strings to their own project files. + var assetRoot = Path.Combine(root, "cache"); + Directory.CreateDirectory(assetRoot); + File.WriteAllText(Path.Combine(assetRoot, "page.html"), ""); + try + { + File.WriteAllText(Path.Combine(assetRoot, "asset.js"), "document.body.textContent='asset v1';"); + await NavigateAsync(core, "https://preview.mandocode.local/cache/page.html"); + Assert((await Run("inspect"))["text"]!.GetValue().Contains("asset v1"), "Fixture asset did not load"); + + File.WriteAllText(Path.Combine(assetRoot, "asset.js"), "document.body.textContent='asset v2';"); + await core.CallDevToolsProtocolMethodAsync("Network.enable", "{}"); + await core.CallDevToolsProtocolMethodAsync("Network.setCacheDisabled", """{"cacheDisabled":true}"""); + await ReloadAsync(core, ignoreCache: false); + Assert((await Run("inspect"))["text"]!.GetValue().Contains("asset v2"), "A plain reload served the cached script"); + + File.WriteAllText(Path.Combine(assetRoot, "asset.js"), "document.body.textContent='asset v3';"); + await core.CallDevToolsProtocolMethodAsync("Network.setCacheDisabled", """{"cacheDisabled":false}"""); + await ReloadAsync(core, ignoreCache: true); + Assert((await Run("inspect"))["text"]!.GetValue().Contains("asset v3"), "A cache-bypassing reload served the cached script"); + } + finally { Directory.Delete(assetRoot, recursive: true); } + + await NavigateAsync(core, "https://preview.mandocode.local/second.html"); + Assert((await Run("inspect"))["text"]!.GetValue().Contains("Second page"), "Navigation did not load new DOM"); + // Snapshot execution must not accept arbitrary origins, even if the host guard is bypassed. + core.NavigateToString("

Unscoped document

"); + await Task.Delay(150); + Assert(!(await Run("inspect"))["ok"]!.GetValue(), "Non-project origin accepted"); + GC.KeepAlive(receiver); + } + + private static async Task ReloadAsync(CoreWebView2 core, bool ignoreCache) + { + var completed = new TaskCompletionSource(); + void Done(object? sender, CoreWebView2NavigationCompletedEventArgs args) => completed.TrySetResult(args.IsSuccess); + core.NavigationCompleted += Done; + try + { + if (ignoreCache) await core.CallDevToolsProtocolMethodAsync("Page.reload", """{"ignoreCache":true}"""); + else core.Reload(); + Assert(await completed.Task.WaitAsync(TimeSpan.FromSeconds(10)), "Reload failed"); + } + finally { core.NavigationCompleted -= Done; } + } + + private static async Task NavigateAsync(CoreWebView2 core, string url) + { + var completed = new TaskCompletionSource(); + void Done(object? sender, CoreWebView2NavigationCompletedEventArgs args) => completed.TrySetResult(args.IsSuccess); + core.NavigationCompleted += Done; + try + { + core.Navigate(url); + Assert(await completed.Task.WaitAsync(TimeSpan.FromSeconds(10)), "Navigation failed"); + } + finally { core.NavigationCompleted -= Done; } + } + private static void Assert(bool condition, string message) { if (!condition) throw new Exception(message); } +} diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.css b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.css new file mode 100644 index 0000000..d258b77 --- /dev/null +++ b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.css @@ -0,0 +1,3 @@ +/* Edit the color below, then refresh the preview to confirm styles reload too. */ +#css-version { color: #0b5c2e; font-weight: 700; } +#css-version::after { content: " (stylesheet v1)"; } diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.html b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.html new file mode 100644 index 0000000..ab862a5 --- /dev/null +++ b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.html @@ -0,0 +1,12 @@ +Linked asset check + + + +

Linked asset check

+

Loaded from a linked script: not loaded

+

Styled by a linked stylesheet

+

Edit assets.js or assets.css, then refresh the preview. +Both values must change without adding ?v=2 to anything.

+ + diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.js b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.js new file mode 100644 index 0000000..55116f6 --- /dev/null +++ b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.js @@ -0,0 +1,3 @@ +// Hand-test fixture for stale assets. Edit "v1" below, then ask the agent to refresh +// the preview. Without the cache fix you would keep seeing the previous value. +document.querySelector('#js-version').textContent = 'script v1'; diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/index.html b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/index.html new file mode 100644 index 0000000..a3617e2 --- /dev/null +++ b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/index.html @@ -0,0 +1,26 @@ +Browser tool fixture +

Browser tools

Count 0

+

+

a

+ + +
+
Hover here Tooltip revealed
+ +

none

+
Footer
+ diff --git a/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/second.html b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/second.html new file mode 100644 index 0000000..46cc961 --- /dev/null +++ b/src/MandoCode.Desktop.BrowserSmokeTests/fixtures/second.html @@ -0,0 +1 @@ +Second

Second page

diff --git a/src/MandoCode.Desktop.Tests/DesktopPreviewToolsTests.cs b/src/MandoCode.Desktop.Tests/DesktopPreviewToolsTests.cs new file mode 100644 index 0000000..7cbe20e --- /dev/null +++ b/src/MandoCode.Desktop.Tests/DesktopPreviewToolsTests.cs @@ -0,0 +1,201 @@ +using System.Text.Json; +using MandoCode.Desktop.Services; +using MandoCode.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +public sealed class DesktopPreviewToolsTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "MandoPreviewTests-" + Guid.NewGuid().ToString("N")); + public DesktopPreviewToolsTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + private DesktopPreviewTools Tools() => new(new ProjectRootAccessor(_root)); + private static bool Ok(string json) => JsonDocument.Parse(json).RootElement.GetProperty("ok").GetBoolean(); + + [Fact] + public async Task UnattachedPaneCannotClaimSuccess() + { + File.WriteAllText(Path.Combine(_root, "index.html"), "

Test

"); + Assert.False(Ok(await Tools().OpenDesktopPreview("index.html"))); + Assert.False(Ok(await Tools().RefreshDesktopPreview())); + } + + [Fact] + public async Task OpenWaitsForObservedNavigationResult() + { + File.WriteAllText(Path.Combine(_root, "index.html"), "

Test

"); + var tools = Tools(); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + tools.ExecuteAsync = (request, _) => + { + Assert.Equal("open", request.Operation); + Assert.Equal(Path.Combine(_root, "index.html"), request.FullPath); + return pending.Task; + }; + var result = tools.OpenDesktopPreview("index.html"); + Assert.False(result.IsCompleted); + pending.SetResult("{\"ok\":false,\"error\":\"navigation failed\"}"); + Assert.False(Ok(await result)); + } + + [Theory] + [InlineData("../outside.html")] + [InlineData("C:/outside.html")] + [InlineData("index.html:stream.html")] + [InlineData("")] + [InlineData("missing.html")] + [InlineData("readme.txt")] + [InlineData("bad\0.html")] + public async Task InvalidPathsNeverReachBrowser(string path) + { + File.WriteAllText(Path.Combine(_root, "readme.txt"), "text"); + var tools = Tools(); + tools.ExecuteAsync = (_, _) => throw new Exception("Must not dispatch"); + var result = await tools.OpenDesktopPreview(path); + Assert.False(Ok(result)); + Assert.DoesNotContain("Must not dispatch", result); + } + + [Fact] + public async Task ActionsAreSerialized_AndTimeoutDoesNotRepeatOrOverlap() + { + var tools = Tools(); + tools.OperationTimeout = TimeSpan.FromMilliseconds(100); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + tools.ExecuteAsync = (_, _) => { calls++; return pending.Task; }; + Assert.Contains("timed out", await tools.ClickDesktopPreview("#save")); + Assert.Contains("timed out", await tools.ClickDesktopPreview("#save")); + Assert.Equal(1, calls); + pending.SetResult("{\"ok\":true}"); + } + + [Fact] + public async Task CancellationIsPassedThroughToHost() + { + var tools = Tools(); + using var cancellation = new CancellationTokenSource(); + tools.ExecuteAsync = async (_, token) => { await Task.Delay(Timeout.Infinite, token); return "{}"; }; + var result = tools.InspectDesktopPreview(cancellationToken: cancellation.Token); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => result); + } + + [Fact] + public async Task SelectorsAndValuesArePassedAsData() + { + var tools = Tools(); + tools.ExecuteAsync = (request, _) => + { + Assert.Equal("fill", request.Operation); + Assert.Equal("#name", request.Selector); + Assert.Equal("');window.pwned=true;//", request.Value); + Assert.Equal(_root, request.ProjectRoot); + return Task.FromResult("{\"ok\":true}"); + }; + Assert.True(Ok(await tools.FillDesktopPreview("#name", "');window.pwned=true;//"))); + } + + [Fact] + public async Task InvalidArgumentsNeverDispatch() + { + var tools = Tools(); + var calls = 0; + tools.ExecuteAsync = (_, _) => { calls++; return Task.FromResult("{}"); }; + Assert.False(Ok(await tools.ClickDesktopPreview(""))); + Assert.False(Ok(await tools.InspectDesktopPreview(offset: -1))); + Assert.False(Ok(await tools.FillDesktopPreview("#name", new string('x', 10001)))); + Assert.False(Ok(await tools.ObserveDesktopPreview(" "))); + Assert.False(Ok(await tools.ClickDesktopPreview("#save", count: 0))); + Assert.False(Ok(await tools.ClickDesktopPreview("#save", count: DesktopPreviewTools.MaxRepeats + 1))); + Assert.Equal(0, calls); + } + + [Fact] + public async Task UnknownKeysAndModifiersNeverDispatch() + { + var tools = Tools(); + var calls = 0; + tools.ExecuteAsync = (_, _) => { calls++; return Task.FromResult("{\"ok\":true}"); }; + Assert.False(Ok(await tools.PressKeyDesktopPreview("Ctrl+Enter"))); // a chord is not a key name + Assert.False(Ok(await tools.PressKeyDesktopPreview("Enter", modifiers: "hyper"))); + Assert.False(Ok(await tools.PressKeyDesktopPreview(""))); + Assert.False(Ok(await tools.PressKeyDesktopPreview("Enter", count: DesktopPreviewTools.MaxRepeats + 1))); + Assert.False(Ok(await tools.PressKeyDesktopPreview("Enter", holdMs: DesktopPreviewTools.MaxKeyHoldMs + 1))); + Assert.False(Ok(await tools.PressKeyDesktopPreview("ArrowUp", count: 25, holdMs: 1000))); // 25s of holding + Assert.Equal(0, calls); + } + + [Fact] + public async Task ResolvedKeysReachTheHostAsData() + { + var tools = Tools(); + DesktopPreviewRequest? seen = null; + tools.ExecuteAsync = (request, _) => { seen = request; return Task.FromResult("{\"ok\":true}"); }; + + Assert.True(Ok(await tools.PressKeyDesktopPreview("Enter", selector: "#search", modifiers: "ctrl+shift"))); + Assert.Equal("key", seen!.Operation); + Assert.Equal("#search", seen.Selector); + Assert.Equal("Enter", seen.Key!.Key); + Assert.Equal(DesktopPreviewKeys.Control | DesktopPreviewKeys.Shift, seen.Modifiers); + + Assert.True(Ok(await tools.PressKeyDesktopPreview("A"))); + Assert.Equal("KeyA", seen!.Key!.Code); + Assert.Equal(DesktopPreviewKeys.Shift, seen.Modifiers); // a capital letter is a shifted key + } + + [Fact] + public async Task InterruptedRepeatsReportProgressInsteadOfReplaying() + { + var tools = Tools(); + tools.OperationTimeout = TimeSpan.FromMilliseconds(150); + var calls = 0; + tools.ExecuteAsync = async (request, token) => + { + calls++; + request.Progress!.Note(3); // the host got three clicks out before the deadline + await Task.Delay(Timeout.Infinite, token); + return "{}"; + }; + var result = await tools.ClickDesktopPreview("#increment", count: 10); + Assert.False(Ok(result)); + Assert.Contains("3 of 10 repeats completed", result); + Assert.Contains("not replayed", result); + Assert.Equal(1, calls); + } + + [Fact] + public async Task FocusedObservationsRideAlongWithActions() + { + var tools = Tools(); + DesktopPreviewRequest? seen = null; + tools.ExecuteAsync = (request, _) => { seen = request; return Task.FromResult("{\"ok\":true}"); }; + Assert.True(Ok(await tools.ClickDesktopPreview("#increment", observe: "#count"))); + Assert.Equal("#count", seen!.Observe); + Assert.True(Ok(await tools.ObserveDesktopPreview("#count"))); + Assert.Equal("observe", seen!.Operation); + Assert.Equal("#count", seen.Observe); + Assert.Null(seen.Selector); + } + + [Theory] + [InlineData("Enter", "\\r", "keyDown")] // a text key inserts its character + [InlineData("ArrowUp", "\"text\":\"\"", "rawKeyDown")] // a navigation key must not insert one + public void KeyEventsCarryTheBrowserFields(string name, string expectedText, string expectedType) + { + Assert.True(DesktopPreviewKeys.TryResolve(name, out var key, out _, out _)); + var payload = DesktopPreviewKeys.BuildKeyEvent(key, 0, down: true); + Assert.Contains(expectedText, payload); + Assert.Contains($"\"type\":\"{expectedType}\"", payload); + Assert.Contains("\"type\":\"keyUp\"", DesktopPreviewKeys.BuildKeyEvent(key, 0, down: false)); + } + + [Fact] + public void ModifierChordsDoNotAlsoTypeTheCharacter() + { + Assert.True(DesktopPreviewKeys.TryResolve("a", out var key, out _, out _)); + Assert.Contains("\"text\":\"a\"", DesktopPreviewKeys.BuildKeyEvent(key, DesktopPreviewKeys.Shift, down: true)); + Assert.Contains("\"text\":\"\"", DesktopPreviewKeys.BuildKeyEvent(key, DesktopPreviewKeys.Control, down: true)); + } +} diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index 2231d07..77eb5e4 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -32,6 +32,9 @@ a design regression this project exists to catch. --> + + +