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:''}], 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:''}], 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, /<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); });