const { test } = require('node:test') const assert = require('node:assert/strict') const fs = require('node:fs') const vm = require('node:vm') const ts = require('typescript') const React = require('react') function harness() { let layoutEffects = [] let effects = [] let resizeCallback let disconnected = false const states = [] const refs = [] let stateIndex = 0 let refIndex = 0 const submissions = [] const input = { style: {}, scrollHeight: 56, getBoundingClientRect: () => ({ width: 300 }) } const exports = {} const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8') vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, { exports, window: { getComputedStyle: () => ({ lineHeight: '20px', paddingTop: '10px', paddingBottom: '10px', borderTopWidth: '0px', borderBottomWidth: '0px', minHeight: '56px' }) }, ResizeObserver: class { constructor(callback) { resizeCallback = callback } observe(element) { assert.equal(element, input) } disconnect() { disconnected = true } }, require(name) { if (name === '../i18n') return { t: key => key, useLocaleRender() {} } if (name === './chatAttachments') return {} if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null } if (name === './ChatImageAttachments') return { ChatImageAttachments: () => null } if (name === './ChatCodeBlock') return {} if (name === 'react') return { ...React, useState(initial) { const index = stateIndex++ if (!(index in states)) states[index] = initial return [states[index], value => { states[index] = value }] }, useRef(initial) { return refs[refIndex++] ??= { current: initial } }, useEffect(callback) { effects.push(callback) }, useLayoutEffect(callback) { layoutEffects.push(callback) } } return require(name) } }) function find(node, type) { if (!React.isValidElement(node)) return null if (node.type === type) return node return React.Children.toArray(node.props.children).map(child => find(child, type)).find(Boolean) } function render() { stateIndex = 0 refIndex = 0 layoutEffects = [] effects = [] const tree = exports.QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit: content => submissions.push(content) }) const textarea = find(tree, 'textarea') textarea.props.ref.current = input layoutEffects.forEach(callback => callback()) return textarea } return { input, render, submissions, mountEffects: () => effects.map(callback => callback()), resize: width => resizeCallback([{ contentRect: { width } }]), disconnected: () => disconnected } } test('input grows to eight visible lines and scrolls only above the cap', () => { const h = harness() h.render() assert.equal(h.input.style.height, '56px') assert.equal(h.input.style.overflowY, 'hidden') h.input.scrollHeight = 100 h.render() assert.equal(h.input.style.height, '100px') h.input.scrollHeight = 180 h.render() assert.equal(h.input.style.height, '180px') assert.equal(h.input.style.overflowY, 'hidden') h.input.scrollHeight = 300 h.render() assert.equal(h.input.style.height, '180px') assert.equal(h.input.style.overflowY, 'auto') }) test('deleting text and submitting restore the initial height', () => { const h = harness() let textarea = h.render() textarea.props.onChange({ target: { value: 'question\nsecond line\nthird line' } }) h.input.scrollHeight = 80 textarea = h.render() assert.equal(h.input.style.height, '80px') let prevented = false textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: {}, preventDefault() { prevented = true } }) assert.equal(prevented, true) assert.equal(h.submissions.length, 1) h.input.scrollHeight = 40 textarea = h.render() assert.equal(textarea.props.value, '') assert.equal(h.input.style.height, '56px') assert.equal(h.input.style.overflowY, 'hidden') }) test('Shift+Enter and IME composition do not submit', () => { const h = harness() let textarea = h.render() textarea.props.onChange({ target: { value: 'question' } }) textarea = h.render() const preventDefault = () => assert.fail('newline or composing Enter must not be intercepted') textarea.props.onKeyDown({ key: 'Enter', shiftKey: true, nativeEvent: {}, preventDefault }) textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: { isComposing: true }, preventDefault }) assert.equal(h.submissions.length, 0) }) test('panel width changes recalculate wrapped lines and observer disconnects on unmount', () => { const h = harness() h.render() const cleanups = h.mountEffects() h.input.scrollHeight = 120 h.resize(180) assert.equal(h.input.style.height, '120px') h.input.scrollHeight = 50 h.resize(400) assert.equal(h.input.style.height, '56px') cleanups.forEach(cleanup => cleanup?.()) assert.equal(h.disconnected(), true) })