From c7b916fa04b2ee413186b4f987d8bee34315a139 Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Sun, 6 Sep 2026 15:53:07 -0700 Subject: [PATCH] Add an interactive browser preview the agent can drive The agent can now use the preview pane as a real browser to check its own work on a web page. It reads page text and controls, clicks, hovers, presses keys, fills forms, selects options, scrolls, waits for elements, and reads browser errors. Pointer and keyboard input are dispatched as genuine browser events, so a page cannot tell them from a person. Edited assets always load. A refresh could previously redisplay a cached script or stylesheet, so a review would pass against the previous version of a change. The preview's cache is disabled and refreshes ask for a cache-bypassing reload, so project files never need cache-busting query strings to preview correctly. Keyboard input covers Enter to submit, Tab to move focus, arrow and letter keys for games and canvas apps, and typeahead fields that only react to key events. Keys can be held for a bounded time and are always released before the call returns, so none is ever left down between calls. Repeated clicks and key presses take a count instead of a call each. Every repeat re-locates its target, and an interrupted batch reports how many completed rather than replaying from the start. Any action can name one element to read back instead of returning a whole page snapshot, which keeps a sequence of checks affordable. Only project files can be opened. The page operations are fixed rather than arbitrary script evaluation, agent input is passed as data, external navigation, popups and downloads are blocked during agent use, and unsaved user edits are never overwritten. Page content is treated as untrusted observation. Verified by unit tests and an opt-in WebView2 integration harness that drives a real browser through typing, form submission, keyboard focus movement, repeated clicking, and an edited script reloading. Co-Authored-By: Claude Opus 5 (1M context) --- MandoCode | 2 +- README.md | 4 + docs/browser-tools.md | 113 +++++ .../BrowserSmokeTests.csproj | 24 ++ .../Program.cs | 203 +++++++++ .../fixtures/assets.css | 3 + .../fixtures/assets.html | 12 + .../fixtures/assets.js | 3 + .../fixtures/index.html | 26 ++ .../fixtures/second.html | 1 + .../DesktopPreviewToolsTests.cs | 201 +++++++++ .../MandoCode.Desktop.Tests.csproj | 3 + .../Controls/ChatTabView.BrowserTools.cs | 404 ++++++++++++++++++ .../Controls/ChatTabView.Explorer.cs | 36 +- .../Controls/ChatTabView.xaml.cs | 14 +- .../Services/AgentSession.cs | 27 ++ .../Services/AiServiceAdapter.cs | 1 + .../Services/DesktopPreviewKeys.cs | 186 ++++++++ .../Services/DesktopPreviewScripts.cs | 136 ++++++ .../Services/DesktopPreviewTools.cs | 202 ++++++++- src/MandoCode.Desktop/Services/IAiService.cs | 1 + .../ViewModels/ChatController.cs | 1 + 22 files changed, 1562 insertions(+), 41 deletions(-) create mode 100644 docs/browser-tools.md create mode 100644 src/MandoCode.Desktop.BrowserSmokeTests/BrowserSmokeTests.csproj create mode 100644 src/MandoCode.Desktop.BrowserSmokeTests/Program.cs create mode 100644 src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.css create mode 100644 src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.html create mode 100644 src/MandoCode.Desktop.BrowserSmokeTests/fixtures/assets.js create mode 100644 src/MandoCode.Desktop.BrowserSmokeTests/fixtures/index.html create mode 100644 src/MandoCode.Desktop.BrowserSmokeTests/fixtures/second.html create mode 100644 src/MandoCode.Desktop.Tests/DesktopPreviewToolsTests.cs create mode 100644 src/MandoCode.Desktop/Controls/ChatTabView.BrowserTools.cs create mode 100644 src/MandoCode.Desktop/Services/DesktopPreviewKeys.cs create mode 100644 src/MandoCode.Desktop/Services/DesktopPreviewScripts.cs 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. --> + + +