using System.Globalization; using Jint; namespace DodoSSH.Client.Terminal.Tests; /// /// One load of terminal.js, in a fake browser, for one test. /// /// /// /// The page's own source is what runs. 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. /// /// /// Jint rather than node, and that is a deliberate trade. 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 /// dotnet test 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 /// docs/manual-checks.md picks up — see 1.10 and 11.12a. /// /// /// 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. /// /// internal sealed class RendererPage { private readonly Engine engine; private RendererPage(Engine engine) => this.engine = engine; /// How many sockets the page has opened since it loaded. /// /// 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. /// public int Attempts => (int)engine.Evaluate("attempts()").AsNumber(); /// What the status element says — the banner a user would be looking at. public string Banner => engine.Evaluate("banner()").AsString(); /// /// Every frame the page has sent, each as opcode/session/payload. /// /// /// Materialised on each read rather than cached, because the point of it is what the page did in answer /// to the last . A session's own resize frames are in here too and read as rubbish; see /// the harness's sentFrame() for why they are not rendered some other way. /// public IReadOnlyList Sent { get { var count = (int)engine.Evaluate("sentFrames()").AsNumber(); var frames = new List(count); for (var index = 0; index < count; index++) { frames.Add( engine .Evaluate(string.Create(CultureInfo.InvariantCulture, $"sentFrame({index})")) .AsString()); } return frames; } } /// How many times the last right-click called preventDefault, and stopPropagation. /// /// 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. /// public int Prevented => (int)engine.Evaluate("prevented()").AsNumber(); /// public int Stopped => (int)engine.Evaluate("stopped()").AsNumber(); /// What is selected in that session's pane. public string Selection(uint sessionId) => engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"selection({sessionId})")).AsString(); /// Everything term.paste was given for that session — the bracketed route. public string Pasted(uint sessionId) => engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"pasted({sessionId})")).AsString(); /// Everything term.input was given for that session — the typed route. public string Typed(uint sessionId) => engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"typed({sessionId})")).AsString(); /// How many times that session's pane has been asked for the caret. public int Focuses(uint sessionId) => (int)engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"focuses({sessionId})")).AsNumber(); /// /// 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. /// 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); } /// Runs a line of the harness's own vocabulary — accept(0), advance(1000). public void Do(string script) => engine.Execute(script); /// Whether that attempt has been closed, by the page or by the far end. public bool IsClosed(int attempt) => engine.Evaluate( string.Create(CultureInfo.InvariantCulture, $"isClosed({attempt})")) .AsBoolean(); /// /// 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. /// private static string Read(string relativePath) => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, relativePath)); }