// chat.test.mjs — chat.js gated without a browser.
//
// node --test test/browser/chat.test.mjs
//
// Same trick as negotiation.test.mjs, and for the same reason: chat.js
// is an IIFE that wants a document, so it cannot be imported. Its
// functions are lifted out of the shipped file by name and evaluated
// here, `mount` included — which needs its module constants and its
// three helpers handed to it, so the harness below rebuilds exactly
// that much of the file and no more. What is gated is therefore the
// code that ships, not a copy of it that can drift, and renaming or
// reshaping any of it fails this file loudly instead of passing on a
// stale duplicate.
//
// What it gates:
//
// chatLine §6's DOM safety — chat text reaches the page through
// textContent and nothing else, so an angle bracket in a
// message is an angle bracket on the screen. No innerHTML,
// no linkification.
// trimLog the log is a window, not a record: 500 lines, oldest
// dropped first.
// chatBytes §4.4's 2 KB is bytes of UTF-8, not characters. The
// control's maxlength counts characters, so for anything
// outside ASCII the two disagree and this is the count that
// matches what the server will refuse.
// mount the panel's whole contract with its host: one send per
// submit, a local echo only on a `true` answer, a refusal
// that keeps the draft, the host's enable gate, Enter
// versus Shift+Enter versus an IME, and the two sentences.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const chatJS = fileURLToPath(new URL('../../internal/calls/static/chat.js', import.meta.url));
const src = readFileSync(chatJS, 'utf8');
// lift returns the source of one `function name(...) {...}` by matching
// braces from its declaration.
function lift(source, name) {
let start = source.indexOf('function ' + name + '(');
assert.notEqual(start, -1, name + ' is not in chat.js any more — this test is stale, not passing');
if (source.slice(start - 6, start) === 'async ') start -= 6;
let depth = 0;
for (let i = source.indexOf('{', start); i < source.length; i++) {
if (source[i] === '{') depth += 1;
else if (source[i] === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, i + 1);
}
}
assert.fail('unbalanced braces reading ' + name + ' out of chat.js');
}
// The limits and the sentences are read out of the shipped file rather
// than retyped here, so this file cannot go on asserting 500 and 2048
// after someone has changed them — and then pinned to the values the
// spec and the copy list name, so changing one is a deliberate act with
// a failing test in front of it.
function numberConst(name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*(\\d+)\\s*;').exec(src);
assert.ok(m, name + ' is not a plain numeric const in chat.js any more');
return Number(m[1]);
}
function stringConst(name) {
const m = new RegExp('const\\s+' + name + "\\s*=\\s*'([^']*)'\\s*;").exec(src);
assert.ok(m, name + ' is not a plain single-quoted const in chat.js any more');
return m[1];
}
const MAX_BYTES = numberConst('MAX_BYTES');
const MAX_LINES = numberConst('MAX_LINES');
const PINNED_PX = numberConst('PINNED_PX');
const SEND_FAILED = stringConst('SEND_FAILED');
const TOO_LONG = stringConst('TOO_LONG');
test('the shipped limits and sentences are the ones everything else was written against', () => {
assert.equal(MAX_BYTES, 2048, "spec §4.4's 2 KB, and the control's maxlength in partials/chat.html");
assert.equal(MAX_LINES, 500);
assert.equal(SEND_FAILED, 'Could not send. Try again.', 'catalogued in docs/superpowers/copy/2026-09-10-slice-2-strings.md');
assert.equal(TOO_LONG, 'Message is too long.', 'catalogued in docs/superpowers/copy/2026-09-10-slice-2-strings.md');
});
const build = (name, prelude) =>
new Function((prelude || '') + lift(src, name) + '; return ' + name + ';')();
const chatLine = build('chatLine');
const trimLog = build('trimLog');
// chatBytes closes over the pattern of what Go escapes and JavaScript
// does not, which is read out of the source rather than retyped — a
// counter tested against a pattern of the test's own would prove the
// test.
const goEscapes = /const GO_ESCAPES = (\/\[[^/]*\]\/g);/.exec(src);
assert.notEqual(goEscapes, null, 'GO_ESCAPES is not a regular expression in chat.js any more');
const chatBytes = build('chatBytes', 'const GO_ESCAPES = ' + goEscapes[1] + ';\n');
// mount closes over the module's constants and its three helpers, so
// the harness hands it exactly those and nothing else. `document` goes
// in undefined on purpose: mount must reach its document through
// root.ownerDocument, and a panel that falls back to a global is one
// that cannot be mounted inside a frame or a template.
const mount = new Function(
'MAX_BYTES',
'MAX_LINES',
'PINNED_PX',
'SEND_FAILED',
'TOO_LONG',
'document',
'const GO_ESCAPES = ' + goEscapes[1] + ';\n' +
[lift(src, 'chatBytes'), lift(src, 'chatLine'), lift(src, 'trimLog'), lift(src, 'mount')].join('\n') +
'\nreturn mount;',
)(MAX_BYTES, MAX_LINES, PINNED_PX, SEND_FAILED, TOO_LONG, undefined);
// --- the smallest document these functions can be driven against ---
//
// Deliberately missing innerHTML: an element here has textContent,
// setAttribute and appendChild and nothing else, so a line built by
// assigning markup would leave every assertion below reading an empty
// string rather than quietly passing.
function textOf(node) {
return node.tag === '#text' ? node.text : node.textContent;
}
function fakeDoc() {
const doc = {
activeElement: null,
createTextNode: (text) => ({ tag: '#text', text }),
createElement(tag) {
const node = {
tag,
ownerDocument: doc,
className: '',
attrs: {},
children: [],
own: '',
listeners: {},
disabled: false,
value: '',
scrollTop: 0,
clientHeight: 0,
// Every line is this tall, so scrollHeight follows the log's
// length the way a real one does. Zero until a test says
// otherwise, which makes every other test's panel "at the
// bottom" and keeps the scroll out of their way.
lineHeight: 0,
};
Object.defineProperty(node, 'textContent', {
get() {
return node.children.length ? node.children.map(textOf).join('') : node.own;
},
set(value) {
node.own = String(value);
node.children = [];
},
});
Object.defineProperty(node, 'scrollHeight', {
get() {
return node.lineHeight * node.childElementCount;
},
});
Object.defineProperty(node, 'childElementCount', {
get() {
return node.children.filter((c) => c.tag !== '#text').length;
},
});
Object.defineProperty(node, 'firstElementChild', {
get() {
return node.children.find((c) => c.tag !== '#text') || null;
},
});
Object.defineProperty(node, 'firstChild', {
get() {
return node.children[0] || null;
},
});
node.setAttribute = (key, value) => {
node.attrs[key] = String(value);
};
node.appendChild = (child) => {
node.children.push(child);
return child;
};
node.removeChild = (child) => {
node.children.splice(node.children.indexOf(child), 1);
return child;
};
node.contains = (other) => {
if (!other) return false;
if (other === node) return true;
return node.children.some((c) => c.contains && c.contains(other));
};
node.querySelector = (sel) => {
const key = sel.replace(/^\[|\]$/g, '');
for (const child of node.children) {
if (child.attrs && key in child.attrs) return child;
const deeper = child.querySelector && child.querySelector(sel);
if (deeper) return deeper;
}
return null;
};
node.addEventListener = (type, fn) => {
(node.listeners[type] = node.listeners[type] || []).push(fn);
};
node.focus = () => {
doc.activeElement = node;
};
return node;
},
};
return doc;
}
// fire dispatches one event and hands the event object back, so a test
// can ask whether the handler called preventDefault.
function fire(node, type, extra) {
const ev = Object.assign(
{
defaultPrevented: false,
preventDefault() {
ev.defaultPrevented = true;
},
},
extra,
);
for (const fn of node.listeners[type] || []) fn(ev);
return ev;
}
// settle drains the microtask queue, which is where mount's send chain
// lives.
const settle = () => new Promise((resolve) => setImmediate(resolve));
const bare = fakeDoc();
const partOf = (li, cls) => li.children.find((n) => n.className === cls);
// --- chatLine: §6's DOM safety ---
test('a message is written as text, so an angle bracket stays an angle bracket', () => {
const nasty = '
& bold';
const li = chatLine(bare, { name: 'Ann', text: nasty, at: new Date('2026-09-10T09:07:00Z') });
assert.equal(partOf(li, 'chat-text').textContent, nasty);
// The whole line reads as text too: nothing was smuggled in as markup
// through the name either.
const line = chatLine(bare, { name: '