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 elements = new Map(), listeners = {}, intervals = new Map(), calls = [], hooks = {}; let intervalId = 0, nextConvId = 1; class Element { constructor(){ this.children = []; this.attributes = {}; this.value = ''; this.style = {}; this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.listeners = {}; 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; } 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)); } focus(){} 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(){ 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); }; 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(), addEventListener: (name, callback) => { listeners[name] = 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'}, AbortController, TextDecoder, requestAnimationFrame:callback => callback(), localStorage:{getItem:() => null, setItem(){}}, window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false}), prompt:() => pageOptions.offlineMessage || null}, setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; }, clearInterval:id => intervals.delete(id), setTimeout(){}, clearTimeout(){}, fetch: async (url, options) => { calls.push({url, body:options && options.body && JSON.parse(options.body)}); if(hooks[url]) return hooks[url](options); if(url.startsWith('/api/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/human/request' ? {ok:true, session_id:7} : url.startsWith('/api/human/poll') ? state : ['/api/rate', '/api/human/send', '/api/human/close', '/api/ticket'].includes(url) ? {ok:true} : url === '/api/get_history' ? (pageOptions.history || []) : []; return {ok:true, json:async () => data}; }, }); for(const file of ['support.js', 'chat.js']){ vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file}); } listeners.DOMContentLoaded(); await flush(); if(pageOptions.human !== false) await context.window.showHuman(); await flush(); return {context, element, calls, hooks, quickButtons, setState:value => { state=value; }, 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); }); 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/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/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/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/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/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/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:23, offlineMessage:'请明天跟进报价'}); await app.context.window.showHuman(); assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 0); 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/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 duplicate human send cannot submit an in-flight message twice', async () => { const app = await customerPage(); let resolve; app.hooks['/api/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/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/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); });