// 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: '