691 lines
36 KiB
JavaScript
691 lines
36 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 ticketsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tickets.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, initialHooks = {}){
|
|
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; }
|
|
scrollIntoView(){}
|
|
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){
|
|
if(selector === 'meta[name="staff-csrf-token"]') return {content:'fixture-csrf'};
|
|
if(selector === 'meta[name="staff-username"]') return {content:'alice'};
|
|
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 = [], dialogs = [];
|
|
const hooks = initialHooks;
|
|
const timers = new Map(), intervals = new Map();
|
|
let timerId = 0;
|
|
const context = vm.createContext({
|
|
document, console, AbortController,
|
|
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; },
|
|
location:{replace(url){ calls.push({redirect:url}); }}},
|
|
setInterval(callback, delay){ const id=++timerId; intervals.set(id,{callback,delay}); return id; },
|
|
setTimeout(callback, delay){ const id=++timerId; timers.set(id,{callback,delay}); return id; },
|
|
clearTimeout(id){ timers.delete(id); },
|
|
clearInterval(id){ intervals.delete(id); },
|
|
fetch: async (url, options) => {
|
|
const body = options && options.body ? JSON.parse(options.body) : undefined;
|
|
calls.push({url, body, signal:options && options.signal, headers:options && options.headers});
|
|
if(url.startsWith('/api/tickets/summary')) return hooks.ticketSummary ? hooks.ticketSummary(url, options) : response({pending_count:0,latest_id:0,new_count:0});
|
|
if(/^\/api\/tickets\/\d+\/events/.test(url)) return hooks.ticketEvents ? hooks.ticketEvents(url, options) : response([]);
|
|
if(url === '/admin/logout' && hooks.logout) return hooks.logout(options);
|
|
if(url.startsWith('/api/tickets') && hooks.tickets) return hooks.tickets(url, body, options);
|
|
if(url === '/api/human/send' && hooks.send) return hooks.send(body, options);
|
|
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(options);
|
|
if(url === '/api/human/active' && hooks.active) return hooks.active(options);
|
|
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url, options);
|
|
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 && options.method ? {ok:true} : []);
|
|
},
|
|
});
|
|
vm.runInContext(sharedSource, context, {filename:'support.js'});
|
|
context.SupportUI.confirm = async (message, options) => { dialogs.push({message, options}); return initialHooks.confirmResult ?? true; };
|
|
vm.runInContext(toolsSource, context, {filename:'admin_tools.js'});
|
|
vm.runInContext(ticketsSource, context, {filename:'admin_tickets.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, dialogs, document, documentListeners, timers,
|
|
runTimers:async delay=>{
|
|
for(const [id,timer] of Array.from(timers)){
|
|
if(timer.delay===delay){ timers.delete(id); timer.callback(); }
|
|
}
|
|
await flush();
|
|
},
|
|
tick:async delay=>{
|
|
for(const timer of intervals.values()) if(timer.delay===delay) timer.callback();
|
|
await flush();
|
|
},
|
|
};
|
|
}
|
|
|
|
function waitForAbort(options){
|
|
return new Promise((resolve,reject)=>{
|
|
if(!options || !options.signal) return;
|
|
options.signal.addEventListener('abort',()=>{
|
|
const error=new Error('Request aborted'); error.name='AbortError'; reject(error);
|
|
},{once:true});
|
|
});
|
|
}
|
|
|
|
test('ticket polling preserves drafts and stale versions require explicit review', async () => {
|
|
const app = await workspace();
|
|
let ticket = {id:10,content:'查询派送',phone:'123',ctime:'now',status:'processing',owner_username:'alice',owner_name:'Alice',note:'原备注',version:1};
|
|
app.hooks.tickets = async url => response(url.includes('?') ? [ticket] : {ok:true,ticket});
|
|
await app.context.loadTickets();
|
|
app.element('ticket-note').value='我的未保存备注';
|
|
app.element('ticket-note').listeners.input();
|
|
ticket = {...ticket, note:'另一页面更新', version:2};
|
|
await app.context.loadTickets();
|
|
assert.equal(app.element('ticket-note').value,'我的未保存备注');
|
|
assert.equal(app.element('ticket-save').disabled,true);
|
|
assert.match(app.element('ticket-server-note').textContent,/另一页面更新/);
|
|
app.element('ticket-rebase').onclick();
|
|
assert.equal(app.element('ticket-save').disabled,false);
|
|
});
|
|
|
|
test('human queue displays five-minute overdue state and notifies once when it crosses the threshold', async () => {
|
|
const app=await workspace();
|
|
let overdue=false;
|
|
app.hooks.queue=async()=>response([{id:119,ctime:'2026-11-02 16:47:39',waiting_minutes:overdue?5:4,overdue,overdue_minutes:overdue?1:0}]);
|
|
const notices=[];
|
|
app.context.SupportUI.notify=(...args)=>notices.push(args);
|
|
await app.context.loadQueue();
|
|
assert.match(app.element('queue').innerHTML,/等待 4 分钟/);
|
|
assert.equal(app.element('queue-overdue-count').textContent,'超时 0');
|
|
assert.equal(notices.filter(item=>item[0]==='新客户排队').length,1);
|
|
overdue=true;
|
|
await app.context.loadQueue();
|
|
assert.match(app.element('queue').innerHTML,/排队超时 1 分钟/);
|
|
assert.equal(app.element('queue-overdue-count').textContent,'超时 1');
|
|
assert.equal(notices.filter(item=>item[0]==='人工排队超时').length,1);
|
|
await app.context.loadQueue();
|
|
assert.equal(notices.filter(item=>item[0]==='人工排队超时').length,1);
|
|
});
|
|
|
|
test('ticket writes use current owner, CSRF and preserve the draft on network failure', async () => {
|
|
const app = await workspace();
|
|
const ticket = {id:10,content:'待处理留言',status:'processing',owner_username:'alice',note:'',version:1};
|
|
app.hooks.tickets = async (url, body, options) => options && options.method ? response({ok:false,error:'版本冲突'},409) : response(url.includes('?') ? [ticket] : {ok:true,ticket});
|
|
await app.context.loadTickets();
|
|
app.element('ticket-note').value='处理结果';
|
|
app.element('ticket-note').listeners.input();
|
|
await app.element('ticket-complete').onclick();
|
|
const call=app.calls.find(call=>call.url==='/api/tickets/10' && call.body);
|
|
assert.equal(call.headers['X-CSRF-Token'],'fixture-csrf');
|
|
assert.equal(call.body.status,'completed');
|
|
assert.equal(call.body.version,1);
|
|
assert.equal(app.element('ticket-note').value,'处理结果');
|
|
assert.match(app.element('ticket-status-message').textContent,/版本冲突/);
|
|
assert.equal(app.element('ticket-complete').disabled,false);
|
|
});
|
|
|
|
test('tickets assigned to other staff are read-only and escaped in the list', async () => {
|
|
const app=await workspace();
|
|
app.hooks.tickets=async()=>response([{id:12,content:'<img src=x onerror=alert(1)>',status:'processing',owner_username:'bob',owner_name:'Bob',version:1,note:'备注'}]);
|
|
await app.context.loadTickets();
|
|
assert.equal(app.element('ticket-note').readOnly,true);
|
|
assert.equal(app.element('ticket-save').hidden,true);
|
|
assert.match(app.element('ticketList').innerHTML,/<img/);
|
|
assert.doesNotMatch(app.element('ticketList').innerHTML,/<img/);
|
|
});
|
|
|
|
test('a delayed ticket list cannot overwrite a newer status filter', async () => {
|
|
const app=await workspace();
|
|
let resolveOld, count=0;
|
|
app.hooks.tickets=async()=>{
|
|
if(++count===1) return new Promise(resolve=>{resolveOld=resolve;});
|
|
return response([{id:20,content:'已完成留言',status:'completed',owner_username:'alice',note:'处理结果',version:2}]);
|
|
};
|
|
const pending=app.context.loadTickets();
|
|
app.element('ticket-filter').value='completed';
|
|
app.element('ticket-filter').listeners.change();
|
|
await flush();
|
|
resolveOld(response([{id:10,content:'旧列表',status:'pending',version:0}]));
|
|
await pending;
|
|
assert.equal(app.element('ticket-state').textContent,'已完成');
|
|
assert.doesNotMatch(app.element('ticketList').innerHTML,/旧列表/);
|
|
});
|
|
|
|
test('a hung ticket save releases controls and retains notes after the request timeout', async () => {
|
|
const app=await workspace();
|
|
const ticket={id:10,content:'留言',status:'processing',owner_username:'alice',note:'',version:1};
|
|
app.hooks.tickets=async(url,body,options)=>options && options.method ? waitForAbort(options) : response([ticket]);
|
|
await app.context.loadTickets();
|
|
app.element('ticket-note').value='不能丢失的备注';
|
|
app.element('ticket-note').listeners.input();
|
|
const pending=app.element('ticket-save').onclick();
|
|
assert.equal(app.element('ticket-save').disabled,true);
|
|
await app.runTimers(10000);
|
|
await pending;
|
|
assert.equal(app.element('ticket-save').disabled,false);
|
|
assert.equal(app.element('ticket-note').value,'不能丢失的备注');
|
|
assert.match(app.element('ticket-status-message').textContent,/未确认成功.*备注已保留/);
|
|
});
|
|
|
|
test('ticket search is sent to the server and retained for pagination and status changes', async () => {
|
|
const app=await workspace();
|
|
app.hooks.tickets=async()=>response(Array.from({length:20},(_,index)=>({id:100-index,content:'查询派送',status:'pending',version:0})));
|
|
app.element('ticket-search').value='138 &小林';
|
|
await app.element('ticket-search-form').listeners.submit({preventDefault(){}});
|
|
let listCalls=app.calls.filter(call=>call.url.startsWith('/api/tickets?'));
|
|
assert.equal(new URL(listCalls.at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
|
|
await app.element('ticket-more').onclick();
|
|
listCalls=app.calls.filter(call=>call.url.startsWith('/api/tickets?'));
|
|
assert.match(listCalls.at(-1).url,/before=81/);
|
|
assert.equal(new URL(listCalls.at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
|
|
app.element('ticket-filter').value='pending';
|
|
app.element('ticket-filter').listeners.change();
|
|
await flush();
|
|
assert.equal(new URL(app.calls.filter(call=>call.url.startsWith('/api/tickets?')).at(-1).url,'http://local').searchParams.get('q'),'138 &小林');
|
|
});
|
|
|
|
test('new ticket alerts skip old records, deduplicate new arrivals and keep polling on older pages', async () => {
|
|
let version=1;
|
|
const app=await workspace(false,{ticketSummary:async()=>response({pending_count:24,latest_id:version,new_count:version===2?1:0})});
|
|
const notices=[];
|
|
app.context.SupportUI.notify=(...args)=>notices.push(args);
|
|
await app.context.loadTickets();
|
|
assert.equal(app.element('ticket-pending-count').textContent,'24');
|
|
assert.equal(notices.length,0);
|
|
app.hooks.tickets=async()=>response(Array.from({length:20},(_,index)=>({id:40-index,content:'旧留言',status:'pending',version:0})));
|
|
await app.context.loadTickets();
|
|
await app.element('ticket-more').onclick();
|
|
version=2;
|
|
await app.context.loadTickets();
|
|
assert.match(app.element('ticket-alert-text').textContent,/1 条新留言/);
|
|
assert.equal(app.element('ticket-alert').hidden,false);
|
|
assert.equal(notices.length,1);
|
|
await app.context.loadTickets();
|
|
assert.equal(notices.length,1);
|
|
version=3;
|
|
await app.context.loadTickets();
|
|
assert.equal(notices.length,1);
|
|
await app.element('ticket-alert-view').onclick();
|
|
assert.equal(app.element('ticket-filter').value,'pending');
|
|
assert.equal(app.element('ticket-search').value,'');
|
|
assert.equal(app.element('ticket-alert').hidden,true);
|
|
});
|
|
|
|
test('ticket header entry focuses the search field and IME confirmation does not submit', async () => {
|
|
const app=await workspace(true);
|
|
await app.element('ticket-inbox-toggle').onclick();
|
|
assert.equal(app.document.body.classList.contains('tools-open'),true);
|
|
assert.equal(app.document.activeElement,app.element('ticket-search'));
|
|
let prevented=false;
|
|
app.element('ticket-search').listeners.keydown({key:'Enter',isComposing:true,preventDefault(){prevented=true;}});
|
|
assert.equal(prevented,true);
|
|
});
|
|
|
|
test('overdue count stays global and the header opens all overdue records', async () => {
|
|
const app=await workspace(true,{ticketSummary:async()=>response({pending_count:8,latest_id:10,new_count:0,overdue_count:3})});
|
|
assert.equal(app.element('ticket-overdue-count').textContent,'3');
|
|
app.hooks.tickets=async()=>response([{id:10,content:'超时件',status:'pending',version:0,overdue_kind:'pending',overdue_minutes:7}]);
|
|
await app.element('ticket-overdue-toggle').onclick();
|
|
const query=new URL(app.calls.filter(call=>call.url.startsWith('/api/tickets?')).at(-1).url,'http://local');
|
|
assert.equal(query.searchParams.get('status'),'all');
|
|
assert.equal(query.searchParams.get('overdue'),'1');
|
|
assert.match(app.element('ticketList').innerHTML,/领取超时 7 分钟/);
|
|
assert.match(app.element('ticket-overdue-hint').textContent,/7 分钟/);
|
|
app.element('tools-close').onclick();
|
|
assert.equal(app.document.activeElement,app.element('ticket-overdue-toggle'));
|
|
await app.element('ticket-inbox-toggle').onclick();
|
|
assert.equal(app.element('ticket-overdue-filter').checked,false);
|
|
});
|
|
|
|
test('history is escaped, paginated and not reloaded by an unchanged poll', async () => {
|
|
const app=await workspace();
|
|
app.hooks.tickets=async()=>response([{id:10,content:'留言',status:'processing',version:1}]);
|
|
let historyCalls=0;
|
|
app.hooks.ticketEvents=async()=>{historyCalls++;return response(Array.from({length:20},(_,index)=>({id:40-index,action:'note_updated',actor_name:'<img>',ctime:'today',old_note:'旧备注',new_note:'<script>evil</script>'})));};
|
|
await app.context.loadTickets(); await flush();
|
|
assert.match(app.element('ticket-history-list').innerHTML,/<script>/);
|
|
assert.doesNotMatch(app.element('ticket-history-list').innerHTML,/<script>/);
|
|
assert.match(app.element('ticket-history-list').innerHTML,/旧备注/);
|
|
await app.context.loadTickets(); await flush();
|
|
assert.equal(historyCalls,1);
|
|
await app.element('ticket-history-more').onclick();
|
|
assert.match(app.calls.filter(call=>call.url.includes('/events?')).at(-1).url,/before=21/);
|
|
});
|
|
|
|
test('a late history response cannot replace the newly selected ticket history', async () => {
|
|
const app=await workspace();
|
|
let resolveOld;
|
|
app.hooks.tickets=async()=>response([{id:10,content:'A',status:'pending',version:0},{id:11,content:'B',status:'pending',version:0}]);
|
|
app.hooks.ticketEvents=async url=>url.includes('/10/') ? new Promise(resolve=>{resolveOld=resolve;}) : response([{id:4,action:'claimed',actor_name:'B客服',ctime:'today'}]);
|
|
await app.context.loadTickets();
|
|
app.element('ticketList').listeners.click({target:{closest(){return {getAttribute(){return '11';}};}}});
|
|
await flush();
|
|
resolveOld(response([{id:1,action:'claimed',actor_name:'A客服',ctime:'today'}])); await flush();
|
|
assert.match(app.element('ticket-history-list').innerHTML,/B客服/);
|
|
assert.doesNotMatch(app.element('ticket-history-list').innerHTML,/A客服/);
|
|
});
|
|
|
|
test('history failures offer retry without blocking a ticket save', async () => {
|
|
const app=await workspace();
|
|
const ticket={id:10,content:'留言',status:'processing',owner_username:'alice',version:1};
|
|
app.hooks.tickets=async(url,body)=>body ? response({ok:true,ticket:{...ticket,version:2,note:body.note}}) : response([ticket]);
|
|
app.hooks.ticketEvents=async()=>response({},500);
|
|
await app.context.loadTickets(); await flush();
|
|
assert.match(app.element('ticket-history-status').textContent,/失败.*重试/);
|
|
app.element('ticket-note').value='处理结果'; app.element('ticket-note').listeners.input();
|
|
await app.element('ticket-save').onclick(); await flush();
|
|
assert.match(app.element('ticket-status-message').textContent,/备注已保存/);
|
|
app.hooks.ticketEvents=async()=>response([{id:2,action:'completed',actor_name:'客服',ctime:'today',new_note:'结果'}]);
|
|
await app.element('ticket-history-reload').onclick();
|
|
assert.match(app.element('ticket-history-list').innerHTML,/处理结果:结果/);
|
|
});
|
|
|
|
test('staff writes include CSRF and logout revokes the session before redirecting', async () => {
|
|
const app = await workspace();
|
|
await app.context.logoutStaff();
|
|
const logout = app.calls.find(call=>call.url==='/admin/logout');
|
|
assert.equal(logout.headers['X-CSRF-Token'], 'fixture-csrf');
|
|
assert.equal(app.calls.filter(call=>call.redirect==='/admin').length, 1);
|
|
});
|
|
|
|
test('a rejected logout remains actionable and does not claim success', async () => {
|
|
const app = await workspace();
|
|
app.hooks.logout=async()=>response({},500);
|
|
await app.context.logoutStaff();
|
|
assert.equal(app.element('staff-logout').disabled, false);
|
|
assert.match(app.element('staff-logout-status').textContent,/退出失败/);
|
|
assert.equal(app.calls.filter(call=>call.redirect).length,0);
|
|
});
|
|
|
|
test('expired staff sessions redirect once and stop sending further polling requests', async () => {
|
|
const app = await workspace();
|
|
app.hooks.queue=async()=>response({},401);
|
|
app.hooks.active=async()=>response({},401);
|
|
await app.context.loadQueue();
|
|
await app.tick(1000);
|
|
await app.tick(3000);
|
|
const count=app.calls.length;
|
|
await app.context.loadQueue();
|
|
assert.equal(app.calls.length,count);
|
|
assert.equal(app.calls.filter(call=>call.redirect==='/admin?expired=1').length,1);
|
|
});
|
|
|
|
test('duplicate logout clicks send only one request', async () => {
|
|
const app=await workspace();
|
|
let finish;
|
|
app.hooks.logout=()=>new Promise(resolve=>{ finish=resolve; });
|
|
const first=app.context.logoutStaff();
|
|
await app.context.logoutStaff();
|
|
assert.equal(app.calls.filter(call=>call.url==='/admin/logout').length,1);
|
|
finish(response({ok:true}));
|
|
await first;
|
|
});
|
|
|
|
test('a hanging reply times out, preserves the newer draft and waits for manual retry', async () => {
|
|
const app=await workspace();
|
|
app.hooks.send=(body,options)=>waitForAbort(options);
|
|
app.element('replyText').value='先发送的回复';
|
|
const sending=app.context.sendReply();
|
|
app.element('replyText').value='继续补充的草稿';
|
|
app.element('replyText').listeners.input();
|
|
await app.runTimers(10000);
|
|
assert.equal(app.element('reply-send').disabled,false);
|
|
await sending;
|
|
assert.equal(app.context.sendingSid,'');
|
|
assert.equal(app.element('replyText').value,'继续补充的草稿');
|
|
assert.match(app.element('reply-status').textContent,/发送超时.*内容已保留.*确认聊天记录/);
|
|
await app.tick(1000);
|
|
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,1);
|
|
app.hooks.send=()=>response({ok:true});
|
|
await app.context.sendReply();
|
|
assert.equal(app.element('replyText').value,'');
|
|
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,2);
|
|
});
|
|
|
|
test('a hung message poll times out and the next interval resumes from the last received message', async () => {
|
|
const app=await workspace();
|
|
app.context.sessions['7'].lastId=12;
|
|
app.hooks.poll=(url,options)=>waitForAbort(options);
|
|
const polling=app.context.poll();
|
|
await flush();
|
|
await app.tick(1000);
|
|
assert.equal(app.calls.filter(call=>call.url==='/api/human/poll?sid=7&after=12').length,1);
|
|
await app.runTimers(10000);
|
|
assert.equal(app.context.polling,false);
|
|
await polling;
|
|
assert.equal(app.element('sync-status').hidden,false);
|
|
app.hooks.poll=()=>response({status:'active',messages:[{id:13,side:'customer',content:'断线恢复后的新消息',ctime:'2026-11-02 12:00'}]});
|
|
await app.tick(1000);
|
|
assert.equal(app.element('sync-status').hidden,true);
|
|
assert.equal(app.context.sessions['7'].lastId,13);
|
|
assert.equal(app.context.sessions['7'].msgs.length,1);
|
|
await app.tick(1000);
|
|
assert.equal(app.context.sessions['7'].msgs.length,1);
|
|
assert.ok(app.calls.some(call=>call.url==='/api/human/poll?sid=7&after=13'));
|
|
});
|
|
|
|
test('a hung active-session list releases the poll lock on timeout and reconnects', async () => {
|
|
const app=await workspace();
|
|
app.hooks.active=options=>waitForAbort(options);
|
|
const polling=app.context.poll();
|
|
await app.runTimers(10000);
|
|
assert.equal(app.context.polling,false);
|
|
await polling;
|
|
assert.equal(app.element('sync-status').hidden,false);
|
|
app.hooks.active=()=>response([{id:7}]);
|
|
await app.tick(1000);
|
|
assert.equal(app.element('sync-status').hidden,true);
|
|
});
|
|
|
|
test('the timeout also covers a response body that never finishes and is cleared after failures', async () => {
|
|
const app=await workspace();
|
|
app.hooks.queue=options=>({ok:true,json:()=>waitForAbort(options)});
|
|
const loading=app.context.loadQueue();
|
|
await flush();
|
|
await app.runTimers(10000);
|
|
assert.match(app.element('queue').innerHTML,/队列暂时无法加载/);
|
|
await loading;
|
|
assert.equal(app.timers.size,0);
|
|
app.hooks.queue=()=>response([],503);
|
|
await app.context.loadQueue();
|
|
assert.equal(app.timers.size,0);
|
|
app.hooks.queue=()=>response([]);
|
|
await app.context.loadQueue();
|
|
assert.match(app.element('queue').innerHTML,/当前没有排队会话/);
|
|
assert.equal(app.timers.size,0);
|
|
});
|
|
|
|
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('reassignment removes the old owners composer but retains unsent text for recovery', async () => {
|
|
const app = await workspace();
|
|
app.element('replyText').value = '尚未发送的处理意见';
|
|
app.hooks.poll = () => response({status:'active',owner_username:'bob',messages:[]});
|
|
await app.context.poll();
|
|
assert.equal(app.context.curSid,0);
|
|
assert.equal(app.context.sessions['7'],undefined);
|
|
assert.equal(app.context.drafts['7'],'尚未发送的处理意见');
|
|
assert.equal(app.element('routing-draft-recovery').hidden,false);
|
|
assert.match(app.element('routing-retired-drafts').innerHTML,/尚未发送的处理意见/);
|
|
});
|
|
|
|
test('moving a session to an offline group preserves the previous owners draft', async () => {
|
|
const app = await workspace();
|
|
app.element('replyText').value = '需要转交的草稿';
|
|
app.hooks.poll = () => response({status:'waiting',owner_username:null,messages:[]});
|
|
await app.context.poll();
|
|
assert.equal(app.context.curSid,0);
|
|
assert.equal(app.context.drafts['7'],'需要转交的草稿');
|
|
assert.match(app.element('routing-retired-drafts').innerHTML,/需要转交的草稿/);
|
|
});
|
|
|
|
test('cancelling the in-page transfer confirmation leaves the session untouched', async () => {
|
|
const app = await workspace(false, {confirmResult:false});
|
|
await app.context.transferSession();
|
|
assert.equal(app.dialogs.length, 1);
|
|
assert.match(app.dialogs[0].message, /确定转接/);
|
|
assert.equal(app.calls.filter(call => call.url === '/api/human/transfer').length, 0);
|
|
assert.equal(app.context.curSid, '7');
|
|
});
|
|
|
|
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>/);
|
|
});
|