Public Access
578 lines
19 KiB
JavaScript
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;
|