Files

1090 lines
63 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 flush = () => new Promise(setImmediate);
async function customerPage(pageOptions = {}){
const storageValues = pageOptions.storage || {};
if(!storageValues['baiyue-visitor-key']) storageValues['baiyue-visitor-key']='1'.repeat(64);
const tabValues = pageOptions.tabStorage || {};
function testStorage(values){return {
getItem:key=>values[key] ?? null,setItem:(key,value)=>{values[key]=String(value);},
removeItem:key=>{delete values[key];},key:index=>Object.keys(values)[index] ?? null,
get length(){return Object.keys(values).length;}
};}
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
let intervalId = 0, timerId = 0, nextConvId = 1;
let voiceOptions;
class Element {
constructor(){
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.scrollTop = 0;
this.listeners = {}; this.focusCalls = []; this.isConnected = true;
this.classList = {
contains: name => this.className.split(' ').includes(name),
add: name => { this.className += ' '+name; },
remove: name => { this.className = this.className.split(' ').filter(value => value !== name).join(' '); },
toggle: (name, on) => { this.classList.remove(name); if(on) this.classList.add(name); },
};
}
set innerHTML(value){ this.html = value; this.children = []; }
get innerHTML(){ return this.html || ''; }
set textContent(value){ this.text = value; this.html = ''; this.children = []; }
get textContent(){ return (this.text || this.innerText || this.html || '')+this.children.map(child => child.textContent).join(''); }
setAttribute(name, value){ this.attributes[name] = value; }
removeAttribute(name){ delete this.attributes[name]; }
getAttribute(name){ return this.attributes[name]; }
addEventListener(name, callback){ this.listeners[name] = callback; }
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
appendChild(child){ child.parent = this; this.children.push(child); }
append(...children){ children.forEach(child => this.appendChild(child)); }
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
focus(options){ this.focusCalls.push(options); document.activeElement=this; if(this.listeners.focus) this.listeners.focus(); }
blur(){ if(document.activeElement === this){ document.activeElement=null; if(this.listeners.blur) this.listeners.blur(); } }
select(){ document.activeElement=this; }
querySelector(selector){
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector){
const matches = element => {
if(selector.startsWith('.')) return element.classList.contains(selector.slice(1));
const id = selector.match(/data-human-message-id="(\d+)"/);
return id && element.attributes['data-human-message-id'] === id[1];
};
return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
}
closest(selector){
if(selector === 'button[data-question]' && this.dataset.question) return this;
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
}
remove(){ this.isConnected=false; if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
}
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
element('emoji-panel').hidden=true;
const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; });
const document = {
body: new Element(), documentElement: {},
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
createElement: () => new Element(),
execCommand: () => pageOptions.copyResult !== false,
addEventListener: (name, callback) => { const previous=listeners[name]; listeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
};
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
let state = {status:'active', messages:[]};
const context = vm.createContext({
document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true,
FormData:class { append(){} },
requestAnimationFrame:callback => callback(),
localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues),
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,offsetTop:0,
addEventListener:(name,callback) => { (viewportListeners[name] ||= []).push(callback); }} : undefined,
prompt:() => pageOptions.offlineMessage || null},
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
clearInterval:id => intervals.delete(id),
setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay}); return id; },
clearTimeout:id => timers.delete(id),
fetch: async (url, options) => {
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
if(hooks[url]) return hooks[url](options);
if(url === '/api/get_history' && pageOptions.historyFetch) return pageOptions.historyFetch();
if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url);
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
if(url === '/stream_chat'){
let read = false;
return {ok:true, body:{getReader:() => ({read:async () => {
if(read) return {done:true};
read = true;
return {value:new TextEncoder().encode('data: {"t":"测试 AI 回复"}\n\ndata: {"d":1}\n\n'), done:false};
}})}};
}
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
: url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7})
: url.startsWith('/api/visitor/human/poll') ? state
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
: url === '/api/get_history' ? (pageOptions.history || []) : [];
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'visitor.js', 'customer.js', 'drafts.js', 'voice.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
if(file === 'voice.js'){
const createVoice=context.ChatVoice.create;
context.ChatVoice.create=options => {voiceOptions=options; return createVoice(options);};
}
}
listeners.DOMContentLoaded();
await flush();
if(pageOptions.human !== false) await context.window.showHuman();
await flush();
return {context, element, calls, hooks, quickButtons, voiceOptions,storage:storageValues,tabStorage:tabValues,
documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); },
resize:async height => {
context.window.innerHeight=height;
if(context.window.visualViewport) context.window.visualViewport.height=height;
(windowListeners.resize || []).forEach(callback => callback());
await flush();
},
resizeViewport:async (height,scale=1,offsetTop=0) => {
Object.assign(context.window.visualViewport,{height,scale,offsetTop});
(viewportListeners.resize || []).forEach(callback => callback());
await flush();
},
panViewport:async top => {
context.window.visualViewport.offsetTop=top;
(viewportListeners.scroll || []).forEach(callback => callback());
await flush();
},
pageShow:async () => { (windowListeners.pageshow || []).forEach(callback => callback()); await flush(); },
pageHide:async () => { (windowListeners.pagehide || []).forEach(callback => callback()); await flush(); },
setState:value => { state=value; },
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
click:async button => { await button.click(); await flush(); },
};
}
test('the final customer-visible reply is processed before closure and a rating can be submitted', async () => {
const app = await customerPage();
app.setState({status:'closed', messages:[{id:1, side:'agent', content:'最后一条回复'}]});
await app.tick();
const log = app.element('chatDom');
assert.match(log.textContent, /最后一条回复/);
assert.match(log.textContent, /人工客服已结束/);
const rating = log.querySelector('.service-rating');
assert.ok(rating);
await rating.querySelector('.rating-choices').children[4].onclick();
assert.match(rating.textContent, /感谢您的评价/);
assert.equal(app.calls.find(call => call.url === '/api/rate').body.stars, 5);
assert.equal(app.context.window.humanSid, 0);
});
function streamResponse(chunks){
let index=0;
return {ok:true, body:{getReader:() => ({read:async () => index < chunks.length
? {done:false, value:typeof chunks[index] === 'string' ? new TextEncoder().encode(chunks[index++]) : chunks[index++]}
: {done:true}})}};
}
test('AI send preserves a newer draft typed while the conversation is being created', async () => {
const app=await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat']=() => new Promise(done => { resolve=done; });
const sending=app.context.window.fillInput('第一条问题');
app.element('input-text').value='正在补充的下一条问题';
resolve(response({id:18,welcome:'welcome'}));
await sending;
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '第一条问题');
assert.equal(app.element('input-text').value, '正在补充的下一条问题');
});
test('switching to a quick topic discards the previous topic attachment', async () => {
const app=await customerPage({human:false});
app.context.window.__pendingImg='/uploads/20261029100145_6787.txt';
app.context.window.__pendingTag='[文件]';
await app.click(app.quickButtons[1]);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '欧洲海运专线报价');
});
test('fragmented CRLF event streams preserve UTF-8 text and complete normally', async () => {
const app=await customerPage({human:false});
const bytes=new TextEncoder().encode('data: {"t":"完整中文回复"}\r\n\r\ndata: {"d":1}\r\n\r\n');
const chunks=Array.from({length:Math.ceil(bytes.length/5)}, (_,index) => bytes.slice(index*5,index*5+5));
app.hooks['/stream_chat']=() => streamResponse(chunks);
await app.context.window.fillInput('请回复');
await flush();
assert.match(app.element('chatDom').textContent, /完整中文回复/);
assert.equal(app.element('chatDom').querySelector('.retry-btn'), null);
assert.equal(app.context.window.__busy, false);
});
test('a stream ending without completion retains partial text and offers retry', async () => {
const app=await customerPage({human:false});
app.hooks['/stream_chat']=() => streamResponse(['data: {"t":"已经收到的部分回答"}\n\n']);
await app.context.window.fillInput('请继续说明');
await flush();
assert.match(app.element('chatDom').textContent, /已经收到的部分回答/);
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
assert.equal(app.context.window.__busy, false);
});
test('rate limit responses show the server message and allow retry', async () => {
const app=await customerPage({human:false});
app.hooks['/stream_chat']=() => response({e:'请求太频繁,请稍后再试'},429);
await app.context.window.fillInput('请查单');
await flush();
assert.match(app.element('chatDom').textContent, /请求太频繁/);
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
});
test('a stalled conversation creation times out and can be retried without losing the draft', async () => {
const app=await customerPage({human:false});
app.hooks['/api/new_chat']=options => new Promise((resolve,reject) => {
if(options.signal) options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'), {name:'AbortError'})));
});
app.context.window.fillInput('请帮我查询');
await app.runTimers(10000);
assert.match(app.element('composer-status').textContent, /会话创建失败/);
assert.equal(app.element('input-text').value, '请帮我查询');
app.hooks['/api/new_chat']=() => response({id:19,welcome:'welcome'});
await app.context.window.fillInput('请帮我查询');
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('history refresh failures preserve the last successfully loaded list', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'保留的对话',ctime:'2026-10-29 12:00'}]});
app.hooks['/api/get_history']=() => response({e:'请求太频繁'},429);
await app.click(app.element('.new-chat-btn'));
await flush();
assert.match(app.element('historyWrap').textContent, /保留的对话/);
});
test('deleting the active conversation aborts its stream and resets the composer', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'当前对话',ctime:'2026-10-29 12:00'}]});
await app.click(app.element('historyWrap').children[0]);
app.hooks['/stream_chat']=() => ({ok:true,body:{getReader:() => ({read:() => new Promise(() => {})})}});
await app.context.window.fillInput('请等待回复');
await flush();
const remove=app.element('historyWrap').children[0].children[2];
await remove.onclick({stopPropagation(){}});
await flush();
assert.equal(app.context.window.__busy,false);
assert.equal(app.element('send-button').textContent,'发送');
assert.ok(app.calls.find(call => call.url === '/stream_chat'));
});
test('switching topics aborts an upload and does not erase the new conversation status', async () => {
const app=await customerPage({human:false});
let aborted=false;
app.hooks['/api/upload']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => { aborted=true; reject(Object.assign(new Error('aborted'),{name:'AbortError'})); });
});
app.element('upload-input').files=[{name:'old.png'}];
app.element('upload-input').onchange();
await app.click(app.quickButtons[0]);
await flush();
assert.equal(aborted,true);
assert.equal(app.element('upload-btn').disabled,false);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('an upload timeout releases the composer and reports an actionable failure', async () => {
const app=await customerPage({human:false});
app.hooks['/api/upload']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'),{name:'AbortError'})));
});
app.element('upload-input').files=[{name:'slow.png'}];
app.element('upload-input').onchange();
await app.runTimers(15000);
assert.equal(app.element('upload-btn').disabled,false);
assert.match(app.element('composer-status').textContent,/附件上传失败/);
await app.context.window.fillInput('请继续查单');
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('malformed stored pin preferences cannot break the conversation list', async () => {
const app=await customerPage({human:false,storage:{pinned_conv:'{broken'},history:[{id:9,title:'正常对话',ctime:'now'}]});
assert.match(app.element('historyWrap').textContent,/正常对话/);
});
test('clearing history blocks new conversations until its timed request settles', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'保留历史',ctime:'now'}]});
app.hooks['/api/clear_all']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'),{name:'AbortError'})));
});
app.element('.clear-all-btn').click();
await app.click(app.quickButtons[1]);
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0);
await app.runTimers(10000);
assert.match(app.element('historyWrap').textContent,/保留历史/);
await app.click(app.quickButtons[0]);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('a failed history deletion preserves the active conversation for the next message', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'当前历史',ctime:'now'}]});
await app.click(app.element('historyWrap').children[0]);
app.hooks['/api/del_history?id=9']=() => response({ok:false},503);
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
await app.context.window.fillInput('继续这个问题');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id,9);
});
test('successful deletion updates the local list even when its refresh is rate limited', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'待删除会话',ctime:'now'},{id:10,title:'保留会话',ctime:'now'}]});
app.hooks['/api/get_history']=() => response({e:'请求太频繁'},429);
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
assert.doesNotMatch(app.element('historyWrap').textContent,/待删除会话/);
assert.match(app.element('historyWrap').textContent,/保留会话/);
});
test('successful clearing removes cached history when the followup refresh fails', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'待清空会话',ctime:'now'}]});
app.hooks['/api/clear_all']=() => response({ok:true});
app.hooks['/api/get_history']=() => Promise.reject(new Error('offline'));
app.element('.clear-all-btn').click();
await flush();
assert.doesNotMatch(app.element('historyWrap').textContent,/待清空会话/);
assert.ok(app.element('historyWrap').querySelector('.history-empty'));
});
test('a history refresh started before a deletion cannot resurrect the deleted item', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'旧刷新中的会话',ctime:'now'}]});
let resolve, refreshes=0;
app.hooks['/api/get_history']=() => ++refreshes === 1 ? new Promise(done => { resolve=done; }) : response({e:'请求太频繁'},429);
await app.click(app.element('.new-chat-btn'));
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
resolve(response([{id:9,title:'旧刷新中的会话',ctime:'now'}]));
await flush();
assert.doesNotMatch(app.element('historyWrap').textContent,/旧刷新中的会话/);
});
test('history text and order cards preserve their original times and card actions', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'历史时间',ctime:'2026-10-28 07:12'}]});
app.hooks.messages=() => response([
{role:'user',content:'FL202610290001',ctime:'2026-10-28 07:12'},
{role:'assistant',content:'__CARD__'+JSON.stringify({kind:'order',no:'FL202610290001',status:'履约中',fields:[],nodes:[],summary:'订单概要'}),ctime:'2026-10-28 07:13'},
{role:'assistant',content:'后续补充回复',ctime:'2026-10-28 07:14'}
]);
await app.click(app.element('historyWrap').children[0]);
const times=app.element('chatDom').querySelectorAll('.msg-time');
assert.deepEqual(times.map(item => item.textContent),['07:12','07:13','07:14']);
assert.equal(times[0].title,'2026-10-28 07:12');
assert.ok(app.element('chatDom').querySelector('.track-card').querySelector('.msg-actions'));
});
test('recalling an earlier reply preserves newer replies in the customer log', async () => {
const app = await customerPage();
app.setState({status:'active', messages:[{id:1, side:'agent', content:'早先的回复'}, {id:2, side:'agent', content:'最新回复'}]});
await app.tick();
app.setState({status:'active', messages:[{id:3, side:'system', content:'该消息已撤回', recalled_id:1}]});
await app.tick();
const log = app.element('chatDom');
assert.match(log.textContent, /该消息已撤回/);
assert.match(log.textContent, /最新回复/);
assert.doesNotMatch(log.textContent, /早先的回复/);
});
function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; }
test('all three footer questions leave the human queue and send to separate AI conversations', async () => {
const app = await customerPage();
for(const button of app.quickButtons) await app.click(button);
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 3);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
assert.deepEqual(requests.map(call => call.body.msg), ['按入仓码查轨迹', '欧洲海运专线报价', '包裹异常处理']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
});
test('welcome and hot question buttons use the same AI routing as the footer', async () => {
const app = await customerPage();
for(const question of ['欧洲海运专线报价', '如何申请理赔?']){
const button = app.element('question-'+question);
button.dataset.question = question;
app.element('chatDom').listeners.click({target:button});
await flush();
}
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
});
test('the welcome contact button opens the human queue and does not send an AI question', async () => {
const app = await customerPage({human:false});
const button = app.element('contact-card');
button.setAttribute('data-human-support', '');
app.element('chatDom').listeners.click({target:button});
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 0);
assert.equal(app.element('human-session').hidden, false);
});
test('new chat clears human routing and the next quote is sent to AI', async () => {
const app = await customerPage();
await app.click(app.element('.new-chat-btn'));
await app.context.window.fillInput('请报价,30KG发往美国FBA仓');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '请报价,30KG发往美国FBA仓');
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.__busy, false);
});
test('opening AI history clears the human session and retains that conversation id', async () => {
const app = await customerPage({history:[{id:9, title:'之前的对话', ctime:'2026-10-29 12:00'}]});
await app.click(app.element('historyWrap').children[0]);
await app.context.window.fillInput('请继续回答这个问题');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 9);
assert.equal(app.context.window.humanSid, 0);
const authors = app.element('chatDom').querySelectorAll('.message-author');
assert.ok(authors.some(author => author.innerHTML.includes('data-i18n="assistant"')));
});
test('a repeated quick click while a new conversation is being created sends only once', async () => {
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; });
app.quickButtons[0].click();
app.quickButtons[0].click();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1);
resolve(response({id:12, welcome:'welcome'}));
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
});
test('a late conversation creation cannot replace the newest quick question', async () => {
const app = await customerPage({human:false});
let resolve, count=0;
app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'});
app.quickButtons[0].click();
await app.click(app.quickButtons[2]);
resolve(response({id:21, welcome:'welcome'}));
await flush();
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 1);
assert.equal(requests[0].body.conv_id, 22);
assert.equal(requests[0].body.msg, '包裹异常处理');
assert.equal(app.context.window.__busy, false);
});
test('a late history response cannot overwrite the latest quick conversation', async () => {
const app = await customerPage({history:[{id:9, title:'旧会话', ctime:'2026-10-29 12:00'}]});
let resolve;
app.hooks.messages = () => new Promise(done => { resolve=done; });
await app.click(app.element('historyWrap').children[0]);
await app.click(app.quickButtons[1]);
resolve(response([{role:'user', content:'旧会话的内容不应显示'}]));
await flush();
assert.doesNotMatch(app.element('chatDom').textContent, /旧会话的内容不应显示/);
assert.match(app.element('chatDom').textContent, /测试 AI 回复/);
});
test('a delayed human connection cannot capture a newly selected AI topic', async () => {
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/visitor/human/request'] = () => new Promise(done => { resolve=done; });
const connecting = app.context.window.showHuman();
await flush();
await app.click(app.quickButtons[1]);
resolve(response({ok:true, session_id:8}));
await connecting;
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
});
test('repeated contact clicks create one human request and show a visible queue state', async () => {
const app = await customerPage({human:false});
await Promise.all([app.context.window.showHuman(), app.context.window.showHuman()]);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
assert.equal(app.element('human-session').hidden, false);
assert.match(app.element('human-session-label').textContent, /正在排队/);
});
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
const app = await customerPage({human:false, hour:12,serviceClosed:true, offlineMessage:'请明天跟进报价'});
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
assert.match(app.element('chatDom').textContent, /留言已提交/);
assert.equal(app.context.window.humanSid, 0);
});
test('a failed human send preserves the text and makes retry possible', async () => {
const app = await customerPage();
app.hooks['/api/visitor/human/send'] = () => response({ok:false}, 503);
await app.context.window.fillInput('请确认这批货的价格');
assert.equal(app.element('input-text').value, '请确认这批货的价格');
assert.match(app.element('composer-status').textContent, /内容已保留/);
assert.equal(app.element('send-button').disabled, false);
});
test('a visitor whose phone says late at night can still reach an open support service', async () => {
const app=await customerPage({human:false,hour:23});
await app.context.window.showHuman();
assert.equal(app.context.window.humanSid,7);
assert.equal(app.calls.filter(call=>call.url==='/api/ticket').length,0);
});
test('a duplicate human send cannot submit an in-flight message twice', async () => {
const app = await customerPage();
let resolve;
app.hooks['/api/visitor/human/send'] = () => new Promise(done => { resolve=done; });
const first = app.context.window.fillInput('请确认时效');
await app.context.window.fillInput('请确认时效');
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 1);
resolve(response({ok:true}));
await first;
assert.equal(app.element('input-text').value, '');
});
test('returning to AI explicitly closes the human session and preserves the conversation', async () => {
const app = await customerPage();
await app.click(app.element('return-ai'));
await app.context.window.fillInput('请继续解答');
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/close').length, 1);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 1);
assert.equal(app.context.window.humanSid, 0);
});
test('a stale human poll cannot append messages to a newly selected AI topic', async () => {
const app = await customerPage();
let resolve;
app.hooks.poll = () => new Promise(done => { resolve=done; });
await app.tick();
await app.click(app.quickButtons[1]);
resolve(response({status:'active', messages:[{id:1, side:'agent', content:'旧人工会话的回复'}]}));
await flush();
assert.doesNotMatch(app.element('chatDom').textContent, /旧人工会话的回复/);
});
test('human poll failures are visible and clear after the connection recovers', async () => {
const app = await customerPage();
app.hooks.poll = () => Promise.reject(new Error('Disconnected'));
await app.tick();
assert.match(app.element('composer-status').textContent, /连接中断/);
delete app.hooks.poll;
await app.tick();
assert.equal(app.element('composer-status').hidden, true);
});
test('order cards show facts as text without import metadata, including older saved cards', async () => {
const app = await customerPage({human:false});
const card = {kind:'order', no:'FL202610290001', status:'履约中',
fields:[{key:'weight',value:'30.000 kg'},{key:'warehouse',value:'<img src=x onerror=bad()>'}],
nodes:[{time:'2026-10-29 10:00',text:'已提交',place:''}],
summary:'订单 FL202610290001,申报毛重30.000 kg', snapshot_at:'2026-10-29 10:30', notice:'旧数据说明'};
app.hooks['/stream_chat'] = () => {
let read = false;
return {ok:true, body:{getReader:() => ({read:async () => {
if(read) return {done:true};
read = true;
return {done:false,value:new TextEncoder().encode('data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n')};
}})}};
};
await app.context.window.fillInput('FL202610290001');
await flush();
const log = app.element('chatDom');
const fields = log.querySelector('.order-data-fields');
assert.ok(fields);
assert.equal(fields.children[1].textContent, '30.000 kg');
assert.equal(fields.children[3].innerHTML, '');
assert.equal(log.querySelector('.order-data-notice'), null);
assert.doesNotMatch(log.textContent, /旧数据说明|数据导入|2026-10-29 10:30/);
assert.equal(app.context.window.__busy, false);
});
test('shipment cards separate forecasts from actual progress and retain both in history', async () => {
const app = await customerPage({human:false, history:[{id:8,title:'订单轨迹',ctime:'2026-10-29'}]});
const card = {kind:'order', no:'FL202610290001', status:'履约中', fields:[], node_kind:'logistics',
tracking_status:'船舶已离开起运港', nodes:[{time:'2026-10-28 10:00',text:'船舶已离开起运港',place:'起运港'}],
estimated_nodes:[{time:'2026-11-02 10:00',text:'预计到达目的港',place:'<img src=x onerror=bad()>'}],
summary:'船舶已离开起运港,预计到达目的港2026-11-02'};
app.hooks.messages = () => response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'2026-10-29 10:00'}]);
await app.click(app.element('historyWrap').children[0]);
const log = app.element('chatDom');
assert.match(log.textContent, /物流轨迹/);
assert.match(log.textContent, /预计节点/);
assert.match(log.textContent, /预计到达目的港/);
const timelines = log.querySelectorAll('.track-timeline');
assert.equal(timelines.length, 2);
assert.ok(timelines[0].querySelector('.track-node').classList.contains('active'));
assert.equal(timelines[1].querySelector('.track-node').classList.contains('active'), false);
assert.match(timelines[1].querySelector('.track-text').innerHTML, /&lt;img/);
assert.ok(log.querySelector('.msg-actions'));
});
test('inbound codes are the primary card identity for live replies and restored history', async () => {
const app=await customerPage({human:false,history:[{id:8,title:'入仓轨迹',ctime:'now'}]});
const card={kind:'order',no:'FL202610290001',status:'履约中',
fields:[{key:'inbound',value:'INB20261029001-01'},{key:'number',value:'FL202610290001'}],
nodes:[],summary:'入仓码 INB20261029001-01'};
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('INB20261029001-01');await flush();
let heading=app.element('chatDom').querySelector('.track-no');
assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/);
assert.match(app.element('chatDom').querySelector('.order-data-fields').textContent,/订单号.*FL202610290001/);
app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]);
await app.click(app.element('historyWrap').children[0]);
heading=app.element('chatDom').querySelector('.track-no');
assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/);
});
test('order details can expand without hiding the shipment timeline', async () => {
const app = await customerPage({human:false,width:390});
const card = {kind:'order',no:'ORDER2026001',status:'履约中',node_kind:'logistics',
fields:[{key:'shipment',value:'LONG-SHIPMENT-20261030001'}],
nodes:[{time:'2026-10-29 10:00',text:'海运途中',place:''}],summary:'订单概要'};
app.hooks['/stream_chat'] = () => streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('ORDER2026001');
await flush();
const log=app.element('chatDom'), fields=log.querySelector('.order-data-fields');
const toggle=log.querySelector('.order-details-toggle');
assert.equal(toggle.getAttribute('aria-expanded'),'false');
assert.ok(fields.classList.contains('is-collapsed'));
assert.match(log.querySelector('.track-timeline').textContent,/海运途中/);
await app.click(toggle);
assert.equal(toggle.getAttribute('aria-expanded'),'true');
assert.equal(fields.classList.contains('is-collapsed'),false);
assert.match(fields.textContent,/LONG-SHIPMENT-20261030001/);
await app.click(toggle);
assert.ok(fields.classList.contains('is-collapsed'));
});
test('viewport changes keep latest replies visible and preserve an older reading position', async () => {
const app=await customerPage({human:false,width:390,viewportHeight:844});
await app.context.window.fillInput('查询物流');
await flush();
const log=app.element('chatDom');
Object.assign(log,{scrollHeight:1500,clientHeight:600,scrollTop:900});
log.listeners.scroll();
log.clientHeight=260;
await app.resize(500);
assert.equal(app.element('.chat-container').style.height,'500px');
assert.equal(log.scrollTop,log.scrollHeight);
log.scrollTop=100;
log.listeners.scroll();
await app.resizeViewport(450);
assert.equal(app.element('.chat-container').style.height,'450px');
assert.equal(log.scrollTop,100);
await app.resizeViewport(400,2);
assert.equal(app.element('.chat-container').style.height,'');
assert.equal(log.scrollTop,100);
});
test('clearing a sent draft restores a single-row composer instead of sizing to its placeholder', async () => {
const app=await customerPage({human:false,width:320});
const input=app.element('input-text');
input.value='多行内容\n第二行';
input.listeners.input();
assert.equal(input.style.height,'140px');
await app.context.window.fillInput(input.value);
await flush();
assert.equal(input.value,'');
assert.equal(input.style.height,'');
});
test('keyboard panning keeps the whole mobile frame in the visual viewport and restores after closing', async () => {
const app=await customerPage({human:false,width:390,viewportHeight:844});
const input=app.element('input-text'), frame=app.element('.chat-container');
input.focus();
await app.resizeViewport(430,1,180);
assert.equal(frame.style.height,'430px');
assert.equal(frame.style.top,'180px');
assert.ok(frame.classList.contains('is-composing'));
await app.panViewport(260);
assert.equal(frame.style.top,'260px');
assert.equal(frame.style.height,'430px');
await app.resizeViewport(0,1,0);
assert.equal(frame.style.height,'430px');
input.blur();
await app.resizeViewport(844);
assert.equal(frame.style.top,'0px');
assert.equal(frame.style.height,'844px');
assert.equal(frame.classList.contains('is-composing'),false);
});
test('embedded browsers without visualViewport use the resized window and recover on pageshow', async () => {
const app=await customerPage({human:false,width:390,height:844});
const frame=app.element('.chat-container');
assert.equal(frame.style.height,'844px');
await app.resize(410);
assert.equal(frame.style.height,'410px');
app.context.window.innerHeight=844;
await app.pageShow();
assert.equal(frame.style.height,'844px');
app.context.window.innerWidth=1200;
await app.resize(720);
assert.equal(frame.style.height,'');
assert.equal(frame.style.top,'');
});
test('a multiline draft leaves room for messages when the keyboard reduces the viewport', async () => {
const app=await customerPage({human:false,width:390,viewportHeight:844});
const input=app.element('input-text');
input.value='多行草稿';
input.scrollHeight=300;
input.listeners.input();
assert.equal(input.style.height,'140px');
await app.resizeViewport(340);
assert.equal(input.style.height,'85px');
await app.resizeViewport(844);
assert.equal(input.style.height,'140px');
});
test('late AI completion does not steal focus or reopen a dismissed mobile keyboard', async () => {
for(const width of [390,1200]){
const app=await customerPage({human:false,width,viewportHeight:844});
const input=app.element('input-text');
let reply;
app.hooks['/stream_chat']=()=>new Promise(resolve=>{reply=resolve;});
input.focus();
await app.context.window.fillInput('报价问题');
await flush();
input.blur();
app.element('history-search').focus();
const before=input.focusCalls.length;
reply(streamResponse(['data: {"t":"完成回复"}\n\ndata: {"d":1}\n\n']));
await flush();await flush();
assert.equal(input.focusCalls.length,before);
assert.equal(app.context.document.activeElement,app.element('history-search'));
assert.match(app.element('chatDom').textContent,/完成回复/);
}
});
test('mobile quick questions keep the keyboard closed and composer controls finish clicking before blur changes layout', async () => {
const app=await customerPage({human:false,width:390});
const input=app.element('input-text');
await app.context.window.fillInput('快捷咨询');await flush();
assert.equal(input.focusCalls.length,0);
input.focus();
for(const event of ['pointerdown','mousedown']){
for(const name of ['send-button','voice-toggle','emoji-toggle','upload-btn']){
let prevented=false;
app.element('.composer').listeners[event]({button:0,target:{closest:()=>app.element(name)},preventDefault(){prevented=true;}});
assert.ok(prevented);
}
}
let blockedTextSelection=false;
app.element('.composer').listeners.pointerdown({button:0,target:{closest:()=>null},preventDefault(){blockedTextSelection=true;}});
assert.equal(blockedTextSelection,false);
assert.equal(app.context.document.activeElement,input);
});
test('recognized voice uses the normal AI and human send routes with a visible user message', async () => {
for(const human of [false,true]){
const app=await customerPage({human,width:390});
await app.voiceOptions.onText('语音识别的物流问题');
await flush();
const call=app.calls.find(call=>call.url===(human ? '/api/visitor/human/send' : '/stream_chat'));
assert.ok(call);
assert.equal(human ? call.body.content : call.body.msg,'语音识别的物流问题');
assert.match(app.element('chatDom').querySelector('.user-message').textContent,/语音识别的物流问题/);
assert.equal(app.element('input-text').value,'');
}
});
test('a failed voice send keeps the transcript editable and a newer draft is not auto-submitted', async () => {
const app=await customerPage({human:false,width:390});
app.hooks['/api/new_chat']=()=>Promise.reject(new Error('offline'));
await app.voiceOptions.onText('语音内容');
assert.equal(app.element('input-text').value,'语音内容');
assert.equal(app.element('.input-area').classList.contains('voice-mode'),false);
app.element('input-text').value='新草稿';
await app.voiceOptions.onText('后到的识别文字');
assert.equal(app.element('input-text').value,'新草稿\n后到的识别文字');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
});
test('a blocked retry retains its previous answer until a replacement can start', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('之前的问题');await flush();
const log=app.element('chatDom'), retry=log.querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='retry');
app.hooks['/api/upload']=options=>new Promise((resolve,reject)=>{
options.signal.addEventListener('abort',()=>reject(Object.assign(new Error(),{name:'AbortError'})));
});
app.element('upload-input').files=[{name:'pending.pdf'}];
app.element('upload-input').onchange();await flush();
await app.click(retry);
assert.match(log.textContent,/测试 AI 回复/);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
await app.runTimers(15000);
await app.click(retry);await flush();
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,2);
assert.equal(log.querySelectorAll('.msg-action-btn').filter(button=>button.dataset.action==='retry').length,1);
});
test('an AI regeneration is not forwarded to a human session', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('之前的问题');await flush();
const retry=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='retry');
await app.context.window.showHuman();await app.click(retry);
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/human/send').length,0);
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
assert.match(app.element('composer-status').textContent,/先返回 AI/);
});
test('feedback waits for server confirmation, ignores rapid duplicates and allows retry after failure', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('反馈测试');await flush();
const log=app.element('chatDom'), buttons=log.querySelectorAll('.msg-action-btn');
const up=buttons.find(button=>button.textContent==='👍'), down=buttons.find(button=>button.textContent==='👎');
let finish;
app.hooks['/api/feedback']=()=>new Promise(resolve=>{finish=resolve;});
const sending=up.onclick();
await flush();
await down.onclick();
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,1);
assert.equal(up.classList.contains('voted'),false);
assert.ok(up.disabled && down.disabled);
finish(response({ok:false},503));await sending;
assert.equal(up.classList.contains('voted'),false);
assert.match(log.textContent,/评价未提交/);
assert.equal(up.disabled,false);
app.hooks['/api/feedback']=()=>response({ok:true});
await up.onclick();
assert.ok(up.classList.contains('voted'));
assert.equal(up.getAttribute('aria-pressed'),'true');
assert.match(log.textContent,/感谢您的评价/);
await up.onclick();
assert.equal(app.calls.filter(call=>call.url==='/api/feedback').length,2);
});
test('clipboard rejection and a failed fallback never report a successful copy', async () => {
const app=await customerPage({human:false,copyResult:false,clipboard:{writeText:()=>Promise.reject(new Error('denied'))}});
await app.context.window.fillInput('复制测试');await flush();
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
app.element('input-text').focus();
await copy.onclick();await flush();
assert.equal(copy.textContent,'复制失败');
assert.equal(copy.disabled,false);
assert.equal(app.context.document.activeElement,app.element('input-text'));
assert.equal(app.element('input-text').focusCalls.at(-1).preventScroll,true);
assert.equal(app.context.document.body.children.length,0);
await app.runTimers(3000);
assert.equal(copy.textContent,'复制');
});
test('successful clipboard fallback reports copied and keeps text focus stable', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('复制测试');await flush();
const copy=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='copy');
await copy.onclick();
assert.equal(copy.textContent,'已复制');
assert.equal(copy.disabled,false);
await app.runTimers(1200);
assert.equal(copy.textContent,'复制');
});
test('blank offline messages are ignored and invalid ticket text stays editable with a readable error', async () => {
const empty=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:' '});
await empty.context.window.showHuman();await flush();
assert.equal(empty.calls.filter(call=>call.url==='/api/ticket').length,0);
const app=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:'需要保留的留言'});
app.hooks['/api/ticket']=()=>response({ok:false,error:'请输入 1–2000 字的留言。'},400);
await app.context.window.showHuman();await flush();
assert.equal(app.element('input-text').value,'需要保留的留言');
assert.notEqual(app.element('input-text').style.height,'');
assert.match(app.element('composer-status').textContent,/1–2000/);
});
test('mobile history selection closes the drawer while desktop navigation stays visible', async () => {
for(const width of [390,1200]){
const app=await customerPage({human:false,width,history:[{id:9,title:'历史会话',ctime:'now'}]});
if(width===390) await app.click(app.element('.toggle-divider'));
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),'true');
await app.click(app.element('historyWrap').children[0]);
assert.equal(app.element('.toggle-divider').getAttribute('aria-expanded'),String(width>900));
}
});
test('emoji panel dismisses outside or on Escape, stays open for its own controls, and closes on conversation change', async () => {
const app=await customerPage({human:false,width:390});
const toggle=app.element('emoji-toggle'), panel=app.element('emoji-panel');
await app.click(toggle);
assert.equal(panel.hidden,false);
app.documentEvent('pointerdown',{target:panel.children[0]});
assert.equal(panel.hidden,false);
app.documentEvent('pointerdown',{target:app.element('input-text')});
assert.equal(panel.hidden,true);
assert.equal(toggle.getAttribute('aria-expanded'),'false');
await app.click(toggle);
app.documentEvent('keydown',{key:'Escape'});
assert.equal(panel.hidden,true);
await app.click(toggle);
await app.click(app.element('.new-chat-btn'));
assert.equal(panel.hidden,true);
});
test('drafts stay with their own conversation and restore across a page reload', async () => {
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
const app=await customerPage({human:false,history});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='A的未发送草稿';app.element('input-text').listeners.input();
await app.click(app.element('historyWrap').children[1]);
assert.equal(app.element('input-text').value,'');
app.element('input-text').value='B的未发送草稿';app.element('input-text').listeners.input();
await app.click(app.element('historyWrap').children[0]);
assert.equal(app.element('input-text').value,'A的未发送草稿');
await app.pageHide();
const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage});
await flush();
assert.equal(restored.element('input-text').value,'A的未发送草稿');
assert.ok(restored.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
await restored.click(restored.element('historyWrap').children[1]);
assert.equal(restored.element('input-text').value,'B的未发送草稿');
});
test('a new conversation draft restores after refresh and moves to the created conversation on send', async () => {
const app=await customerPage({human:false});
app.element('input-text').value='尚未发送';app.element('input-text').listeners.input();await app.pageHide();
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage});
assert.equal(restored.element('input-text').value,'尚未发送');
await restored.context.window.fillInput('尚未发送');await restored.pageHide();
const store=restored.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>restored.context.localStorage,tabStorage:()=>restored.context.sessionStorage});
assert.equal(store.read(null),'');assert.equal(store.read(1),'');assert.equal(store.active(),'1');
});
test('draft recovery stays silent but quota failure remains visible until storage recovers', async () => {
const history=[{id:9,title:'A',ctime:'now'}],app=await customerPage({human:false,history});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='已保存草稿';app.element('input-text').listeners.input();await app.pageHide();
const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage});
assert.equal(restored.element('input-text').value,'已保存草稿');
assert.equal(restored.element('composer-status').textContent,'');
const setItem=restored.context.localStorage.setItem;
restored.context.localStorage.setItem=()=>{throw Error('quota');};
restored.element('input-text').value='新的未保存输入';restored.element('input-text').listeners.input();await restored.runTimers(250);
assert.match(restored.element('composer-status').textContent,/无法保存/);
assert.equal(restored.element('input-text').value,'新的未保存输入');
restored.context.localStorage.setItem=setItem;await restored.pageHide();
assert.equal(restored.element('composer-status').textContent,'');
});
test('restoring an old conversation cannot replace a newer user-selected draft', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();await app.pageHide();
let release;
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
await restored.click(restored.element('.new-chat-btn'));
restored.element('input-text').value='新话题草稿';restored.element('input-text').listeners.input();
release(response([{id:9,title:'A',ctime:'now'}]));await flush();
assert.equal(restored.element('input-text').value,'新话题草稿');
});
test('failed history loading cannot send a question under a hidden conversation context', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
await app.context.window.fillInput('原会话');await flush();
app.hooks.messages=()=>response({ok:false},503);
await app.click(app.element('historyWrap').children[0]);
const before=app.calls.filter(call=>call.url==='/stream_chat').length;
await app.context.window.fillInput('禁止发到未加载会话');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
assert.match(app.element('composer-status').textContent,/会话未加载成功/);
assert.doesNotMatch(app.element('chatDom').textContent,/原会话/);
});
test('startup removes a deleted active conversation draft without erasing a newer edit', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='旧会话草稿';app.element('input-text').listeners.input();await app.pageHide();
const noEdit=await customerPage({human:false,storage:{...app.storage},tabStorage:{...app.tabStorage}});
assert.equal(noEdit.element('input-text').value,'');
assert.ok(!noEdit.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
let release;
const edited=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
edited.element('input-text').value='加载期间的新输入';edited.element('input-text').listeners.input();
release(response([]));await flush();
assert.equal(edited.element('input-text').value,'加载期间的新输入');
await edited.context.window.fillInput('加载期间的新输入');
assert.ok(!edited.calls.some(call=>call.url==='/stream_chat'));
assert.match(edited.element('composer-status').textContent,/会话未加载成功/);
});
test('deleting a conversation removes only its saved draft', async () => {
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
const app=await customerPage({human:false,history});
await app.click(app.element('historyWrap').children[0]);
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();
await app.click(app.element('historyWrap').children[1]);
app.element('input-text').value='B草稿';app.element('input-text').listeners.input();await app.pageHide();
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
const store=app.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>app.context.localStorage,tabStorage:()=>app.context.sessionStorage});
assert.equal(store.read(9),'');assert.equal(store.read(10),'B草稿');
});
test('an order login action resumes the original question and preserves a newer draft', async () => {
const app=await customerPage({human:false});let authenticated=false;
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated,user:authenticated ? {id:1,name:'admin'} : null});
app.hooks['/api/customer/login']=()=>{authenticated=true;return response({ok:true,authenticated:true,user:{id:1,name:'admin'}});};
app.hooks['/stream_chat']=()=>streamResponse([`data: ${JSON.stringify(authenticated ? {t:'账号范围内的订单结果'} : {t:'请先登录',login_required:true})}\n\ndata: {"d":1}\n\n`]);
await app.context.window.fillInput('查询ORDER20260001');await flush();
const action=app.element('chatDom').querySelector('.customer-login-cta');assert.ok(action);
app.element('input-text').value='下一条未发送草稿';app.element('input-text').listeners.input();
await app.click(action);assert.equal(app.element('customer-login').hidden,false);
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
assert.equal(app.element('input-text').value,'下一条未发送草稿');
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,2);
assert.match(app.element('chatDom').textContent,/账号范围内的订单结果/);
assert.match(app.element('customer-account').textContent,/admin/);
});
test('invalid customer credentials leave the login form open without sending another question', async () => {
const app=await customerPage({human:false});
app.hooks['/api/customer/login']=()=>response({ok:false,error:'账号或密码不正确。'},403);
await app.click(app.element('customer-account'));
app.element('customer-username').value='admin';app.element('customer-password').value='wrong';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});
assert.equal(app.element('customer-login').hidden,false);
assert.match(app.element('customer-error').textContent,/账号或密码/);
assert.equal(app.calls.filter(call=>call.url==='/api/customer/login').length,1);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
await app.click(app.element('customer-cancel'));
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
});
test('a late login cannot replay an order into a newer conversation', async () => {
const app=await customerPage({human:false});let release;
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:false,user:null});
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":"先登录","login_required":true}\n\ndata: {"d":1}\n\n']);
app.hooks['/api/customer/login']=()=>new Promise(resolve=>{release=resolve;});
await app.context.window.fillInput('查询ORDER20260001');await flush();
await app.click(app.element('chatDom').querySelector('.customer-login-cta'));
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
const signing=app.element('customer-login-form').listeners.submit({preventDefault(){}});
await app.click(app.element('.new-chat-btn'));
app.element('input-text').value='新话题';app.element('input-text').listeners.input();
release(response({ok:true,authenticated:true,user:{id:1,name:'admin'}}));await signing;
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
assert.equal(app.element('input-text').value,'新话题');
});
test('signing out clears visible order conversation and returns to guest mode', async () => {
const app=await customerPage({human:false});
app.hooks['/api/customer/login']=()=>response({ok:true,authenticated:true,user:{id:1,name:'admin'}});
app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null});
await app.click(app.element('customer-account'));
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
await app.element('customer-login-form').listeners.submit({preventDefault(){}});
await app.context.window.fillInput('ORDER20260001');await flush();
assert.ok(app.element('chatDom').querySelector('.user-message'));
await app.click(app.element('customer-account'));
assert.equal(app.element('customer-account').textContent,'登录');
assert.equal(app.element('chatDom').querySelector('.user-message'),null);
assert.equal(app.calls.filter(call=>call.url==='/api/customer/logout').length,1);
});
test('an unavailable image gets one readable replacement instead of a broken image', async () => {
const app=await customerPage({human:false});
let shown;
const image={tagName:'IMG',classList:{contains:name=>name==='message-image'},dataset:{},after:hint=>{shown=hint;}};
app.element('chatDom').listeners.error({target:image});
assert.equal(image.hidden,true);
assert.match(shown.textContent,/图片暂时无法访问/);
const first=shown;
app.element('chatDom').listeners.error({target:image});
assert.equal(shown,first);
});