299 lines
14 KiB
JavaScript
299 lines
14 KiB
JavaScript
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); }
|
|
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)) elements.set(id, new Element()); 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){ documentListeners[name] = 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:'<img onerror=bad()>', content:'<script>bad()</script>', 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, /<script>/);
|
|
});
|