@@ -6,6 +6,14 @@ 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 ;
@@ -71,7 +79,7 @@ async function customerPage(pageOptions = {}){
document , console , Date : TestDate , navigator : { language : 'zh-CN' , clipboard : pageOptions . clipboard } , AbortController , TextDecoder , confirm : ( ) => true ,
FormData : class { append ( ) { } } ,
requestAnimationFrame : callback => callback ( ) ,
localStorage : { getItem : key => ( pageOptions . storage || { } ) [ key ] ? ? null , setItem ( ) { } } ,
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 ] ) } ) ,
@@ -85,6 +93,7 @@ async function customerPage(pageOptions = {}){
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' ) {
@@ -97,14 +106,14 @@ async function customerPage(pageOptions = {}){
}
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' ? { ok : true , session _id : 7 }
: 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' , 'voice.js' , 'chat.js' ] ) {
for ( const file of [ 'support.js' , 'visitor.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 ;
@@ -115,7 +124,7 @@ async function customerPage(pageOptions = {}){
await flush ( ) ;
if ( pageOptions . human !== false ) await context . window . showHuman ( ) ;
await flush ( ) ;
return { context , element , calls , hooks , quickButtons , voiceOptions ,
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 ;
@@ -134,6 +143,7 @@ async function customerPage(pageOptions = {}){
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 ( ) ; } ,
@@ -488,9 +498,9 @@ test('repeated contact clicks create one human request and show a visible queue
} ) ;
test ( 'the contact shortcut outside service hours submits a ticket without entering human mode' , async ( ) => {
const app = await customerPage ( { human : false , hour : 23 , offlineMessage : '请明天跟进报价' } ) ;
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 , 0 ) ;
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 ) ;
@@ -505,6 +515,13 @@ test('a failed human send preserves the text and makes retry possible', async ()
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 ;
@@ -841,10 +858,10 @@ test('successful clipboard fallback reports copied and keeps text focus stable',
} ) ;
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 , offlineMessage : ' ' } ) ;
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 , offlineMessage : '需要保留的留言' } ) ;
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 , '需要保留的留言' ) ;
@@ -879,3 +896,99 @@ test('emoji panel dismisses outside or on Escape, stays open for its own control
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 ( '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 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 ) ;
} ) ;