Merge pull request 'Reach the picker on a new socket, not a new page' (#411) from web-reconnect into session-switcher
tests / confined (mirror) (pull_request_target) Successful in 1m15s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (launch) (pull_request_target) Successful in 2m42s
tests / confined (share) (pull_request_target) Successful in 2m29s
tests / confined (shellcheck) (pull_request_target) Successful in 1m46s
tests / runner-env (confined) (pull_request_target) Successful in 1m13s
tests / confined (remote) (pull_request_target) Successful in 4m34s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / privileged (pull_request_target) Successful in 6m0s
tests / confined (audit) (pull_request_target) Successful in 4m35s
tests / confined (static) (pull_request_target) Successful in 4m23s
tests / confined (config) (pull_request_target) Successful in 6m24s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
tests / confined (mirror) (pull_request_target) Successful in 1m15s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / confined (launch) (pull_request_target) Successful in 2m42s
tests / confined (share) (pull_request_target) Successful in 2m29s
tests / confined (shellcheck) (pull_request_target) Successful in 1m46s
tests / runner-env (confined) (pull_request_target) Successful in 1m13s
tests / confined (remote) (pull_request_target) Successful in 4m34s
tests / confined (project) (pull_request_target) Successful in 5m21s
tests / privileged (pull_request_target) Successful in 6m0s
tests / confined (audit) (pull_request_target) Successful in 4m35s
tests / confined (static) (pull_request_target) Successful in 4m23s
tests / confined (config) (pull_request_target) Successful in 6m24s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
Reviewed-on: #411 Reviewed-by: branderson <brad@branderson.io>
This commit was merged in pull request #411.
This commit is contained in:
@@ -46,6 +46,10 @@ heading with the date it merges, and tags `main` with the same date.
|
||||
- **Inside tmux the picker switches your client** to the session's own window
|
||||
instead of attaching. tmux refuses to nest, so on your own machine the
|
||||
picker could list every session and reach none of them.
|
||||
- **The web front door's SESS button no longer reloads the page.** It opens a
|
||||
new WebSocket instead, which is all ttyd needs to run a different command,
|
||||
so the half a megabyte of inlined terminal is not fetched and re-run to
|
||||
reach the picker. On a phone that was most of the wait.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ why each is not a test:
|
||||
| `forges/`, the forge guard | Open a pull request through each forge and read the body that arrived |
|
||||
| any guard | Try the refused thing by the route an agent would take, not by the route the test takes |
|
||||
| `bin/dejima-tty-suspend`, the launch | Ctrl-Z a real session and `fg` it: the tests use a stand-in for the container run |
|
||||
| `web/` | Load it on a phone; nothing here can see a soft keyboard |
|
||||
| `web/` | `cd web && node test/browser/sess-button.mjs`, then load it on a phone; nothing here can see a soft keyboard |
|
||||
| `harnesses/` | Launch the harness against a real model |
|
||||
|
||||
## 5. The release pull request
|
||||
|
||||
@@ -1128,6 +1128,20 @@ Docker nothing, because the point of the number is not having to open
|
||||
anything. What it gives up is noticing a session whose container died without
|
||||
taking its launcher with it; every listing shows that one as gone.
|
||||
|
||||
**The SESS button does not reload the page.** ttyd takes the run id as a
|
||||
WebSocket argument rather than as part of the page, so pressing it closes the
|
||||
socket and opens another without that argument, which is `dejima-attach` with
|
||||
no argument, which is the picker. The half a megabyte of inlined terminal
|
||||
stays loaded and stays running; on a phone, fetching and re-running it was
|
||||
most of the wait. Closing the socket is also what ends the session you are
|
||||
leaving, exactly as navigating away used to: ttyd kills the command when its
|
||||
connection goes.
|
||||
|
||||
The terminal is reset on the way, and the mode tracker with it. What the
|
||||
socket carries away is the process, not the state it left behind in the
|
||||
terminal - an alternate screen, mouse reporting, a cursor key mode - and the
|
||||
picker would otherwise open into whatever the session had switched on.
|
||||
|
||||
**Inside tmux, the picker switches your client to the session's own window**
|
||||
rather than attaching. tmux refuses to nest a session inside one of its own
|
||||
panes, so on your own machine the picker would otherwise list every session
|
||||
|
||||
@@ -48,6 +48,24 @@ so they cannot quietly stop running.
|
||||
The rest — gestures, the viewport manager, the toolbar — needs a real browser
|
||||
and a real phone. `docs/remote-access.md` has the manual checklist.
|
||||
|
||||
One of them is automated, against a real browser and a mock ttyd:
|
||||
|
||||
```sh
|
||||
node test/browser/sess-button.mjs
|
||||
```
|
||||
|
||||
It drives the built page in Chromium and proves the thing the unit tests
|
||||
cannot: that SESS reaches the picker on a new WebSocket, without the page
|
||||
being fetched or run a second time. Three seconds, and it needs Playwright,
|
||||
which a dejima sandbox has and this package deliberately does not depend on -
|
||||
a browser is a strange thing to install to inline two files.
|
||||
|
||||
Deliberately not part of `npm test`, which CI runs in a `node:20-bookworm`
|
||||
container with no browser in it — a test that cannot run there would have to
|
||||
be skipped, and a skipped test is one nobody is reading. What keeps it from
|
||||
rotting instead is [docs/release.md](../docs/release.md), whose manual list
|
||||
names it for any release that touches `web/`.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Role |
|
||||
|
||||
+65
-3
@@ -14,7 +14,7 @@
|
||||
no path a /favicon.ico could be fetched from, so a link to one would 404 and
|
||||
the tab would fall back to the generic icon. -->
|
||||
<link rel="icon" type="image/png" href="data:image/png;base64,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">
|
||||
<!-- Generated by web/build.mjs - do not edit. Sources: 0cc964e9a08f82488c268b4e95b19542a42c5ada3b927e7fac62f7acc46bde94 -->
|
||||
<!-- Generated by web/build.mjs - do not edit. Sources: c43f6331189abbbe08955f8548a631132008ba6051a6274088be6f85c0f7d2a4 -->
|
||||
<style>
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
@@ -1011,6 +1011,7 @@ body.mono-keys {
|
||||
// onStatus(string), size() -> {cols, rows}}
|
||||
function createClient(opts) {
|
||||
var urls = endpoints(opts.location);
|
||||
var location = opts.location;
|
||||
var encoder = new TextEncoder();
|
||||
var decoder = new TextDecoder();
|
||||
var socket = null;
|
||||
@@ -1108,9 +1109,45 @@ body.mono-keys {
|
||||
};
|
||||
}
|
||||
|
||||
// Point the connection at a different ttyd argument. ttyd reads --url-arg
|
||||
// off the WebSocket request rather than off the page load, so a different
|
||||
// command is a different socket and nothing else: the half a megabyte of
|
||||
// terminal this page is does not have to be fetched, parsed and run again
|
||||
// to reach one.
|
||||
//
|
||||
// The old socket's handlers are detached before it is closed, rather than
|
||||
// a flag being read inside them. onclose is delivered asynchronously, so a
|
||||
// handler left attached can arrive after the replacement socket exists and
|
||||
// null the field holding it - and the reconnect it would schedule is for
|
||||
// the url this call just replaced.
|
||||
function switchTo(search) {
|
||||
urls = endpoints({
|
||||
protocol: location.protocol,
|
||||
host: location.host,
|
||||
pathname: location.pathname,
|
||||
search: search
|
||||
});
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
backoff = RECONNECT_MIN_MS;
|
||||
if (socket) {
|
||||
socket.onclose = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
connect();
|
||||
}
|
||||
|
||||
return {
|
||||
connect: connect,
|
||||
urls: urls,
|
||||
switchTo: switchTo,
|
||||
// A function, not the object: switchTo replaces it, and a copy taken
|
||||
// here would answer for the connection this client used to have.
|
||||
urls: function () { return urls; },
|
||||
isOpen: open,
|
||||
sendInput: sendInput,
|
||||
sendBinary: sendBinary,
|
||||
@@ -1879,6 +1916,31 @@ body.mono-keys {
|
||||
}
|
||||
});
|
||||
|
||||
// The SESS button: back to dejima-attach with no run id, which is the
|
||||
// picker. A new socket rather than a new page - ttyd takes the run id as a
|
||||
// WebSocket argument, so the command changes without the half a megabyte of
|
||||
// inlined terminal being fetched, parsed and run a second time. On a phone
|
||||
// that was most of the wait.
|
||||
//
|
||||
// The terminal is reset first, and the mode tracker with it. What the socket
|
||||
// carries away is the process, not the state it left in here: an alternate
|
||||
// screen, mouse reporting, a cursor key mode. The picker would otherwise
|
||||
// open into whatever the session it replaced had switched on, and the
|
||||
// gestures would still be sending mouse reports to something that is not
|
||||
// asking for them.
|
||||
//
|
||||
// replaceState rather than a history entry: the address bar has to stop
|
||||
// saying ?arg=<run-id> or a reload would reopen the session just left, and
|
||||
// going "back" to a session that ended is not a thing to offer.
|
||||
function goToPicker(search) {
|
||||
term.reset();
|
||||
mouse.modes.clear();
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState(null, '', window.location.pathname + search);
|
||||
}
|
||||
client.switchTo(search);
|
||||
}
|
||||
|
||||
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
|
||||
// because the target is a phone: soft keyboards overwhelmingly report
|
||||
// keydown as "Unidentified" and deliver the real character through an input
|
||||
@@ -1938,7 +2000,7 @@ body.mono-keys {
|
||||
prefix: function () { return prefixByte; },
|
||||
notify: notify,
|
||||
search: function () { return window.location.search; },
|
||||
navigate: function (search) { window.location.search = search; },
|
||||
navigate: goToPicker,
|
||||
allowKeyboard: keyboard.allow
|
||||
});
|
||||
toolbar.attach();
|
||||
|
||||
+2
-2
@@ -5,12 +5,12 @@ f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.j
|
||||
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
|
||||
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
|
||||
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
|
||||
e5888ba8413fc2b442b44cade378f7393fd231dd5f79d44c58d1bc217ed7c807 src/main.js
|
||||
05bc2e446c3baa57422c8fa036939a0df8cf35ac0cf02bb35233befafae5667f src/main.js
|
||||
e3a550e2249b536c9bc3d90ee2735a9a2d8e6f772a6a631f724d289b96b1f557 src/modes.js
|
||||
8446be09a1eca041a5a2e2068fdace32596713aef1d1fb74b522ea4eacc50ec3 src/mouse.js
|
||||
cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
|
||||
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
|
||||
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
|
||||
2490ec55a050d993364a62f590b51e63945ea5b1276832821d6d1d0854fd5c81 src/protocol.js
|
||||
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
|
||||
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
|
||||
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
|
||||
|
||||
+26
-1
@@ -139,6 +139,31 @@
|
||||
}
|
||||
});
|
||||
|
||||
// The SESS button: back to dejima-attach with no run id, which is the
|
||||
// picker. A new socket rather than a new page - ttyd takes the run id as a
|
||||
// WebSocket argument, so the command changes without the half a megabyte of
|
||||
// inlined terminal being fetched, parsed and run a second time. On a phone
|
||||
// that was most of the wait.
|
||||
//
|
||||
// The terminal is reset first, and the mode tracker with it. What the socket
|
||||
// carries away is the process, not the state it left in here: an alternate
|
||||
// screen, mouse reporting, a cursor key mode. The picker would otherwise
|
||||
// open into whatever the session it replaced had switched on, and the
|
||||
// gestures would still be sending mouse reports to something that is not
|
||||
// asking for them.
|
||||
//
|
||||
// replaceState rather than a history entry: the address bar has to stop
|
||||
// saying ?arg=<run-id> or a reload would reopen the session just left, and
|
||||
// going "back" to a session that ended is not a thing to offer.
|
||||
function goToPicker(search) {
|
||||
term.reset();
|
||||
mouse.modes.clear();
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState(null, '', window.location.pathname + search);
|
||||
}
|
||||
client.switchTo(search);
|
||||
}
|
||||
|
||||
// Sticky Ctrl is applied here rather than in attachCustomKeyEventHandler
|
||||
// because the target is a phone: soft keyboards overwhelmingly report
|
||||
// keydown as "Unidentified" and deliver the real character through an input
|
||||
@@ -198,7 +223,7 @@
|
||||
prefix: function () { return prefixByte; },
|
||||
notify: notify,
|
||||
search: function () { return window.location.search; },
|
||||
navigate: function (search) { window.location.search = search; },
|
||||
navigate: goToPicker,
|
||||
allowKeyboard: keyboard.allow
|
||||
});
|
||||
toolbar.attach();
|
||||
|
||||
+38
-1
@@ -41,6 +41,7 @@
|
||||
// onStatus(string), size() -> {cols, rows}}
|
||||
function createClient(opts) {
|
||||
var urls = endpoints(opts.location);
|
||||
var location = opts.location;
|
||||
var encoder = new TextEncoder();
|
||||
var decoder = new TextDecoder();
|
||||
var socket = null;
|
||||
@@ -138,9 +139,45 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Point the connection at a different ttyd argument. ttyd reads --url-arg
|
||||
// off the WebSocket request rather than off the page load, so a different
|
||||
// command is a different socket and nothing else: the half a megabyte of
|
||||
// terminal this page is does not have to be fetched, parsed and run again
|
||||
// to reach one.
|
||||
//
|
||||
// The old socket's handlers are detached before it is closed, rather than
|
||||
// a flag being read inside them. onclose is delivered asynchronously, so a
|
||||
// handler left attached can arrive after the replacement socket exists and
|
||||
// null the field holding it - and the reconnect it would schedule is for
|
||||
// the url this call just replaced.
|
||||
function switchTo(search) {
|
||||
urls = endpoints({
|
||||
protocol: location.protocol,
|
||||
host: location.host,
|
||||
pathname: location.pathname,
|
||||
search: search
|
||||
});
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
backoff = RECONNECT_MIN_MS;
|
||||
if (socket) {
|
||||
socket.onclose = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
connect();
|
||||
}
|
||||
|
||||
return {
|
||||
connect: connect,
|
||||
urls: urls,
|
||||
switchTo: switchTo,
|
||||
// A function, not the object: switchTo replaces it, and a copy taken
|
||||
// here would answer for the connection this client used to have.
|
||||
urls: function () { return urls; },
|
||||
isOpen: open,
|
||||
sendInput: sendInput,
|
||||
sendBinary: sendBinary,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Enough of ttyd to drive the real page: the three paths its HTTP handler
|
||||
// answers, and a WebSocket that spawns nothing but speaks the frame protocol
|
||||
// src/protocol.js was written against.
|
||||
//
|
||||
// Hand-rolled rather than a dependency, because web/ ships with two and both
|
||||
// are the terminal itself. The frames here are the ones this page sends and
|
||||
// receives and no others: a text handshake, masked client frames under 64KB,
|
||||
// and unmasked server frames.
|
||||
import http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
function accept(key) {
|
||||
return crypto.createHash('sha1').update(key + GUID).digest('base64');
|
||||
}
|
||||
|
||||
// A server frame: FIN set, binary opcode, no mask, two length forms.
|
||||
function frame(payload) {
|
||||
const len = payload.length;
|
||||
const head = len < 126 ? Buffer.from([0x82, len])
|
||||
: Buffer.concat([Buffer.from([0x82, 126]), Buffer.from([len >> 8, len & 0xff])]);
|
||||
return Buffer.concat([head, payload]);
|
||||
}
|
||||
|
||||
// One client frame off the front of a buffer, or null when it is not all here
|
||||
// yet. Only the opcodes this page sends: text, binary and close.
|
||||
function unframe(buf) {
|
||||
if (buf.length < 2) return null;
|
||||
const masked = (buf[1] & 0x80) !== 0;
|
||||
let len = buf[1] & 0x7f;
|
||||
let off = 2;
|
||||
if (len === 126) {
|
||||
if (buf.length < 4) return null;
|
||||
len = buf.readUInt16BE(2);
|
||||
off = 4;
|
||||
}
|
||||
const maskLen = masked ? 4 : 0;
|
||||
if (buf.length < off + maskLen + len) return null;
|
||||
const mask = masked ? buf.subarray(off, off + 4) : null;
|
||||
const body = Buffer.from(buf.subarray(off + maskLen, off + maskLen + len));
|
||||
if (mask) for (let i = 0; i < body.length; i++) body[i] ^= mask[i % 4];
|
||||
return { opcode: buf[0] & 0x0f, body, rest: buf.subarray(off + maskLen + len) };
|
||||
}
|
||||
|
||||
// opts.onConnect(url) -> the text this "session" prints. Returning different
|
||||
// text for the picker and for a run id is what lets a test tell which command
|
||||
// ttyd would have run.
|
||||
export async function startMockTtyd(indexPath, opts) {
|
||||
const index = fs.readFileSync(indexPath);
|
||||
const pageLoads = [];
|
||||
const sockets = [];
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const path = req.url.split('?')[0];
|
||||
if (path === '/token') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ token: '' }));
|
||||
return;
|
||||
}
|
||||
if (path === '/' || path === '/index.html') {
|
||||
pageLoads.push(req.url);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(index);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
server.on('upgrade', (req, socket) => {
|
||||
sockets.push({ url: req.url, closed: false, input: '' });
|
||||
const mine = sockets[sockets.length - 1];
|
||||
socket.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\n' +
|
||||
'Upgrade: websocket\r\nConnection: Upgrade\r\n' +
|
||||
'Sec-WebSocket-Protocol: tty\r\n' +
|
||||
'Sec-WebSocket-Accept: ' + accept(req.headers['sec-websocket-key']) + '\r\n\r\n');
|
||||
let buf = Buffer.alloc(0);
|
||||
let spawned = false;
|
||||
socket.on('data', (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
for (;;) {
|
||||
const f = unframe(buf);
|
||||
if (!f) break;
|
||||
buf = f.rest;
|
||||
if (f.opcode === 0x8) { socket.end(); return; }
|
||||
// An input frame is '0' and then the bytes. Recorded so a test can see
|
||||
// that what is typed after a switch reaches the new command rather
|
||||
// than the socket that was replaced.
|
||||
if (spawned && f.body.length > 1 && f.body[0] === 0x30) {
|
||||
mine.input += f.body.subarray(1).toString();
|
||||
}
|
||||
// ttyd spawns the command when the first message - the one carrying
|
||||
// the token and the size - arrives, and not before.
|
||||
if (!spawned) {
|
||||
spawned = true;
|
||||
socket.write(frame(Buffer.concat([
|
||||
Buffer.from('0'), Buffer.from(opts.onConnect(mine.url))
|
||||
])));
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on('close', () => { mine.closed = true; });
|
||||
});
|
||||
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
return {
|
||||
url: `http://127.0.0.1:${server.address().port}/`,
|
||||
pageLoads,
|
||||
sockets,
|
||||
stop: () => new Promise((r) => server.close(r))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// What the SESS button does, against the real page in a real browser.
|
||||
//
|
||||
// Not part of `npm test`: it needs Chromium, which the CI image does not have
|
||||
// and which `node --test` would fail on rather than skip. Run it by hand, on a
|
||||
// machine with Playwright:
|
||||
//
|
||||
// node test/browser/sess-button.mjs
|
||||
//
|
||||
// It proves the one thing the unit tests cannot: that pressing SESS reaches
|
||||
// the picker without the page being fetched and parsed a second time.
|
||||
import { startMockTtyd } from './mock-ttyd.mjs';
|
||||
|
||||
// Playwright is not a dependency of this package and is deliberately not
|
||||
// added as one: it would pull a browser into an install that exists to inline
|
||||
// two files. Resolved where it is rather than where this happens to have been
|
||||
// run - a dejima sandbox carries it at a fixed path, a host that has it has it
|
||||
// on the module path.
|
||||
async function loadChromium() {
|
||||
for (const spec of ['playwright', '/opt/node-tools/lib/node_modules/playwright/index.mjs']) {
|
||||
try {
|
||||
return (await import(spec)).chromium;
|
||||
} catch (e) { /* the next one, or the message below */ }
|
||||
}
|
||||
console.error('This check needs Playwright, which is not a dependency here.');
|
||||
console.error('Run it inside a dejima sandbox, which has one, or install it:');
|
||||
console.error(' npm i -g playwright && npx playwright install chromium');
|
||||
process.exit(2);
|
||||
}
|
||||
const chromium = await loadChromium();
|
||||
|
||||
const RUN_ID = 'deadbeefdeadbeef';
|
||||
const checks = [];
|
||||
function check(name, ok, detail) {
|
||||
checks.push({ name, ok, detail });
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' <- ' + detail}`);
|
||||
}
|
||||
|
||||
const ttyd = await startMockTtyd(new URL('../../index.html', import.meta.url).pathname, {
|
||||
onConnect: (url) => (url.includes('arg=' + RUN_ID)
|
||||
? 'SESSION ' + RUN_ID + ' attached\r\n'
|
||||
: 'dejima | live sessions\r\n')
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${ttyd.url}?toolbar=1&arg=${RUN_ID}`);
|
||||
await page.waitForFunction((id) => document.body.innerText.includes('SESSION ' + id), RUN_ID);
|
||||
|
||||
check('the run id reaches ttyd as a WebSocket argument',
|
||||
ttyd.sockets.length === 1 && ttyd.sockets[0].url.includes('arg=' + RUN_ID),
|
||||
JSON.stringify(ttyd.sockets.map((s) => s.url)));
|
||||
const loadsBefore = ttyd.pageLoads.length;
|
||||
// A marker on the window as well as the server's count: a reload served from
|
||||
// the browser's cache would never reach the server, and this page is one big
|
||||
// cacheable file.
|
||||
await page.evaluate(() => { window.__survivedTheSwitch = true; });
|
||||
|
||||
await page.click('[data-action="sessions"]');
|
||||
await page.waitForFunction(() => document.body.innerText.includes('live sessions'));
|
||||
|
||||
check('the page itself was never re-run',
|
||||
await page.evaluate(() => window.__survivedTheSwitch === true));
|
||||
check('the picker arrives without the page being fetched again',
|
||||
ttyd.pageLoads.length === loadsBefore,
|
||||
`${ttyd.pageLoads.length} loads, was ${loadsBefore}`);
|
||||
check('a second socket is opened, with no arg on it',
|
||||
ttyd.sockets.length === 2 && !ttyd.sockets[1].url.includes('arg='),
|
||||
JSON.stringify(ttyd.sockets.map((s) => s.url)));
|
||||
check('the first socket is closed, which is what ends the session left behind',
|
||||
ttyd.sockets[0].closed === true);
|
||||
check('the address bar stops naming the session, so a reload lands on the picker',
|
||||
!(await page.evaluate(() => window.location.search)).includes('arg='),
|
||||
await page.evaluate(() => window.location.search));
|
||||
// The session's own output must not still be on screen under the picker: the
|
||||
// socket carries away the process, not what it left in the terminal.
|
||||
// Typing has to reach the command the new socket spawned. The terminal's
|
||||
// handlers were wired to the client once, at startup, and the client is now
|
||||
// holding a different socket.
|
||||
await page.click('#terminal');
|
||||
await page.keyboard.type('2');
|
||||
await page.waitForFunction(() => true);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
check('what is typed after the switch reaches the new socket',
|
||||
ttyd.sockets[1].input.includes('2') && !ttyd.sockets[0].input.includes('2'),
|
||||
JSON.stringify(ttyd.sockets.map((s) => s.input)));
|
||||
check('the terminal is reset, so the picker is not drawn over the session',
|
||||
!(await page.evaluate(() => document.body.innerText)).includes('SESSION ' + RUN_ID));
|
||||
|
||||
await browser.close();
|
||||
await ttyd.stop();
|
||||
|
||||
const failed = checks.filter((c) => !c.ok).length;
|
||||
console.log(`\n${checks.length - failed}/${checks.length} checks passed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -44,3 +44,84 @@ test("the query string rides along, because ttyd's --url-arg reads it off the We
|
||||
'wss://h/ws'
|
||||
);
|
||||
});
|
||||
|
||||
// A WebSocket and a fetch just real enough for createClient: every instance is
|
||||
// recorded, so a test can see which url was opened and what was done to the
|
||||
// one before it.
|
||||
function stubTransport() {
|
||||
const sockets = [];
|
||||
globalThis.WebSocket = function (url, protocols) {
|
||||
this.url = url;
|
||||
this.protocols = protocols;
|
||||
this.readyState = 0;
|
||||
this.closed = false;
|
||||
this.sent = [];
|
||||
this.send = function (data) { this.sent.push(data); };
|
||||
this.close = function () { this.closed = true; if (this.onclose) this.onclose(); };
|
||||
sockets.push(this);
|
||||
};
|
||||
globalThis.WebSocket.OPEN = 1;
|
||||
globalThis.fetch = () => Promise.resolve({ json: () => ({ token: 't' }) });
|
||||
return sockets;
|
||||
}
|
||||
|
||||
function client(search, statuses) {
|
||||
return globalThis.DEJIMA.protocol.createClient({
|
||||
location: { protocol: 'http:', host: 'h', pathname: '/', search },
|
||||
size: () => ({ cols: 80, rows: 24 }),
|
||||
onOutput() {}, onTitle() {}, onPreferences() {},
|
||||
onStatus: (s) => statuses.push(s)
|
||||
});
|
||||
}
|
||||
|
||||
test('switchTo opens a new socket for a different arg, and keeps the page', async () => {
|
||||
const sockets = stubTransport();
|
||||
const statuses = [];
|
||||
const c = client('?arg=0123456789abcdef', statuses);
|
||||
c.connect();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(sockets.length, 1);
|
||||
assert.equal(sockets[0].url, 'ws://h/ws?arg=0123456789abcdef');
|
||||
|
||||
c.switchTo('');
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
// The old one was closed - ttyd kills the command when the socket goes, so
|
||||
// this is what ends the session being left - and a second one opened on the
|
||||
// url with no arg, which is the picker.
|
||||
assert.equal(sockets[0].closed, true);
|
||||
assert.equal(sockets.length, 2);
|
||||
assert.equal(sockets[1].url, 'ws://h/ws');
|
||||
assert.equal(c.urls().ws, 'ws://h/ws');
|
||||
});
|
||||
|
||||
test('a switch is not reported as the session dropping', async () => {
|
||||
const sockets = stubTransport();
|
||||
const statuses = [];
|
||||
const c = client('?arg=0123456789abcdef', statuses);
|
||||
c.connect();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
statuses.length = 0;
|
||||
c.switchTo('');
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
// "closed" is what puts "Disconnected. Reconnecting..." on the screen and
|
||||
// starts the backoff. A switch is neither.
|
||||
assert.deepEqual(statuses, ['connecting']);
|
||||
});
|
||||
|
||||
test('a switch while disconnected drops the pending reconnect', async () => {
|
||||
const sockets = stubTransport();
|
||||
const statuses = [];
|
||||
const c = client('?arg=0123456789abcdef', statuses);
|
||||
c.connect();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
sockets[0].onclose(); // the session ended; a retry is now queued
|
||||
const queued = sockets.length;
|
||||
c.switchTo('');
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(sockets.length, queued + 1);
|
||||
assert.equal(sockets[sockets.length - 1].url, 'ws://h/ws');
|
||||
// And the queued retry must not arrive afterwards and reopen the session
|
||||
// that was just left.
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
assert.equal(sockets.length, queued + 1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user