Files
DodoSSH/tests/DodoSSH.Client.Terminal.Tests/Renderer/renderer-harness.js
T
jaap-jan 6806e542f9
ci / build and test (push) Successful in 3m37s
ci / android head (push) Successful in 3m45s
ci / api image (push) Successful in 8s
ci / desktop nightly (push) Successful in 1m44s
Terminal: measure characters with Unicode 11 widths, so an emoji takes the two columns the remote counts
2026-10-04 13:55:20 +02:00

578 lines
19 KiB
JavaScript

'use strict';
/*
The world terminal.js is loaded into by RendererReconnectionTests.
It fakes exactly what the page's transport touches and nothing else: a WebSocket whose every step is
driven from the test, a clock that only moves when a test moves it, and the two DOM objects the page
reads at load.
◆ AND, SINCE RendererClipboardTests, A PANE.
Which the reconnection tests never needed and this file used to say so, inviting whoever first opened a
session to add the Terminal, the fit addon and the WebGL addon rather than guessing at them in advance.
They are below, and they are stubs with a memory: a fake terminal records what was pasted into it, what
was typed into it and what it was asked to focus, because those three are the whole of what the page does
to a pane and therefore the whole of what a test can ask about. The selection is settable from a test,
since a selection is made with a mouse in a canvas and there is no canvas here.
What a fake terminal is NOT is xterm. It cannot say whether paste() brackets its argument or whether
getSelection() trims a row's right padding — that is xterm's behaviour, exercised by xterm's own suite and
by docs/manual-checks.md 1.11 here. What these tests can prove is which of the two the page calls, with
what, and addressed to which session, and that is where every mistake in this area has actually been.
◆ NOTHING HERE MAY DECLARE A NAME terminal.js ALSO DECLARES.
Both files are evaluated as scripts into the same global scope, and a collision goes one of two ways.
Against one of the page's `const`s — its own `root`, say — it is a SyntaxError before a line of either
runs, which is the good outcome: it is impossible to miss. That is why the page's two elements are
`pageRoot` and `pageBanner` below and reached through getElementById, which is how the page reaches them
anyway.
Against one of the page's *functions* there is no error at all. The page is evaluated second, so its
declaration silently replaces this file's and every test calling that name gets the page's function
instead — which is how `frame()` here, meant to read a sent frame back, quietly returned the ArrayBuffer
that terminal.js's own frame() builds instead, and failed six tests on a type error a long way from the
cause. Hence `sentFrames`/`sentFrame` below: prefixed on purpose, not for tidiness.
◆ AND THE FAKE SOCKET'S BEHAVIOUR IS THE PART TO GET RIGHT.
close() is the one method with a rule that is not obvious and that the tests lean on: a socket closed
while it is still CONNECTING never fires 'close' at all, per the WebSocket specification, while one
closed after it opened does. The page relies on exactly that — connect() closes the attempt it abandons
and expects to hear nothing back from it — so a fake that fired 'close' either way would report the loop
the page's guards exist to prevent, and one that fired it neither way would hide it.
*/
var clock = { now: 0, next: 1, timers: {} };
/** Every socket the page has opened, in order, live or dead. */
var sockets = [];
/** Every frame the page has sent, in order, as arrays of byte values. */
var outbound = [];
/** Every fake terminal the page has opened on a pane, in the order it opened them. */
var terminals = [];
/** What the last right-click did about the browser's own menu. See rightClick(). */
var gesture = { prevented: 0, stopped: 0 };
function listeners(target) {
target.handlers = {};
target.addEventListener = function (name, handler) {
if (!target.handlers[name]) {
target.handlers[name] = [];
}
target.handlers[name].push(handler);
};
target.fire = function (name, event) {
var handlers = target.handlers[name] || [];
for (var i = 0; i < handlers.length; i++) {
handlers[i](event || {});
}
};
return target;
}
function FakeSocket(url, protocols) {
this.url = url;
this.protocols = protocols;
this.readyState = FakeSocket.CONNECTING;
this.binaryType = '';
listeners(this);
sockets.push(this);
}
FakeSocket.CONNECTING = 0;
FakeSocket.OPEN = 1;
FakeSocket.CLOSING = 2;
FakeSocket.CLOSED = 3;
/**
* Records rather than discards, because half of what the page does on a right-click is a frame.
*
* Kept as raw bytes and decoded on the way out — see sentFrame() — so that a payload the page wrote as
* UTF-8 is read back as UTF-8 rather than as whatever a stringification would have made of it.
*/
FakeSocket.prototype.send = function (buffer) {
var bytes = new Uint8Array(buffer);
var copy = [];
for (var i = 0; i < bytes.length; i++) {
copy.push(bytes[i]);
}
outbound.push(copy);
};
/** What the page calls. See the note above on why a connecting socket goes quietly. */
FakeSocket.prototype.close = function () {
if (this.readyState === FakeSocket.CLOSED) {
return;
}
var wasConnecting = this.readyState === FakeSocket.CONNECTING;
this.readyState = FakeSocket.CLOSED;
if (!wasConnecting) {
this.fire('close');
}
};
var pageBanner = { textContent: 'Connecting…' };
var pageRoot = listeners({
dataset: { token: 'test-token', socket: 'ws://127.0.0.1:1/socket' },
appendChild: function () {},
});
var document = listeners({
hidden: false,
getElementById: function (id) { return id === 'root' ? pageRoot : pageBanner; },
createElement: function () { return newElement(); },
});
/**
* A pane, as far as the page can tell.
*
* With real dimensions, and not because anything would fail without them — which is the point. The page's
* guard is `clientWidth < MINIMUM_FITTABLE_PIXELS`, and an undefined width compares false against every
* number, so a fake with no dimensions slips past a rule it ought to have been caught by and gets fitted
* anyway. A fake that passes for the wrong reason stops matching the page the day that guard is written
* differently.
*
* Listeners, because the right-click handler is registered on this and there is nowhere else to fire it
* from.
*/
function newElement() {
return listeners({
dataset: {},
style: {},
clientWidth: 800,
clientHeight: 600,
remove: function () {},
});
}
var window = listeners({});
// The page warns through this on paths no reconnection test reaches. Present so that a test which does
// reach one fails on its own assertion rather than on a missing global.
var console = { warn: function () {}, log: function () {} };
function setTimeout(callback, delay) {
var id = clock.next++;
clock.timers[id] = { at: clock.now + (delay || 0), callback: callback };
return id;
}
function clearTimeout(id) {
delete clock.timers[id];
}
function ResizeObserver() {
this.observe = function () {};
}
// ── The pane's three globals, which the page reaches by the names the UMD builds attach ──────────────
/**
* A terminal that remembers what was done to it.
*
* `selection` is a plain field rather than anything computed, and settable from a test through select():
* a selection is made by dragging a mouse across a canvas, which is the one part of this there is no
* standing in for. What the tests are about is what the page does with whatever is selected.
*/
function FakeTerminal(options) {
this.options = options || {};
this.cols = 80;
this.rows = 24;
this.selection = '';
this.pastes = [];
this.inputs = [];
this.writes = [];
this.focuses = 0;
this.disposed = false;
this.pane = null;
this.addons = [];
// The proposed unicode API, at xterm's own default, so a test can see which table the page picked.
this.unicode = { activeVersion: '6' };
terminals.push(this);
}
FakeTerminal.prototype.loadAddon = function (addon) { this.addons.push(addon); };
FakeTerminal.prototype.open = function (pane) {
this.pane = pane;
// Taken at open() because that is when the first frame is laid out: a table chosen afterwards would leave
// whatever was drawn first measured with the old one.
this.unicodeAtOpen = this.unicode.activeVersion;
};
FakeTerminal.prototype.attachCustomKeyEventHandler = function (handler) { this.keys = handler; };
FakeTerminal.prototype.onData = function (handler) { this.data = handler; };
FakeTerminal.prototype.onResize = function (handler) { this.resized = handler; };
FakeTerminal.prototype.focus = function () { this.focuses++; };
FakeTerminal.prototype.paste = function (text) { this.pastes.push(text); };
FakeTerminal.prototype.input = function (text) { this.inputs.push(text); };
FakeTerminal.prototype.getSelection = function () { return this.selection; };
FakeTerminal.prototype.clearSelection = function () { this.selection = ''; };
FakeTerminal.prototype.dispose = function () { this.disposed = true; };
/**
* The write callback is the acknowledgement, so it is called rather than kept.
*
* Synchronously, which a real xterm does not promise — it renders first. The page's contract is that the
* acknowledgement is sent from this callback and not before, and calling it straight away is what lets a
* test assert that without needing a frame clock.
*/
FakeTerminal.prototype.write = function (data, done) {
this.writes.push(data);
if (done) {
done();
}
};
var Terminal = FakeTerminal;
var FitAddon = {
FitAddon: function () {
this.fit = function () {};
},
};
var Unicode11Addon = {
Unicode11Addon: function () {
this.kind = 'unicode11';
},
};
var WebglAddon = {
WebglAddon: function () {
this.onContextLoss = function () {};
this.dispose = function () {};
},
};
// ── UTF-8, because the page encodes and decodes every payload through these ──────────────────────────
//
// Written out rather than stubbed as one byte per character. The page sends a selection as UTF-8 and reads
// a clipboard as UTF-8, so a harness that treated the two as Latin-1 would pass a test with an em dash in
// the selection and ship a page that mangled it.
function TextEncoder() {
this.encode = function (text) {
var bytes = [];
for (var i = 0; i < text.length; i++) {
var point = text.charCodeAt(i);
// A surrogate pair is one code point in two units. Combined here, because encoding each half on its
// own is exactly the mojibake this exists to catch.
if (point >= 0xd800 && point <= 0xdbff && i + 1 < text.length) {
var low = text.charCodeAt(i + 1);
if (low >= 0xdc00 && low <= 0xdfff) {
point = 0x10000 + ((point - 0xd800) << 10) + (low - 0xdc00);
i++;
}
}
if (point < 0x80) {
bytes.push(point);
} else if (point < 0x800) {
bytes.push(0xc0 | (point >> 6), 0x80 | (point & 0x3f));
} else if (point < 0x10000) {
bytes.push(0xe0 | (point >> 12), 0x80 | ((point >> 6) & 0x3f), 0x80 | (point & 0x3f));
} else {
bytes.push(
0xf0 | (point >> 18),
0x80 | ((point >> 12) & 0x3f),
0x80 | ((point >> 6) & 0x3f),
0x80 | (point & 0x3f));
}
}
return new Uint8Array(bytes);
};
}
function TextDecoder() {
this.decode = function (bytes) {
var text = '';
for (var i = 0; i < bytes.length;) {
var first = bytes[i];
var length = first < 0x80 ? 1 : first < 0xe0 ? 2 : first < 0xf0 ? 3 : 4;
var point = length === 1 ? first : first & (0x7f >> length);
for (var n = 1; n < length; n++) {
point = (point << 6) | (bytes[i + n] & 0x3f);
}
i += length;
if (point > 0xffff) {
point -= 0x10000;
text += String.fromCharCode(0xd800 + (point >> 10), 0xdc00 + (point & 0x3ff));
} else {
text += String.fromCharCode(point);
}
}
return text;
};
}
// ── What the tests drive the page with ──────────────────────────────────────────────────────────────
/**
* Runs every timer due within the next `ms`, in the order they fall due.
*
* One at a time and re-scanned each round rather than collected up front, because a timer's callback
* routinely schedules the next one — which is the whole shape of the page's retry — and a snapshot taken
* before the first callback ran would miss it.
*/
function advance(ms) {
var target = clock.now + ms;
for (;;) {
var dueId = null;
var due = null;
for (var id in clock.timers) {
var timer = clock.timers[id];
if (timer.at <= target && (due === null || timer.at < due.at)) {
due = timer;
dueId = id;
}
}
if (due === null) {
break;
}
delete clock.timers[dueId];
clock.now = due.at;
due.callback();
}
clock.now = target;
}
/** How many sockets the page has opened since it loaded. */
function attempts() {
return sockets.length;
}
/** What the one status element says — the banner the user sees. */
function banner() {
return pageBanner.textContent;
}
/** How many frames the page has sent since it loaded. */
function sentFrames() {
return outbound.length;
}
/**
* One frame the page sent, as "opcode/session/payload", the payload decoded as UTF-8.
*
* One string rather than three values because it crosses into C# as one, and a test asserting on
* "5/7/whoami" reads as the frame it is. Resize frames land here too and read as rubbish; nothing asserts
* on those, and rendering them some other way would mean a second shape for a reader to learn.
*/
function sentFrame(index) {
var bytes = outbound[index];
var session = ((bytes[1] << 24) | (bytes[2] << 16) | (bytes[3] << 8) | bytes[4]) >>> 0;
return bytes[0] + '/' + session + '/' + new TextDecoder().decode(bytes.slice(5));
}
/** Hands the page a frame, on whichever socket is its current one. */
function deliverFrame(opcode, sessionId, payload) {
var bytes = new Uint8Array(5 + payload.length);
var header = new DataView(bytes.buffer);
header.setUint8(0, opcode);
header.setUint32(1, sessionId);
bytes.set(payload, 5);
sockets[sockets.length - 1].fire('message', { data: bytes.buffer });
}
/** SessionOpened, which is what makes the page build a pane. */
function openSession(sessionId) {
deliverFrame(2, sessionId, []);
}
/** Clipboard: the host's answer to a right-click paste. */
function deliverClipboard(sessionId, text) {
deliverFrame(8, sessionId, new TextEncoder().encode(text));
}
/** Paste: a snippet off the sidebar, which is deliberately not the same frame. */
function deliverSnippet(sessionId, text, execute) {
var body = new TextEncoder().encode(text);
var payload = new Uint8Array(1 + body.length);
payload[0] = execute ? 1 : 0;
payload.set(body, 1);
deliverFrame(6, sessionId, payload);
}
/** The pane behind one session, found the way the page labelled it. */
function terminalFor(sessionId) {
for (var i = 0; i < terminals.length; i++) {
if (terminals[i].pane && terminals[i].pane.dataset.sessionId === String(sessionId)) {
return terminals[i];
}
}
throw new Error('No pane for session ' + sessionId + '.');
}
/** Drags a selection across that pane, which is the one thing a fake terminal cannot do for itself. */
function select(sessionId, text) {
terminalFor(sessionId).selection = text;
}
/** What is selected in that pane now — how a test sees that a copy dropped it. */
function selection(sessionId) {
return terminalFor(sessionId).selection;
}
/**
* The right button, over that pane.
*
* The event carries only the two methods the page calls on it, and counts the calls: suppressing the
* browser's own menu is half of what this gesture has to do, and a handler that copied correctly and left
* the menu up would be a regression no assertion about frames could see.
*/
function rightClick(sessionId) {
gesture = { prevented: 0, stopped: 0 };
terminalFor(sessionId).pane.fire('contextmenu', {
preventDefault: function () { gesture.prevented++; },
stopPropagation: function () { gesture.stopped++; },
});
}
/**
* The right button somewhere that is not a pane — the status banner, in practice.
*
* Fired on the document rather than on an element, which is where the page's fallback rule lives: a pane
* stops the event before it gets here, so this is the path only a click that missed every pane takes.
*/
function rightClickOffAPane() {
gesture = { prevented: 0, stopped: 0 };
document.fire('contextmenu', {
preventDefault: function () { gesture.prevented++; },
stopPropagation: function () { gesture.stopped++; },
});
}
/** Whether the last right-click took the event away from the browser, and from xterm. */
function prevented() {
return gesture.prevented;
}
function stopped() {
return gesture.stopped;
}
/** Everything paste() was given for that pane, joined — the bracketed route. */
function pasted(sessionId) {
return terminalFor(sessionId).pastes.join('|');
}
/** Everything input() was given for that pane, joined — the typed route. */
function typed(sessionId) {
return terminalFor(sessionId).inputs.join('|');
}
/** Which character-width table the pane was opened with. */
function unicodeAtOpen(sessionId) {
return terminalFor(sessionId).unicodeAtOpen;
}
/** Whether that pane loaded the Unicode 11 addon. */
function hasUnicode11(sessionId) {
return terminalFor(sessionId).addons.some(function (addon) { return addon.kind === 'unicode11'; });
}
/** How many times that pane has been asked for the caret. */
function focuses(sessionId) {
return terminalFor(sessionId).focuses;
}
/** Whether that attempt has been closed, by the page or by the far end. */
function isClosed(index) {
return sockets[index].readyState === FakeSocket.CLOSED;
}
/** The host accepted the upgrade. */
function accept(index) {
sockets[index].readyState = FakeSocket.OPEN;
sockets[index].fire('open');
}
/** An established socket goes away and the page is told. */
function drop(index) {
sockets[index].readyState = FakeSocket.CLOSED;
sockets[index].fire('close');
}
/** An attempt that never connects: 'error' then 'close', as a refused connection reports itself. */
function fail(index) {
sockets[index].readyState = FakeSocket.CLOSED;
sockets[index].fire('error');
sockets[index].fire('close');
}
/**
* A close arriving for a socket that died earlier — the host's takeover abort, landing late.
*
* Separate from drop() because the point of it is the delay: the socket is already dead by the time the
* event is delivered, which is what a suspended renderer's queued events look like on the way back.
*/
function deliverLateClose(index) {
sockets[index].readyState = FakeSocket.CLOSED;
sockets[index].fire('close');
}
/** The screen the page is on goes away, and comes back. */
function becomeHidden() {
document.hidden = true;
document.fire('visibilitychange');
}
function becomeVisible() {
document.hidden = false;
document.fire('visibilitychange');
}
function takeFocus() {
window.fire('focus');
}
function comeOnline() {
window.fire('online');
}
var WebSocket = FakeSocket;