Public Access
Selecting output and right-clicking it opened the WebView's own context menu with Copy on it, which is correct behaviour for a web page and wrong for a terminal. What a terminal's right button has meant since the Windows console got QuickEdit, and since PuTTY shipped, is this: with something selected it copies and drops the selection, with nothing selected it pastes. No menu, no second click. The gesture can only live in the page. The selection is drawn into a canvas the host never sees, and once a terminal has focus the host's window receives no input events at all — the same reason the Ctrl+plus chords are handled there. ◆ THE CLIPBOARD IS THE HOST'S IN BOTH DIRECTIONS, AND THE READING IS WHY. navigator.clipboard.readText() is gated behind the clipboard-read permission in every Chromium, and an embedded WebView with no PermissionRequested handler answers a permission request by putting a prompt on the screen. Trading a context menu for a permission dialog is not this change. So the page asks and the host answers, exactly as requestFontSizeStep does. Writing does not go the other way either, although navigator.clipboard.writeText would have worked from the page on Windows: a clipboard that is the host's in one direction and the page's in the other is two mechanisms to keep working across WebView2, WebKitGTK and Android's WebView. The host has owned a clipboard delegate since the keychain grew its COPY buttons, so each App.axaml.cs grows a ClipboardReader beside its ClipboardWriter and MainWindowViewModel takes it. Three opcodes: client ClipboardCopy and ClipboardPaste, server Clipboard. ◆ Clipboard IS NOT Paste, AND THE DIFFERENCE IS THE BRACKETING. Paste carries a snippet off the sidebar, and the page deliberately *types* a single-line one rather than bracketing it, because bash marks a bracketed paste as an active region and a snippet should read as though it had been typed. A clipboard paste is the case those markers are for — readline uses them to decide it has been pasted into — so Clipboard is always bracketed and never followed by an Enter. One opcode for both would have needed a flag meaning "which of the two kinds of paste is this", on a payload whose existing flag byte means something else. Both client frames are dispatched before the session lookup, which is what keeps the gesture working in a pane whose shell has just ended: squinting at the message a remote left behind is exactly when somebody copies the line. Dispatch was past MA0051's length once it grew a second such branch, so the frames no pump needs to see moved into Announce() and the font-size chord went with them. ◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED. preventDefault alone suppresses the menu and still lets xterm's own contextmenu handler run, which moves its helper textarea under the pointer at z-index 1000 and leaves the selection sitting in it, selected. Both of those exist to serve the native menu. Taking the event in the capture phase on the pane and stopping it there is what keeps the gesture wholly the page's. rightClickSelectsWord is now false, which on macOS is a change of default rather than a restatement of it: xterm sets it true there alone, so a right-click would have selected the word under the pointer and copied that instead of pasting — wrong on one platform only, which is the worst way for it to be wrong. The renderer harness grows the pane it has been inviting since RendererReconnectionTests: a fake Terminal, the two addons, and a real UTF-8 codec, because both directions run through the page's own TextEncoder and TextDecoder and a Latin-1 stub would pass a test with an em dash in the selection. Its warning about declaring names terminal.js declares now covers the half that does not SyntaxError — a harness frame() was silently replaced by the page's frame() and failed six tests on a type error a long way from the cause. Manual checks 1.11 and 11.13 are what is left for a person: that a real contextmenu event reaches a capture listener, that no native menu is drawn once it is prevented, and that xterm's real paste() brackets. 11.13 also records what is unverified — whether Android's WebView delivers a pointer's secondary click as a contextmenu event at all. ClipboardReader is wired on that head regardless, because the alternative is a head where the paste half silently does nothing and nobody finds out; that head is not compiled here either, this machine having no android workload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
6.3 KiB
C#
134 lines
6.3 KiB
C#
using System.Globalization;
|
|
|
|
using Jint;
|
|
|
|
namespace DodoSSH.Client.Terminal.Tests;
|
|
|
|
/// <summary>
|
|
/// One load of <c>terminal.js</c>, in a fake browser, for one test.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The page's own source is what runs.</b> Not a transcription of its logic into C# — that would test a
|
|
/// copy, and the copy would be the thing that stayed correct. The file is read from the shell project and
|
|
/// evaluated as it ships, so a change to the page that breaks the reconnection rules fails here.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Jint rather than node, and that is a deliberate trade.</b> A node script would be the obvious way to
|
|
/// run JavaScript and would need node on every machine and in every CI job that runs the suite — so it
|
|
/// would be a second test command, run separately, and the first thing to be forgotten. This runs inside
|
|
/// <c>dotnet test</c> with everything else. What it costs is that the engine is not the engine the page
|
|
/// actually runs in: Jint is not Chromium, so this can prove the page's own logic and can prove nothing
|
|
/// about how WebView2 or Android's WebView behave. That boundary is exactly where
|
|
/// <c>docs/manual-checks.md</c> picks up — see 1.10 and 11.12a.
|
|
/// </para>
|
|
/// <para>
|
|
/// A fresh engine per test, because the page is a script with module-level state and there is no unloading
|
|
/// it: two tests sharing one engine would share a socket list, a clock and a backoff.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class RendererPage
|
|
{
|
|
private readonly Engine engine;
|
|
|
|
private RendererPage(Engine engine) => this.engine = engine;
|
|
|
|
/// <summary>How many sockets the page has opened since it loaded.</summary>
|
|
/// <remarks>
|
|
/// The measurement nearly every test here turns on, and it is deliberately a count of *attempts*
|
|
/// rather than of live sockets: the failures being guarded against are a page that stops trying and a
|
|
/// page that never stops, and both are counted rather than observed.
|
|
/// </remarks>
|
|
public int Attempts => (int)engine.Evaluate("attempts()").AsNumber();
|
|
|
|
/// <summary>What the status element says — the banner a user would be looking at.</summary>
|
|
public string Banner => engine.Evaluate("banner()").AsString();
|
|
|
|
/// <summary>
|
|
/// Every frame the page has sent, each as <c>opcode/session/payload</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Materialised on each read rather than cached, because the point of it is what the page did in answer
|
|
/// to the last <see cref="Do"/>. A session's own resize frames are in here too and read as rubbish; see
|
|
/// the harness's <c>sentFrame()</c> for why they are not rendered some other way.
|
|
/// </remarks>
|
|
public IReadOnlyList<string> Sent
|
|
{
|
|
get
|
|
{
|
|
var count = (int)engine.Evaluate("sentFrames()").AsNumber();
|
|
var frames = new List<string>(count);
|
|
|
|
for (var index = 0; index < count; index++)
|
|
{
|
|
frames.Add(
|
|
engine
|
|
.Evaluate(string.Create(CultureInfo.InvariantCulture, $"sentFrame({index})"))
|
|
.AsString());
|
|
}
|
|
|
|
return frames;
|
|
}
|
|
}
|
|
|
|
/// <summary>How many times the last right-click called <c>preventDefault</c>, and <c>stopPropagation</c>.</summary>
|
|
/// <remarks>
|
|
/// Both, because they answer different questions: the first is whether the WebView's own context menu
|
|
/// was suppressed, and the second is whether xterm's own right-click handling was — which is what keeps
|
|
/// a 20-pixel textarea from being parked under the pointer with the selection sitting in it.
|
|
/// </remarks>
|
|
public int Prevented => (int)engine.Evaluate("prevented()").AsNumber();
|
|
|
|
/// <inheritdoc cref="Prevented" />
|
|
public int Stopped => (int)engine.Evaluate("stopped()").AsNumber();
|
|
|
|
/// <summary>What is selected in that session's pane.</summary>
|
|
public string Selection(uint sessionId) =>
|
|
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"selection({sessionId})")).AsString();
|
|
|
|
/// <summary>Everything <c>term.paste</c> was given for that session — the bracketed route.</summary>
|
|
public string Pasted(uint sessionId) =>
|
|
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"pasted({sessionId})")).AsString();
|
|
|
|
/// <summary>Everything <c>term.input</c> was given for that session — the typed route.</summary>
|
|
public string Typed(uint sessionId) =>
|
|
engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"typed({sessionId})")).AsString();
|
|
|
|
/// <summary>How many times that session's pane has been asked for the caret.</summary>
|
|
public int Focuses(uint sessionId) =>
|
|
(int)engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"focuses({sessionId})")).AsNumber();
|
|
|
|
/// <summary>
|
|
/// Loads the harness and then the page, leaving the page exactly as it is a moment after the WebView
|
|
/// navigated to it: one socket opened and still connecting.
|
|
/// </summary>
|
|
public static RendererPage Load()
|
|
{
|
|
var browser = new Engine();
|
|
|
|
// Order matters and is not incidental: the page connects on its last line, so every global it
|
|
// touches — the socket constructor above all — has to be in place before it is evaluated.
|
|
browser.Execute(Read("Renderer/renderer-harness.js"));
|
|
browser.Execute(Read("Renderer/terminal.js"));
|
|
|
|
return new RendererPage(browser);
|
|
}
|
|
|
|
/// <summary>Runs a line of the harness's own vocabulary — <c>accept(0)</c>, <c>advance(1000)</c>.</summary>
|
|
public void Do(string script) => engine.Execute(script);
|
|
|
|
/// <summary>Whether that attempt has been closed, by the page or by the far end.</summary>
|
|
public bool IsClosed(int attempt) =>
|
|
engine.Evaluate(
|
|
string.Create(CultureInfo.InvariantCulture, $"isClosed({attempt})"))
|
|
.AsBoolean();
|
|
|
|
/// <remarks>
|
|
/// Both files are copied beside the test assembly by the project file, the page out of the shell
|
|
/// project it belongs to. Read from disk rather than embedded so that the copy which runs here is the
|
|
/// same bytes the host serves, with nothing in between that could go stale.
|
|
/// </remarks>
|
|
private static string Read(string relativePath) =>
|
|
File.ReadAllText(Path.Combine(AppContext.BaseDirectory, relativePath));
|
|
}
|