oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1// chat.test.mjs — chat.js gated without a browser.
2//
3// node --test test/browser/chat.test.mjs
4//
5// Same trick as negotiation.test.mjs, and for the same reason: chat.js
6// is an IIFE that wants a document, so it cannot be imported. Its
7// functions are lifted out of the shipped file by name and evaluated
8// here, `mount` included — which needs its module constants and its
9// three helpers handed to it, so the harness below rebuilds exactly
10// that much of the file and no more. What is gated is therefore the
11// code that ships, not a copy of it that can drift, and renaming or
12// reshaping any of it fails this file loudly instead of passing on a
13// stale duplicate.
14//
15// What it gates:
16//
17// chatLine §6's DOM safety — chat text reaches the page through
18// textContent and nothing else, so an angle bracket in a
19// message is an angle bracket on the screen. No innerHTML,
20// no linkification.
21// trimLog the log is a window, not a record: 500 lines, oldest
22// dropped first.
23// chatBytes §4.4's 2 KB is bytes of UTF-8, not characters. The
24// control's maxlength counts characters, so for anything
25// outside ASCII the two disagree and this is the count that
26// matches what the server will refuse.
27// mount the panel's whole contract with its host: one send per
28// submit, a local echo only on a `true` answer, a refusal
29// that keeps the draft, the host's enable gate, Enter
30// versus Shift+Enter versus an IME, and the two sentences.
31import { test } from 'node:test';
32import assert from 'node:assert/strict';
33import { readFileSync } from 'node:fs';
34import { fileURLToPath } from 'node:url';
35
36const chatJS = fileURLToPath(new URL('../../internal/calls/static/chat.js', import.meta.url));
37const src = readFileSync(chatJS, 'utf8');
38
39// lift returns the source of one `function name(...) {...}` by matching
40// braces from its declaration.
41function lift(source, name) {
42 let start = source.indexOf('function ' + name + '(');
43 assert.notEqual(start, -1, name + ' is not in chat.js any more — this test is stale, not passing');
44 if (source.slice(start - 6, start) === 'async ') start -= 6;
45 let depth = 0;
46 for (let i = source.indexOf('{', start); i < source.length; i++) {
47 if (source[i] === '{') depth += 1;
48 else if (source[i] === '}') {
49 depth -= 1;
50 if (depth === 0) return source.slice(start, i + 1);
51 }
52 }
53 assert.fail('unbalanced braces reading ' + name + ' out of chat.js');
54}
55
56// The limits and the sentences are read out of the shipped file rather
57// than retyped here, so this file cannot go on asserting 500 and 2048
58// after someone has changed them — and then pinned to the values the
59// spec and the copy list name, so changing one is a deliberate act with
60// a failing test in front of it.
61function numberConst(name) {
62 const m = new RegExp('const\\s+' + name + '\\s*=\\s*(\\d+)\\s*;').exec(src);
63 assert.ok(m, name + ' is not a plain numeric const in chat.js any more');
64 return Number(m[1]);
65}
66
67function stringConst(name) {
68 const m = new RegExp('const\\s+' + name + "\\s*=\\s*'([^']*)'\\s*;").exec(src);
69 assert.ok(m, name + ' is not a plain single-quoted const in chat.js any more');
70 return m[1];
71}
72
73const MAX_BYTES = numberConst('MAX_BYTES');
74const MAX_LINES = numberConst('MAX_LINES');
75const PINNED_PX = numberConst('PINNED_PX');
76const SEND_FAILED = stringConst('SEND_FAILED');
77const TOO_LONG = stringConst('TOO_LONG');
78
79test('the shipped limits and sentences are the ones everything else was written against', () => {
80 assert.equal(MAX_BYTES, 2048, "spec §4.4's 2 KB, and the control's maxlength in partials/chat.html");
81 assert.equal(MAX_LINES, 500);
82 assert.equal(SEND_FAILED, 'Could not send. Try again.', 'catalogued in docs/superpowers/copy/2026-09-10-slice-2-strings.md');
83 assert.equal(TOO_LONG, 'Message is too long.', 'catalogued in docs/superpowers/copy/2026-09-10-slice-2-strings.md');
84});
85
86const build = (name, prelude) =>
87 new Function((prelude || '') + lift(src, name) + '; return ' + name + ';')();
88const chatLine = build('chatLine');
89const trimLog = build('trimLog');
90
91// chatBytes closes over the pattern of what Go escapes and JavaScript
92// does not, which is read out of the source rather than retyped — a
93// counter tested against a pattern of the test's own would prove the
94// test.
95const goEscapes = /const GO_ESCAPES = (\/\[[^/]*\]\/g);/.exec(src);
96assert.notEqual(goEscapes, null, 'GO_ESCAPES is not a regular expression in chat.js any more');
97const chatBytes = build('chatBytes', 'const GO_ESCAPES = ' + goEscapes[1] + ';\n');
98
99// mount closes over the module's constants and its three helpers, so
100// the harness hands it exactly those and nothing else. `document` goes
101// in undefined on purpose: mount must reach its document through
102// root.ownerDocument, and a panel that falls back to a global is one
103// that cannot be mounted inside a frame or a template.
104const mount = new Function(
105 'MAX_BYTES',
106 'MAX_LINES',
107 'PINNED_PX',
108 'SEND_FAILED',
109 'TOO_LONG',
110 'document',
111 'const GO_ESCAPES = ' + goEscapes[1] + ';\n' +
112 [lift(src, 'chatBytes'), lift(src, 'chatLine'), lift(src, 'trimLog'), lift(src, 'mount')].join('\n') +
113 '\nreturn mount;',
114)(MAX_BYTES, MAX_LINES, PINNED_PX, SEND_FAILED, TOO_LONG, undefined);
115
116// --- the smallest document these functions can be driven against ---
117//
118// Deliberately missing innerHTML: an element here has textContent,
119// setAttribute and appendChild and nothing else, so a line built by
120// assigning markup would leave every assertion below reading an empty
121// string rather than quietly passing.
122function textOf(node) {
123 return node.tag === '#text' ? node.text : node.textContent;
124}
125
126function fakeDoc() {
127 const doc = {
128 activeElement: null,
129 createTextNode: (text) => ({ tag: '#text', text }),
130 createElement(tag) {
131 const node = {
132 tag,
133 ownerDocument: doc,
134 className: '',
135 attrs: {},
136 children: [],
137 own: '',
138 listeners: {},
139 disabled: false,
140 value: '',
141 scrollTop: 0,
142 clientHeight: 0,
143 // Every line is this tall, so scrollHeight follows the log's
144 // length the way a real one does. Zero until a test says
145 // otherwise, which makes every other test's panel "at the
146 // bottom" and keeps the scroll out of their way.
147 lineHeight: 0,
148 };
149 Object.defineProperty(node, 'textContent', {
150 get() {
151 return node.children.length ? node.children.map(textOf).join('') : node.own;
152 },
153 set(value) {
154 node.own = String(value);
155 node.children = [];
156 },
157 });
158 Object.defineProperty(node, 'scrollHeight', {
159 get() {
160 return node.lineHeight * node.childElementCount;
161 },
162 });
163 Object.defineProperty(node, 'childElementCount', {
164 get() {
165 return node.children.filter((c) => c.tag !== '#text').length;
166 },
167 });
168 Object.defineProperty(node, 'firstElementChild', {
169 get() {
170 return node.children.find((c) => c.tag !== '#text') || null;
171 },
172 });
173 Object.defineProperty(node, 'firstChild', {
174 get() {
175 return node.children[0] || null;
176 },
177 });
178 node.setAttribute = (key, value) => {
179 node.attrs[key] = String(value);
180 };
181 node.appendChild = (child) => {
182 node.children.push(child);
183 return child;
184 };
185 node.removeChild = (child) => {
186 node.children.splice(node.children.indexOf(child), 1);
187 return child;
188 };
189 node.contains = (other) => {
190 if (!other) return false;
191 if (other === node) return true;
192 return node.children.some((c) => c.contains && c.contains(other));
193 };
194 node.querySelector = (sel) => {
195 const key = sel.replace(/^\[|\]$/g, '');
196 for (const child of node.children) {
197 if (child.attrs && key in child.attrs) return child;
198 const deeper = child.querySelector && child.querySelector(sel);
199 if (deeper) return deeper;
200 }
201 return null;
202 };
203 node.addEventListener = (type, fn) => {
204 (node.listeners[type] = node.listeners[type] || []).push(fn);
205 };
206 node.focus = () => {
207 doc.activeElement = node;
208 };
209 return node;
210 },
211 };
212 return doc;
213}
214
215// fire dispatches one event and hands the event object back, so a test
216// can ask whether the handler called preventDefault.
217function fire(node, type, extra) {
218 const ev = Object.assign(
219 {
220 defaultPrevented: false,
221 preventDefault() {
222 ev.defaultPrevented = true;
223 },
224 },
225 extra,
226 );
227 for (const fn of node.listeners[type] || []) fn(ev);
228 return ev;
229}
230
231// settle drains the microtask queue, which is where mount's send chain
232// lives.
233const settle = () => new Promise((resolve) => setImmediate(resolve));
234
235const bare = fakeDoc();
236const partOf = (li, cls) => li.children.find((n) => n.className === cls);
237
238// --- chatLine: §6's DOM safety ---
239
240test('a message is written as text, so an angle bracket stays an angle bracket', () => {
241 const nasty = '<img src=x onerror="alert(1)"> & <b>bold</b>';
242 const li = chatLine(bare, { name: 'Ann', text: nasty, at: new Date('2026-09-10T09:07:00Z') });
243 assert.equal(partOf(li, 'chat-text').textContent, nasty);
244 // The whole line reads as text too: nothing was smuggled in as markup
245 // through the name either.
246 const line = chatLine(bare, { name: '<script>', text: 'hi' });
247 assert.equal(partOf(line, 'chat-name').textContent, '<script>');
248});
249
250test('a link is not linkified in version one', () => {
251 const li = chatLine(bare, { name: 'Ann', text: 'see https://example.com/x' });
252 const text = partOf(li, 'chat-text');
253 assert.equal(text.textContent, 'see https://example.com/x');
254 assert.equal(text.children.length, 0, 'the text node was split into elements — something linkified it');
255});
256
257test('chat.js parses no markup anywhere', () => {
258 // The file's own header says it does not; this is the line that makes
259 // that sentence a gate. Matched on the assignment, not the word,
260 // because the word appears in that header.
261 for (const pattern of [/innerHTML\s*=/, /outerHTML\s*=/, /insertAdjacentHTML/, /document\.write/]) {
262 assert.equal(pattern.test(src), false, 'chat.js must build every node, never parse markup: ' + pattern);
263 }
264});
265
266test('a line carries the name, a local clock time and the machine instant', () => {
267 const at = new Date(2026, 8, 10, 9, 7, 0); // local, like a reader's clock
268 const li = chatLine(bare, { name: 'Ann', text: 'hello', at });
269 assert.equal(li.tag, 'li');
270 assert.equal(li.className, 'chat-line');
271 assert.equal(partOf(li, 'chat-name').textContent, 'Ann');
272 const time = partOf(li, 'chat-time');
273 assert.equal(time.tag, 'time');
274 assert.equal(time.textContent, '09:07');
275 assert.equal(time.attrs.datetime, at.toISOString());
276});
277
278test('the whole date is in title, since the visible text is only a clock', () => {
279 const at = new Date(2026, 8, 10, 9, 7, 0);
280 const li = chatLine(bare, { name: 'Ann', text: 'hello', at });
281 const time = partOf(li, 'chat-time');
282 assert.equal(time.attrs.title, at.toLocaleString());
283 assert.notEqual(time.attrs.title, time.textContent, 'title must say more than the clock does');
284});
285
286test('an unusable timestamp becomes now rather than an Invalid Date on the screen', () => {
287 const li = chatLine(bare, { name: 'Ann', text: 'hello', at: 'not a date' });
288 assert.match(partOf(li, 'chat-time').textContent, /^\d\d:\d\d$/);
289 assert.doesNotThrow(() => chatLine(bare, {}));
290});
291
292test("the sender's own line is marked, and the name is still the signal", () => {
293 const li = chatLine(bare, { name: 'Ann', text: 'hello', self: true });
294 assert.equal(li.className, 'chat-line chat-line-self');
295 assert.equal(partOf(li, 'chat-name').textContent, 'Ann');
296});
297
298// --- trimLog: the cap ---
299
300function fakeLog(count) {
301 const kids = [];
302 for (let i = 0; i < count; i++) kids.push({ n: i });
303 return {
304 kids,
305 get childElementCount() {
306 return kids.length;
307 },
308 get firstElementChild() {
309 return kids[0];
310 },
311 removeChild(node) {
312 kids.splice(kids.indexOf(node), 1);
313 return node;
314 },
315 };
316}
317
318test('the log is capped and the oldest go first', () => {
319 const log = fakeLog(MAX_LINES + 140);
320 trimLog(log, MAX_LINES);
321 assert.equal(log.childElementCount, MAX_LINES);
322 assert.equal(log.kids[0].n, 140, 'the oldest lines are the ones dropped');
323 assert.equal(log.kids[MAX_LINES - 1].n, MAX_LINES + 139, 'the newest line must survive');
324});
325
326test('a log inside the cap is left alone', () => {
327 const log = fakeLog(MAX_LINES);
328 trimLog(log, MAX_LINES);
329 assert.equal(log.childElementCount, MAX_LINES);
330 assert.equal(log.kids[0].n, 0);
331});
332
333// --- chatBytes: the byte counter ---
334
335test('the byte counter counts UTF-8 bytes, not characters', () => {
336 assert.equal(chatBytes(''), 0);
337 assert.equal(chatBytes('hello'), 5);
338 assert.equal(chatBytes('é'), 2);
339 assert.equal(chatBytes('日'), 3);
340 assert.equal(chatBytes('🙂'), 4);
341 assert.equal('🙂'.length, 2, 'a surrogate pair is two characters and four bytes');
342});
343
344test('the counter measures the text as the wire will carry it', () => {
345 // The whole-branch review's W4-1. The server measures the canonical
346 // JSON form — the bytes it re-encodes and fans out — so a quote, a
347 // backslash and a newline each cost two bytes there. A panel counting
348 // raw UTF-8 would let a 2048-byte draft with twenty-three quotes in it
349 // go, and the 413 that came back would be one nobody in the panel
350 // could have foreseen.
351 assert.equal(chatBytes('"'), 2);
352 assert.equal(chatBytes('\\'), 2);
353 assert.equal(chatBytes('\n'), 2);
354 assert.equal(chatBytes('\t'), 2);
355 assert.equal(chatBytes('a"b'), 4);
356 // A control character with no short escape spells out in full.
357 assert.equal(chatBytes(''), 6);
358 // And these are not escaped on either side: SSE data is not HTML.
359 assert.equal(chatBytes('<b>&</b>'), 8);
360 assert.equal(chatBytes('a'.repeat(MAX_BYTES - 2) + '"'), MAX_BYTES,
361 'the boundary the server accepts');
362 assert.ok(chatBytes('a'.repeat(MAX_BYTES - 1) + '"') > MAX_BYTES,
363 'one byte past it, and the panel must say so before the server does');
364});
365
366test('the two separators Go escapes and JSON.stringify does not are counted its way', () => {
367 // Codex S4-7. U+2028 and U+2029 are the one place the two languages
368 // disagree about what "the JSON escaping of this string" means: Go's
369 // encoder escapes them unconditionally, SetEscapeHTML(false) included,
370 // and JavaScript leaves them literal. Three bytes here, six there —
371 // so 2045 a's and one U+2028 counted 2048 in the composer and 2051 at
372 // the server, and the draft the panel accepted came back 413 with no
373 // way to say why.
374 for (const sep of ['\u2028', '\u2029']) {
375 assert.equal(chatBytes(sep), 6, JSON.stringify(sep) + ' is six bytes on the wire');
376 assert.equal(chatBytes('a' + sep + 'b'), 8);
377 }
378 // The boundary Codex measured, from both sides of it.
379 assert.equal(chatBytes('a'.repeat(MAX_BYTES - 6) + '\u2028'), MAX_BYTES);
380 assert.equal(chatBytes('a'.repeat(MAX_BYTES - 5) + '\u2029'), MAX_BYTES + 1);
381 assert.ok(chatBytes('a'.repeat(2045) + '\u2028') > MAX_BYTES,
382 "the composer accepted a draft the server refuses");
383});
384
385test('maxlength is not the limit: 2048 characters can be far more than 2048 bytes', () => {
386 // Exactly at the cap in ASCII, and the control would allow it.
387 assert.equal(chatBytes('a'.repeat(MAX_BYTES)), MAX_BYTES);
388 // The same number of characters, four times the bytes: this is what
389 // the server refuses with a 413 and what the panel must catch first.
390 assert.equal(chatBytes('🙂'.repeat(MAX_BYTES / 4)), MAX_BYTES);
391 assert.ok(chatBytes('🙂'.repeat(MAX_BYTES / 4 + 1)) > MAX_BYTES, 'over the cap in bytes while well under it in characters');
392 assert.ok(chatBytes('é'.repeat(MAX_BYTES)) > MAX_BYTES);
393});
394
395test('the byte counter survives what a textarea can hand it', () => {
396 assert.equal(chatBytes(null), 0);
397 assert.equal(chatBytes(undefined), 0);
398});
399
400// --- mount: the contract with the host ---
401
402function panel() {
403 const doc = fakeDoc();
404 const root = doc.createElement('section');
405 const log = doc.createElement('ol');
406 const form = doc.createElement('form');
407 const input = doc.createElement('textarea');
408 const button = doc.createElement('button');
409 const status = doc.createElement('p');
410 log.attrs['data-chat-log'] = '';
411 form.attrs['data-chat-form'] = '';
412 input.attrs['data-chat-text'] = '';
413 button.attrs['data-chat-send'] = '';
414 status.attrs['data-chat-status'] = '';
415 form.appendChild(input);
416 form.appendChild(button);
417 form.appendChild(status);
418 root.appendChild(log);
419 root.appendChild(form);
420 return { doc, root, log, form, input, button, status };
421}
422
423// spy records what the host was asked to send and answers with what the
424// test tells it to.
425function spy(answer) {
426 const calls = [];
427 const send = (text) => {
428 calls.push(text);
429 return typeof answer === 'function' ? answer(text) : answer;
430 };
431 return { calls, send };
432}
433
434test('a send in flight swallows every further submit', async () => {
435 const p = panel();
436 let release;
437 const held = spy(() => new Promise((resolve) => { release = resolve; }));
438 mount(p.root, { name: 'Ann', send: held.send });
439 p.input.value = 'hello';
440 fire(p.form, 'submit');
441 fire(p.form, 'submit');
442 fire(p.input, 'keydown', { key: 'Enter' });
443 await settle();
444 assert.equal(held.calls.length, 1, 'one message, one send');
445 release(true);
446 await settle();
447 assert.equal(held.calls.length, 1);
448 assert.equal(p.input.value, '', 'an accepted message leaves the box');
449});
450
451test('the local echo is appended only when send answers exactly true', async () => {
452 const p = panel();
453 const ok = spy(true);
454 const chat = mount(p.root, { name: 'Ann', send: ok.send });
455 p.input.value = ' hello ';
456 fire(p.form, 'submit');
457 await settle();
458 assert.deepEqual(ok.calls, ['hello'], 'the text is trimmed on the way out');
459 assert.equal(p.log.childElementCount, 1);
460 const li = p.log.firstElementChild;
461 assert.equal(partOf(li, 'chat-name').textContent, 'Ann');
462 assert.equal(partOf(li, 'chat-text').textContent, 'hello');
463 assert.equal(li.className, 'chat-line chat-line-self');
464 assert.equal(p.status.textContent, '');
465 assert.equal(p.input.value, '');
466 assert.equal(typeof chat.append, 'function');
467});
468
469test('a truthy answer that is not true is a refusal, not an echo', async () => {
470 // The obvious wrong send is `fetch(...)`: a Response object is truthy
471 // whatever its status, so `!ok` would echo a message the server
472 // refused with a 413 and nobody else will ever see.
473 const p = panel();
474 const response = spy({ ok: false, status: 413 });
475 mount(p.root, { name: 'Ann', send: response.send });
476 p.input.value = 'hello';
477 fire(p.form, 'submit');
478 await settle();
479 assert.equal(p.log.childElementCount, 0, 'nothing was echoed');
480 assert.equal(p.status.textContent, SEND_FAILED);
481 assert.equal(p.input.value, 'hello', 'the draft stays in the box');
482});
483
484test('a refused send keeps the draft and says so', async () => {
485 for (const answer of [false, undefined, null, Promise.resolve(false)]) {
486 const p = panel();
487 const refused = spy(answer);
488 mount(p.root, { name: 'Ann', send: refused.send });
489 p.input.value = 'hello';
490 fire(p.form, 'submit');
491 await settle();
492 assert.equal(p.log.childElementCount, 0);
493 assert.equal(p.status.textContent, SEND_FAILED);
494 assert.equal(p.input.value, 'hello');
495 assert.equal(p.button.disabled, false, 'and Send is live again for the retry');
496 }
497});
498
499test('a send that throws is a refusal like any other', async () => {
500 const p = panel();
501 const thrown = spy(() => {
502 throw new Error('offline');
503 });
504 mount(p.root, { name: 'Ann', send: thrown.send });
505 p.input.value = 'hello';
506 fire(p.form, 'submit');
507 await settle();
508 assert.equal(p.status.textContent, SEND_FAILED);
509 assert.equal(p.input.value, 'hello');
510});
511
512test('editing the draft clears the refusal sentence', async () => {
513 const p = panel();
514 const refused = spy(false);
515 mount(p.root, { name: 'Ann', send: refused.send });
516 p.input.value = 'hello';
517 fire(p.form, 'submit');
518 await settle();
519 assert.equal(p.status.textContent, SEND_FAILED);
520 p.input.value = 'hello again';
521 fire(p.input, 'input');
522 assert.equal(p.status.textContent, '', 'the sentence was about text that no longer exists');
523});
524
525test('the host gate stops a send and leaves the log readable', async () => {
526 const p = panel();
527 const ok = spy(true);
528 const chat = mount(p.root, { name: 'Ann', send: ok.send });
529 chat.append({ name: 'Bob', text: 'still here' });
530 chat.setEnabled(false);
531 assert.equal(p.input.disabled, true);
532 assert.equal(p.button.disabled, true);
533 p.input.value = 'hello';
534 fire(p.form, 'submit');
535 fire(p.input, 'keydown', { key: 'Enter' });
536 await settle();
537 assert.equal(ok.calls.length, 0, 'not admitted, not sending');
538 assert.equal(p.log.childElementCount, 1, 'what arrived earlier is still readable');
539 chat.setEnabled(true);
540 assert.equal(p.input.disabled, false);
541 assert.equal(p.button.disabled, false);
542 fire(p.form, 'submit');
543 await settle();
544 assert.deepEqual(ok.calls, ['hello']);
545});
546
547test('Enter sends, Shift+Enter does not, and an IME is left alone', async () => {
548 const p = panel();
549 const ok = spy(true);
550 mount(p.root, { name: 'Ann', send: ok.send });
551 p.input.value = 'hello';
552
553 const shift = fire(p.input, 'keydown', { key: 'Enter', shiftKey: true });
554 assert.equal(shift.defaultPrevented, false, 'Shift+Enter must reach the textarea as a newline');
555
556 // Enter mid-composition is how an IME commits characters; taking it
557 // would send half a word and eat the rest.
558 const composing = fire(p.input, 'keydown', { key: 'Enter', isComposing: true });
559 const legacy = fire(p.input, 'keydown', { key: 'Enter', keyCode: 229 });
560 assert.equal(composing.defaultPrevented, false);
561 assert.equal(legacy.defaultPrevented, false);
562
563 const other = fire(p.input, 'keydown', { key: 'a' });
564 assert.equal(other.defaultPrevented, false);
565 await settle();
566 assert.equal(ok.calls.length, 0, 'none of those was a send');
567
568 const enter = fire(p.input, 'keydown', { key: 'Enter' });
569 assert.equal(enter.defaultPrevented, true, 'a plain Enter must not also insert a newline');
570 await settle();
571 assert.deepEqual(ok.calls, ['hello']);
572});
573
574test('an over-long draft says so and disables Send, in bytes not characters', async () => {
575 const p = panel();
576 const ok = spy(true);
577 mount(p.root, { name: 'Ann', send: ok.send });
578
579 // Well inside maxlength as characters, well past the cap as bytes.
580 p.input.value = '🙂'.repeat(MAX_BYTES / 4 + 1);
581 fire(p.input, 'input');
582 assert.equal(p.status.textContent, TOO_LONG);
583 assert.equal(p.button.disabled, true);
584
585 fire(p.form, 'submit');
586 fire(p.input, 'keydown', { key: 'Enter' });
587 await settle();
588 assert.equal(ok.calls.length, 0, 'neither path sends what the server would refuse');
589
590 // Exactly at the cap is inside it.
591 p.input.value = '🙂'.repeat(MAX_BYTES / 4);
592 fire(p.input, 'input');
593 assert.equal(p.status.textContent, '', 'the sentence clears itself when the draft comes back under');
594 assert.equal(p.button.disabled, false);
595 fire(p.form, 'submit');
596 await settle();
597 assert.equal(ok.calls.length, 1);
598});
599
600test('an empty box is nothing to send, the same nothing on both paths', async () => {
601 const p = panel();
602 const ok = spy(true);
603 mount(p.root, { name: 'Ann', send: ok.send });
604 for (const draft of ['', ' ', '\n\n']) {
605 p.input.value = draft;
606 fire(p.form, 'submit');
607 fire(p.input, 'keydown', { key: 'Enter' });
608 }
609 await settle();
610 assert.equal(ok.calls.length, 0);
611 assert.equal(p.status.textContent, '', 'and nothing is said about it');
612});
613
614test('append writes an arrived message and clear empties the log', () => {
615 const p = panel();
616 const chat = mount(p.root, { name: 'Ann', send: () => true });
617 chat.append({ from: 'peer1', name: 'Bob', text: '<not markup>' });
618 chat.append({ from: 'peer1', name: 'Bob', text: 'second' });
619 assert.equal(p.log.childElementCount, 2);
620 assert.equal(partOf(p.log.firstElementChild, 'chat-text').textContent, '<not markup>');
621 chat.clear();
622 assert.equal(p.log.childElementCount, 0);
623 assert.equal(p.log.children.length, 0);
624});
625
626test('the log stays at its cap through the panel too', () => {
627 const p = panel();
628 const chat = mount(p.root, { name: 'Ann', send: () => true });
629 for (let i = 0; i < MAX_LINES + 3; i++) chat.append({ name: 'Bob', text: 'line ' + i });
630 assert.equal(p.log.childElementCount, MAX_LINES);
631 assert.equal(partOf(p.log.firstElementChild, 'chat-text').textContent, 'line 3');
632});
633
634test('a reader at the bottom is kept at the bottom', () => {
635 const p = panel();
636 p.log.lineHeight = 20;
637 p.log.clientHeight = 100;
638 const chat = mount(p.root, { name: 'Ann', send: () => true });
639 for (let i = 0; i < 20; i++) chat.append({ name: 'Bob', text: 'line ' + i });
640 assert.equal(p.log.scrollTop, p.log.scrollHeight, 'each arrival scrolls to the newest line');
641 assert.ok(PINNED_PX > 0, 'and there is a tolerance, so a pixel of rounding is not "scrolled up"');
642});
643
644test('a trimmed log does not slide the text a scrolled-up reader is looking at', () => {
645 const p = panel();
646 p.log.lineHeight = 20;
647 p.log.clientHeight = 100;
648 const chat = mount(p.root, { name: 'Ann', send: () => true });
649 for (let i = 0; i < MAX_LINES; i++) chat.append({ name: 'Bob', text: 'line ' + i });
650
651 // The reader scrolls back up to something older. From here every
652 // arrival trims one line off the top, and leaving scrollTop alone
653 // would walk that text up and out of the box.
654 p.log.scrollTop = 200;
655 chat.append({ name: 'Bob', text: 'newest' });
656 assert.equal(p.log.childElementCount, MAX_LINES, 'still at the cap, so a line was removed');
657 assert.equal(p.log.scrollTop, 180, 'scrollTop moves up by the height of the line that went');
658
659 chat.append({ name: 'Bob', text: 'newer still' });
660 assert.equal(p.log.scrollTop, 160);
661});
662
663// --- the draft, while a send is in flight (Codex S4-3) ---
664//
665// The box stays editable while a message is on its way, because a chat
666// where you cannot type for the length of a round trip is a chat that
667// feels broken. That makes the acceptance path a race: `input.value =
668// ''` clears whatever is in the box *then*, which is not necessarily
669// the text that was sent. So the draft carries a revision, every edit
670// bumps it, and a send only clears a box that has not been touched
671// since it left.
672
673test('a draft typed during a send survives the send being accepted', async () => {
674 const p = panel();
675 let release;
676 const held = spy(() => new Promise((resolve) => { release = resolve; }));
677 mount(p.root, { name: 'Ann', send: held.send });
678 p.input.value = 'first message';
679 fire(p.form, 'submit');
680 await settle();
681 assert.deepEqual(held.calls, ['first message']);
682
683 // Typing while the first one is still in the air.
684 p.input.value = 'second draft';
685 fire(p.input, 'input');
686
687 release(true);
688 await settle();
689 assert.equal(p.input.value, 'second draft', 'the accepted send ate a draft it never sent');
690 assert.equal(p.log.childElementCount, 1, 'the message that was accepted is still echoed');
691 assert.equal(partOf(p.log.firstElementChild, 'chat-text').textContent, 'first message');
692 assert.equal(p.button.disabled, false, 'and the newer draft can be sent');
693});
694
695test('a draft typed during a send survives the send being refused', async () => {
696 const p = panel();
697 let release;
698 const held = spy(() => new Promise((resolve) => { release = resolve; }));
699 mount(p.root, { name: 'Ann', send: held.send });
700 p.input.value = 'first message';
701 fire(p.form, 'submit');
702 await settle();
703
704 p.input.value = 'second draft';
705 fire(p.input, 'input');
706
707 release(false);
708 await settle();
709 assert.equal(p.input.value, 'second draft', 'a refusal wrote over a later edit');
710 assert.equal(p.log.childElementCount, 0);
711});
712
713test('a send nobody typed over still clears the box', async () => {
714 // The other half of the same rule: the revision only holds the box
715 // when there is really something newer in it. An untouched box is
716 // cleared exactly as it always was, twice over, so the counter cannot
717 // quietly stop clearing after the first message.
718 const p = panel();
719 const ok = spy(true);
720 mount(p.root, { name: 'Ann', send: ok.send });
721 for (const text of ['one', 'two']) {
722 p.input.value = text;
723 fire(p.input, 'input');
724 fire(p.form, 'submit');
725 await settle();
726 assert.equal(p.input.value, '', 'an accepted message leaves the box (' + text + ')');
727 }
728 assert.deepEqual(ok.calls, ['one', 'two']);
729 assert.equal(p.log.childElementCount, 2);
730});
731
732test('a panel that is not there, or not complete, returns a working no-op', async () => {
733 for (const root of [null, undefined, fakeDoc().createElement('section')]) {
734 const ok = spy(true);
735 const chat = mount(root, { name: 'Ann', send: ok.send });
736 assert.doesNotThrow(() => chat.append({ name: 'Bob', text: 'hi' }));
737 assert.doesNotThrow(() => chat.setEnabled(false));
738 assert.doesNotThrow(() => chat.clear());
739 await settle();
740 assert.equal(ok.calls.length, 0);
741 }
742});
743