// node ui/js/views/views.test.mjs (docker run --rm -v $PWD:/w -w /w node:22-alpine node ui/js/views/views.test.mjs) import { readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const dir = dirname(fileURLToPath(import.meta.url)); function load(name) { const m = { exports: {} }; new Function('module', 'window', readFileSync(join(dir, name), 'utf8'))(m, undefined); return m.exports; } const claude = load('claude.js'), terminal = load('terminal.js'), files = load('files.js'); let n = 0; const test = (name, fn) => { fn(); n++; console.log('ok', name); }; const ev = (event, id = 7) => ({ id, event }); test('a streamed answer grows live, then the assistant message replaces it', () => { const st = claude.newState(); claude.applyEvent(st, ev({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'text' } } })); claude.applyEvent(st, ev({ type: 'stream_event', event: { type: 'content_block_delta', delta: { text: 'Hel' } } })); claude.applyEvent(st, ev({ type: 'stream_event', event: { type: 'content_block_delta', delta: { text: 'lo' } } })); assert.equal(st.live.buf, 'Hello'); assert.equal(st.working, true); assert.equal(st.items.length, 0); assert.equal(claude.applyEvent(st, ev({ type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } })), 'items'); assert.equal(st.live, null); assert.deepEqual(st.items, [{ role: 'assistant', text: 'Hello' }]); }); test('thinking, tool use, tool errors', () => { const st = claude.newState(); claude.applyEvent(st, ev({ type: 'assistant', session_id: 'S1', message: { content: [{ type: 'thinking', thinking: 'hm' }, { type: 'tool_use', name: 'Bash', input: { command: 'ls' } }] } })); claude.applyEvent(st, ev({ type: 'user', message: { content: [{ type: 'tool_result', is_error: true, content: [{ text: 'boom' }] }] } })); assert.deepEqual(st.items.map((i) => i.role), ['thinking', 'tool', 'tool-error']); assert.equal(st.items[2].text, 'boom'); assert.equal(st.session, 'S1'); assert.equal(st.tools, 1); }); test('result adds done, stops working; errors and denials', () => { const st = claude.newState(); claude.applyEvent(st, ev({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: 'a' } }] } })); claude.applyEvent(st, ev({ type: 'result', is_error: true, result: 'oops', duration_ms: 3400, permission_denials: [{ tool_name: 'Bash' }] })); assert.deepEqual(st.items.map((i) => i.role), ['tool', 'error', 'note', 'done']); assert.match(st.items[3].text, /Stopped · 3 s · 1 action/); assert.equal(st.working, false); }); test('exit with an error shows it', () => { const st = claude.newState(); st.working = true; claude.applyExit(st, { id: 1, code: 1, error: 'no claude' }); assert.equal(st.working, false); assert.equal(st.items[0].text, 'no claude'); }); test('snap replaces the conversation and keeps conv', () => { const st = claude.newState(); st.items.push({ role: 'user', text: 'x' }); assert.equal(claude.applySnap(st, { panel: 'claude', conv: 'c1', items: [{ role: 'user', text: 'a' }, { role: 'assistant', text: 'b' }] }), 'reset'); assert.equal(st.conv, 'c1'); assert.equal(st.items.length, 2); }); test('toolLabel like the IDE panel', () => { assert.deepEqual(claude.toolLabel('Bash', { command: 'ls -la' }), { label: '$', what: 'ls -la' }); assert.equal(claude.toolLabel('Edit', { file_path: 'src/A.php' }).what, 'src/A.php'); assert.equal(claude.toolLabel('mcp__jumphp-ide__read_file', { path: 'x' }).label, 'read file'); assert.equal(claude.toolLabel('Mystery', { description: 'd' }).label, 'Mystery'); }); test('terminal base64 decoding (raw bytes, url-safe, no padding)', () => { assert.deepEqual([...terminal.decode('aGk=')], [104, 105]); assert.deepEqual([...terminal.decode('aGk')], [104, 105]); assert.deepEqual([...terminal.decode(Buffer.from([0xff, 0xfe, 0x1b, 0x5b]).toString('base64url'))], [0xff, 0xfe, 0x1b, 0x5b]); assert.equal(terminal.decode('').length, 0); }); test('terminal messages are interpreted', () => { const b = Buffer.from('\x1b[1mhi').toString('base64'); assert.deepEqual(terminal.interpret('snap', { panel: 'terminal', id: 3, data: b, cols: 80, rows: 24 }).cols, 80); assert.equal(terminal.interpret('snap', { panel: 'claude', id: 3 }), null); assert.equal(terminal.interpret('terminal.output', { id: 'a', data: b }).kind, 'output'); assert.equal(terminal.interpret('terminal.exit', { id: 1 }).kind, 'exit'); assert.equal(terminal.interpret('terminal.output', null), null); }); test('ctrl turns letters into control codes', () => { assert.equal(terminal.applyCtrl('c'), '\x03'); assert.equal(terminal.applyCtrl('D'), '\x04'); assert.equal(terminal.applyCtrl('|'), '|'); }); test('file tree: folders first, nested', () => { const t = files.buildTree([{ path: 'b.php', size: 1 }, { path: 'src/Z.php', size: 2 }, { path: 'src/a/x.php', size: 3 }, { path: 'README.md', size: 4 }]); assert.deepEqual(t.children.map((c) => c.name), ['src', 'b.php', 'README.md'].sort((a, b) => 0) && ['src', 'b.php', 'README.md']); assert.deepEqual(t.children[0].children.map((c) => c.name), ['a', 'Z.php']); assert.equal(t.children[0].children[0].children[0].path, 'src/a/x.php'); }); test('lines are cut at the maximum', () => { const r = files.toLines('a\nb\nc\n', 2); assert.deepEqual(r, { lines: ['a', 'b'], cut: 1 }); assert.deepEqual(files.toLines('x\r\ny', 10).lines, ['x', 'y']); }); console.log(n + ' passed');