保存今天的修改
This commit is contained in:
+370
-11
@@ -6,13 +6,14 @@ 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){
|
||||
async function workspace(compact = false, initialHooks = {}){
|
||||
const elements = new Map();
|
||||
const documentListeners = {};
|
||||
let document;
|
||||
@@ -43,6 +44,7 @@ async function workspace(compact = false){
|
||||
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()];
|
||||
@@ -52,6 +54,8 @@ async function workspace(compact = false){
|
||||
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;
|
||||
@@ -59,35 +63,390 @@ async function workspace(compact = false){
|
||||
addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
|
||||
};
|
||||
const calls = [];
|
||||
const hooks = {};
|
||||
const hooks = initialHooks;
|
||||
const timers = new Map(), intervals = new Map();
|
||||
let timerId = 0;
|
||||
const context = vm.createContext({
|
||||
document, console,
|
||||
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; }},
|
||||
setInterval(){}, setTimeout(){}, clearTimeout(){},
|
||||
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});
|
||||
if(url === '/api/human/send' && hooks.send) return hooks.send(body);
|
||||
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();
|
||||
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url);
|
||||
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 ? {ok:true} : []);
|
||||
return response(options && options.method ? {ok:true} : []);
|
||||
},
|
||||
});
|
||||
vm.runInContext(sharedSource, context, {filename:'support.js'});
|
||||
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, document, documentListeners};
|
||||
return {context, element: id => document.getElementById(id), calls, hooks, 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);
|
||||
|
||||
Reference in New Issue
Block a user