const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const source = fs.readFileSync(path.join(__dirname, '../../static/js/admin.js'), 'utf8'); const sharedSource = fs.readFileSync(path.join(__dirname, '../../static/js/support.js'), 'utf8'); const toolsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tools.js'), 'utf8'); const flush = () => new Promise(setImmediate); function response(data, status = 200){ return {ok: status >= 200 && status < 300, status, json: async () => data}; } async function workspace(compact = false){ const elements = new Map(); const documentListeners = {}; let document; class Element { constructor(){ this.value = ''; this.children = []; this.attributes = {}; this.listeners = {}; this.style = {}; this.hidden = false; this.scrollHeight = 70; this.scrollTop = 0; this.clientHeight = 300; const classes = new Set(); this.classList = { toggle: (name, enabled) => { enabled ? classes.add(name) : classes.delete(name); }, contains: name => classes.has(name), }; } set innerHTML(value){ this.html = value; this.children = []; } get innerHTML(){ return this.html || ''; } setAttribute(name, value){ this.attributes[name] = value; } toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; } appendChild(element){ this.children.push(element); } contains(target){ return this === target || this.children.some(child=>child.contains(target)); } addEventListener(name, callback){ this.listeners[name] = callback; } querySelectorAll(){ return []; } querySelector(selector){ if(selector === '.meta') return this.meta || (this.meta = new Element()); return null; } setSelectionRange(start, end){ this.selectionStart = start; this.selectionEnd = end; } showModal(){ this.open = true; } close(){ this.open = false; } focus(){ document.activeElement = this; } click(){ if(this.onclick) return this.onclick(); } } const quickButtons = [new Element(), new Element()]; document = { body: new Element(), activeElement: null, getElementById(id){ if(!elements.has(id)){ const value=new Element(); if(id==='agent-emoji-panel') value.hidden=true; elements.set(id,value); } return elements.get(id); }, createElement(){ return new Element(); }, querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; }, querySelector(selector){ const match = selector.match(/data-message-id="(\d+)"/); if(!match) return null; return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null; }, addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; }, }; const calls = []; const hooks = {}; const context = vm.createContext({ document, console, window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; }}, setInterval(){}, setTimeout(){}, clearTimeout(){}, fetch: async (url, options) => { const body = options && options.body ? JSON.parse(options.body) : undefined; calls.push({url, body}); if(url === '/api/human/send' && hooks.send) return hooks.send(body); if(url === '/api/human/close' && hooks.close) return hooks.close(body); if(url === '/api/human/transfer' && hooks.transfer) return hooks.transfer(body); if(url === '/api/human/note' && hooks.note) return hooks.note(body); if(url === '/api/human/queue' && hooks.queue) return hooks.queue(); if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url); if(url.includes('/api/search_chat') && hooks.search) return hooks.search(url); if(url.includes('/api/human/poll')) return response({status:'active', messages:[]}); if(url === '/api/admin/stats') return response({total_conv:0, total_msg:0, today_conv:0, good:0, bad:0}); return response(options ? {ok:true} : []); }, }); vm.runInContext(sharedSource, context, {filename:'support.js'}); vm.runInContext(toolsSource, context, {filename:'admin_tools.js'}); vm.runInContext(source, context, {filename:'admin.js'}); await flush(); await context.accept(7); await flush(); return {context, element: id => document.getElementById(id), calls, hooks, document, documentListeners}; } test('failed replies preserve the draft and make retry available', async () => { const app = await workspace(); app.hooks.send = () => response({ok:false}, 503); app.element('replyText').value = '请确认这批货物的外箱尺寸'; await app.context.sendReply(); assert.equal(app.element('replyText').value, '请确认这批货物的外箱尺寸'); assert.equal(app.element('reply-send').disabled, false); assert.match(app.element('reply-status').textContent, /内容已保留/); app.hooks.send = () => response({ok:true}); await app.context.sendReply(); assert.equal(app.element('replyText').value, ''); }); test('a second send click cannot duplicate an in-flight reply', async () => { const app = await workspace(); let resolve; app.hooks.send = () => new Promise(done => { resolve = done; }); app.element('replyText').value = '正在确认线路'; const first = app.context.sendReply(); await app.context.sendReply(); assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1); assert.equal(app.element('reply-send').disabled, true); resolve(response({ok:true})); await first; }); test('new text typed while a reply is sending is retained', async () => { const app = await workspace(); let resolve; app.hooks.send = () => new Promise(done => { resolve = done; }); const input = app.element('replyText'); input.value = '第一条回复'; const sending = app.context.sendReply(); input.value = '继续补充的第二条回复'; input.listeners.input(); resolve(response({ok:true})); await sending; assert.equal(input.value, '继续补充的第二条回复'); }); test('switching sessions during a send does not erase another customer draft', async () => { const app = await workspace(); await app.context.accept(8); app.element('replyText').value = '客户 8 的草稿'; app.context.switchSession('7'); app.element('replyText').value = '发给客户 7'; let resolve; app.hooks.send = () => new Promise(done => { resolve = done; }); const sending = app.context.sendReply(); app.context.switchSession('8'); resolve(response({ok:true})); await sending; assert.equal(app.context.curSid, '8'); assert.equal(app.element('replyText').value, '客户 8 的草稿'); app.context.switchSession('7'); assert.equal(app.element('replyText').value, ''); }); test('quick replies become editable drafts before sending', async () => { const app = await workspace(); app.context.quickReply('请提供运单号'); assert.equal(app.element('replyText').value, '请提供运单号'); assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0); assert.match(app.element('reply-status').textContent, /可编辑后发送/); }); test('a failed close keeps the selected session and reply visible', async () => { const app = await workspace(); app.hooks.close = () => response({ok:false}, 503); await app.context.closeSession(); assert.equal(app.context.curSid, '7'); assert.equal(app.element('chatContent').style.display, 'flex'); assert.match(app.element('reply-status').textContent, /会话仍保留/); }); test('IME confirmation and Shift+Enter do not submit a reply', async () => { const app = await workspace(); app.element('replyText').value = '正在输入中文'; const onKey = app.element('replyText').listeners.keydown; const preventDefault = () => {}; onKey({key:'Enter', isComposing:true, shiftKey:false, preventDefault}); onKey({key:'Enter', isComposing:false, shiftKey:true, preventDefault}); await flush(); assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0); onKey({key:'Enter', isComposing:false, shiftKey:false, preventDefault}); await flush(); assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1); }); test('the compact service drawer is keyboard accessible and closes on Escape', async () => { const app = await workspace(true); assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true'); app.context.setToolsOpen(true); assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false'); assert.equal(app.document.activeElement, app.element('tools-close')); app.documentListeners.keydown({key:'Escape'}); assert.equal(app.element('tools-toggle').attributes['aria-expanded'], 'false'); assert.equal(app.document.activeElement, app.element('tools-toggle')); }); test('desktop service information starts closed and can be toggled on demand', async () => { const app = await workspace(); assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true'); app.context.setToolsOpen(true); assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false'); assert.equal(app.element('tools-toggle').attributes['aria-expanded'], 'true'); app.context.setToolsOpen(false); assert.equal(app.element('service-tools').attributes['aria-hidden'], 'true'); }); test('returning to the mobile session list keeps the selected session and draft', async () => { const app = await workspace(true); app.element('replyText').value = '返回列表前的草稿'; app.context.showSessionList(); assert.equal(app.context.curSid, '7'); assert.equal(app.document.body.classList.contains('chat-view'), false); assert.equal(app.document.activeElement, app.element('tab-active')); assert.equal(app.element('replyText').value, '返回列表前的草稿'); app.context.switchSession('7'); assert.equal(app.document.body.classList.contains('chat-view'), true); assert.equal(app.element('replyText').value, '返回列表前的草稿'); assert.equal(app.calls.filter(call => call.url === '/api/human/close').length, 0); }); test('mobile operators can open service information without selecting a conversation', async () => { const app = await workspace(true); app.context.showEmptySession(); app.element('tools-from-list').click(); assert.equal(app.element('service-tools').attributes['aria-hidden'], 'false'); app.element('tools-close').click(); assert.equal(app.document.activeElement, app.element('tools-from-list')); }); test('mobile drafts are measured after the chat view becomes visible', async () => { const app = await workspace(true); const input = app.element('replyText'); Object.defineProperty(input, 'scrollHeight', { get: () => app.document.body.classList.contains('chat-view') ? 128 : 0, }); input.value = '需要恢复的多行草稿'; app.context.showSessionList(); app.context.switchSession('7'); assert.equal(input.style.height, '128px'); assert.equal(input.value, '需要恢复的多行草稿'); }); test('transfer returns a conversation to the queue without auto-accepting it again', async () => { const app = await workspace(); app.hooks.queue = () => response([{id:7, ctime:'now'}]); await app.context.transferSession(); await flush(); assert.equal(app.context.curSid, 0); assert.equal(app.context.sessions['7'], undefined); assert.equal(app.calls.filter(call => call.url === '/api/human/accept').length, 1); }); test('failed transfers retain the conversation and draft', async () => { const app = await workspace(); app.hooks.transfer = () => response({ok:false}, 503); app.element('replyText').value = '等待确认的草稿'; await app.context.transferSession(); assert.equal(app.context.curSid, '7'); assert.equal(app.element('replyText').value, '等待确认的草稿'); assert.match(app.element('reply-status').textContent, /转接未成功/); }); test('a late note response cannot overwrite a different session note', async () => { const app = await workspace(); app.context.sessions['7'].noteLoaded = false; let resolve; app.hooks.poll = url => url.includes('sid=7&') ? new Promise(done => { resolve = done; }) : response({status:'active', note:'客户 8 的备注', messages:[]}); const first = app.context.loadSessionNote('7'); app.context.sessions['8'] = {lastId:0, msgs:[], unread:0}; app.context.switchSession('8'); await flush(); resolve(response({status:'active', note:'客户 7 的备注', messages:[]})); await first; assert.equal(app.element('noteInput').value, '客户 8 的备注'); }); test('failed note saves keep the edited note available', async () => { const app = await workspace(); app.hooks.note = () => response({ok:false}, 503); app.element('noteInput').value = '下个班次继续跟进'; await app.context.saveNote(); assert.equal(app.element('noteInput').value, '下个班次继续跟进'); assert.match(app.element('reply-status').textContent, /备注保存失败/); }); test('recalling an older reply leaves the newest reply intact', async () => { const app = await workspace(); app.context.sessions['7'].msgs = [ {id:1, side:'agent', content:'较早回复'}, {id:2, side:'agent', content:'最新回复'}, ]; app.context.sessions['7'].msgs.forEach(app.context.appendMsg); await app.context.recallMsg(1); assert.equal(app.context.sessions['7'].msgs[0].content, '该消息已撤回'); assert.equal(app.context.sessions['7'].msgs[1].content, '最新回复'); assert.equal(app.element('chatBox').children[0].textContent, '该消息已撤回'); }); test('historical search renders customer text without interpreting HTML', async () => { const app = await workspace(); app.hooks.search = () => response([{title:'', content:'', role:'user', ctime:'now'}]); app.element('searchBox').value = '物流'; await app.context.doSearch(); assert.match(app.element('searchBody').innerHTML, /<script>/); assert.doesNotMatch(app.element('searchBody').innerHTML, /