summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/chat_send_probe.py
blob: f1cc191a21774310b39b0ac4f64922287469ea67 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
#!/usr/bin/env python3
"""
Does sending a chat message come back?

Mounts **the real `ChatPanel` over the real `MeshBayTransport`** — both shipped
modules, neither a model of the other — and types a message into the composer
the way a person does. Only the DataChannel is replaced, by a stand-in that
answers what the node answers.

It exists because the defect it was written for is invisible to a structural
test and to `chat_scroll_probe.py` alike: nothing in `chat-app.js` is wrong,
and the transport routes every message it knows how to route. The node's reply
to a chat message is a bare `{"type": "ack"}` that names no request, so it fell
through to `_dispatch`'s arrival-order guess and was handed to whichever
request happened to be waiting — a `media_meta_req` from the Videos tab, say,
which the node never answered because it refused the file_id with a bare
`error` that named no request either. The chat send then waited out its own 30s
timeout with the composer disabled, so the tab looked frozen and the message
never appeared, while the node had stored it all along.

    chat_send_probe.py

Prints JSON: `steps`, the state of the panel at each stage, and `log`, what the
transport sent and how the deliberately-unanswered request ended up.
"""
import http.server
import json
import socketserver
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path

STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
PORT = 8755
RECORDS = []
socketserver.TCPServer.allow_reuse_address = True

PAGE = r"""<!doctype html><html><head><meta charset=utf-8>
<link rel="stylesheet" href="/style.css"></head>
<body>
<div class="layout"><div class="main">
  <div class="group-header"><div><h2>a group</h2></div></div>
  <div class="group-tabs"><button class="group-tab active">Chat</button></div>
  <div id="root"></div>
</div></div>
<script src="/transport.js"></script>
<script type="module">
import { html, render, useRef } from '/vendor/htm-preact.js';
import { ChatPanel } from '/chat-app.js';

const log = [];
window.addEventListener('error', e => log.push('error: ' + e.message));

// The real transport, with only the channel replaced: _send takes the plain
// object _sendAndWait built, so the framing and msgpack are the only things
// skipped — every pending entry, key and dispatch path below is the shipped one.
const tp = new window.MeshBayTransport('', 'token');
tp._connected = true;
tp._channel = { readyState: 'open', send() {} };

const now = Date.now() / 1000;
const history = [];
for (let i = 0; i < 5; i++) {
  history.push({ id: 'm' + i, sender_id: 'someone', sender_name: 'someone',
                 payload: 'message ' + i, timestamp: now - (5 - i) * 60 });
}

// Stands in for the node, answering exactly what webrtc_server.py answers.
// media_meta_req is answered with nothing at all, which is what a refusal
// amounts to for the request that asked: `_do_media_meta_request` sends a bare
// `error` for a file_id the index does not have, and a bare error names no
// request, so it reaches none.
tp._send = (obj) => {
  log.push('sent ' + obj.type);
  if (obj.type === 'chat_hist') {
    setTimeout(() => tp._dispatch(
      { type: 'chat_hist_resp', v: '0.2', messages: history, has_more: false }), 10);
  } else if (obj.type === 'chat_msg') {
    setTimeout(() => tp._dispatch({ type: 'ack', v: '0.14' }), 10);
  }
};

function Host() {
  const transportRef = useRef(tp);
  const gekRef = useRef(null);
  return html`<${ChatPanel} transportRef=${transportRef} gekRef=${gekRef}
    username="me" entries=${[]} status="connected" />`;
}
render(html`<${Host} />`, document.getElementById('root'));

const out = { steps: [], log };
const composer = () => document.querySelector('.chat-input');

function snap(label) {
  const c = composer();
  out.steps.push({
    label,
    bubbles: document.querySelectorAll('.chat-bubble').length,
    lastText: [...document.querySelectorAll('.chat-text')].pop()?.textContent ?? null,
    // What a frozen tab actually is: the composer is disabled for as long as
    // a send is in flight.
    composerDisabled: c ? c.disabled : null,
    composerValue: c ? c.value : null,
    pending: tp._pending.size,
  });
}

const wait = ms => new Promise(r => setTimeout(r, ms));

function typeInto(text) {
  const c = composer();
  c.focus();
  // Preact reads e.target.value on input, so the native setter has to run.
  Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
    .set.call(c, text);
  c.dispatchEvent(new Event('input', { bubbles: true }));
}

(async () => {
  await wait(500);
  snap('arrived');

  // The Videos tab asked about a file a moment ago and is still waiting. Any
  // unanswered request will do; this is the one that was live when the defect
  // was found.
  tp.fetchMediaMeta('a-file-the-node-refused')
    .then(m => log.push('media_meta resolved with ' + m.type),
          e => log.push('media_meta rejected: ' + e.message));
  await wait(100);
  snap('stale request pending');

  typeInto('hello');
  await wait(100);
  composer().dispatchEvent(new KeyboardEvent('keydown',
    { key: 'Enter', bubbles: true, cancelable: true }));

  // Far short of _sendAndWait's 30s timeout: a send that has not come back by
  // now is the freeze, not a slow node.
  await wait(1500);
  snap('after send');

  fetch('/log', { method: 'POST', body: JSON.stringify(out) });
})();
</script></body></html>"""


class H(http.server.BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass

    def do_POST(self):
        RECORDS.append(json.loads(
            self.rfile.read(int(self.headers["Content-Length"])).decode()))
        self.send_response(204)
        self.end_headers()

    def do_GET(self):
        if self.path == "/":
            body, ctype = PAGE.encode(), "text/html; charset=utf-8"
        else:
            path = (STATIC / self.path.lstrip("/")).resolve()
            if not str(path).startswith(str(STATIC)) or not path.is_file():
                self.send_response(404)
                self.end_headers()
                return
            body = path.read_bytes()
            ctype = ("text/css" if path.suffix == ".css"
                     else "text/javascript" if path.suffix == ".js"
                     else "application/octet-stream")
        self.send_response(200)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


def main() -> int:
    with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
        threading.Thread(target=srv.serve_forever, daemon=True).start()
        with tempfile.TemporaryDirectory() as profile:
            proc = subprocess.Popen(
                ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
                 f"--user-data-dir={profile}", "--window-size=1100,800",
                 f"http://127.0.0.1:{PORT}/"],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            for _ in range(300):
                if RECORDS:
                    break
                time.sleep(0.1)
            proc.terminate()
            try:
                proc.wait(timeout=10)
            except subprocess.TimeoutExpired:
                proc.kill()
    if not RECORDS:
        print(json.dumps({"error": "no measurement"}), file=sys.stderr)
        return 1
    print(json.dumps(RECORDS[0], indent=1))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())