2026-10-27 23:09:34 +08:00
/**
2026-10-31 17:05:17 +08:00
* Sidebar popup and control-room presentation regressions against the actual client bundle.
2026-10-27 23:09:34 +08:00
* Usage: node 04_test/sidebar-popup.test.mjs [lib/client.js]
* The small React hook/JSX harness runs the exported component and its event handlers;
* it does not claim browser layout or desktop GUI coverage.
*/
import { readFileSync } from 'node:fs'
import { createContext , runInNewContext } from 'node:vm'
import { fileURLToPath } from 'node:url'
import { dirname , join , resolve } from 'node:path'
const BUNDLE = resolve ( process . argv [ 2 ] ?? join ( dirname ( fileURLToPath ( import . meta . url )), '..' , '01_content' , 'lib' , 'client.js' ))
const source = readFileSync ( BUNDLE , 'utf8' )
const assert = ( condition , message ) => {
if ( ! condition ) throw new Error ( message )
}
const Fragment = Symbol ( 'test.fragment' )
const jsx = ( type , props , key ) => ({ type , props : props ?? {}, key : key ?? props ? . key ?? null })
function makeHooks () {
const slots = []
const effects = []
let cursor = 0
let mounted = false
const useState = ( initial ) => {
const i = cursor ++
if ( ! ( i in slots )) slots [ i ] = typeof initial === 'function' ? initial () : initial
return [ slots [ i ], ( next ) => { slots [ i ] = typeof next === 'function' ? next ( slots [ i ]) : next }]
}
const useRef = ( initial ) => {
const i = cursor ++
if ( ! ( i in slots )) slots [ i ] = { current : initial }
return slots [ i ]
}
const useMemo = ( make ) => { cursor ++ ; return make () }
const useCallback = ( callback ) => { cursor ++ ; return callback }
const useEffect = ( effect ) => { cursor ++ ; if ( ! mounted ) effects . push ( effect ) }
return {
React : { Fragment , useState , useRef , useMemo , useCallback , useEffect , useLayoutEffect : useEffect },
render ( component , props ) {
cursor = 0
const tree = component ( props )
if ( ! mounted ) {
mounted = true
for ( const effect of effects . splice ( 0 )) {
try { effect () } catch ( error ) { throw new Error ( 'mount effect failed: ' + error . message ) }
}
}
return tree
},
}
}
function fakeElement ( tagName = 'div' ) {
return {
tagName : tagName . toUpperCase (),
className : '' ,
style : {},
children : [],
setAttribute ( name , value ) { this [ name ] = value },
appendChild ( child ) { child . parentElement = this ; this . children . push ( child ); return child },
remove () {},
addEventListener () {},
removeEventListener () {},
getBoundingClientRect () { return { left : 24 , top : 100 , right : 64 , bottom : 140 , width : 40 , height : 40 } },
getContext () {
return {
globalCompositeOperation : '' ,
createLinearGradient () { return { addColorStop () {} } },
fillRect () {},
getImageData () { return { data : [ 0 , 0 , 0 , 255 ] } },
}
},
}
}
2026-10-31 17:05:17 +08:00
function loadComponent ({ stored = {}, fetch : fetchFixture } = {}) {
2026-10-27 23:09:34 +08:00
const values = new Map ([
[ 'dsh.worktable.updateCheck.v1' , '0' ],
[ 'dsh.worktable.projects.v1' , JSON . stringify ({
order : [], lastUsed : {}, hidden : [], nameOverrides : {}, iconOverrides : {}, removed : [],
views : {}, shortcuts : [], bindings : {}, folders : {},
layouts : [{
id : 'popup-test-layout' , title : 'Popup Test' , icon : '🧱' , top : null ,
main : [{ id : 'popup-test-pane' , title : 'Pane' , min : 200 , content : null }],
chatWidth : { default : 320 , min : 240 , max : 600 },
}],
})],
])
2026-10-31 17:05:17 +08:00
for ( const [ key , value ] of Object . entries ( stored )) values . set ( key , String ( value ))
2026-10-27 23:09:34 +08:00
const localStorage = {
getItem : ( key ) => values . has ( key ) ? values . get ( key ) : null ,
setItem : ( key , value ) => values . set ( key , String ( value )),
removeItem : ( key ) => values . delete ( key ),
}
const body = fakeElement ( 'body' )
const head = fakeElement ( 'head' )
const document = {
body , head ,
documentElement : fakeElement ( 'html' ),
querySelector : () => null ,
querySelectorAll : () => [],
createElement : ( tag ) => fakeElement ( tag ),
addEventListener () {},
removeEventListener () {},
}
const window = {
innerWidth : 1280 ,
innerHeight : 900 ,
localStorage ,
document ,
__ModuleLoader__ : { load : ( definition ) => { window . captured = definition } },
addEventListener () {},
removeEventListener () {},
dispatchEvent () {},
matchMedia : () => ({ matches : false , addEventListener () {}, removeEventListener () {} }),
setTimeout : () => 1 ,
clearTimeout () {},
setInterval : () => 1 ,
clearInterval () {},
open () {},
}
const hooks = makeHooks ()
const jsxRuntime = { Fragment , jsx , jsxs : jsx }
const sandbox = {
window , document , localStorage ,
self : window ,
location : { href : 'http://localhost/' , protocol : 'http:' },
navigator : { userAgent : 'node popup regression' , platform : 'Win32' , language : 'en-US' },
console ,
atob ,
btoa ,
2026-10-31 17:05:17 +08:00
fetch : fetchFixture ?? ( async () => ({ ok : false , status : 404 , json : async () => ({}) })),
2026-10-27 23:09:34 +08:00
setTimeout : window . setTimeout ,
clearTimeout : window . clearTimeout ,
setInterval : window . setInterval ,
clearInterval : window . clearInterval ,
requestAnimationFrame : () => 1 ,
cancelAnimationFrame () {},
MutationObserver : class { observe () {} disconnect () {} },
ResizeObserver : class { observe () {} unobserve () {} disconnect () {} },
IntersectionObserver : class { observe () {} unobserve () {} disconnect () {} },
CustomEvent : class { constructor ( type , init ) { this . type = type ; this . detail = init ? . detail } },
URL , Blob , AbortController ,
indexedDB : undefined ,
}
createContext ( sandbox )
2026-10-31 17:05:17 +08:00
// Expose existing bundle locals in memory only; the delivered module stays unchanged.
const footer = 'return module.exports; } });'
assert ( source . split ( footer ). length === 2 , 'client bundle footer must be unique' )
const instrumented = source . replace ( footer ,
'Object.defineProperty(module.exports, "__consoleTest", { value: Object.freeze({ ConsolePane, setSplitEnv, setSplitT, zh, en, css, sessionNotifyState, LOCAL_VERSION, cmpVer, readCache }) });\n' + footer )
runInNewContext ( instrumented , sandbox , { filename : BUNDLE , timeout : 20000 })
2026-10-27 23:09:34 +08:00
assert ( typeof window . captured ? . factory === 'function' , 'client bundle did not register a factory' )
const requireExternal = ( id ) => {
if ( id === 'react' ) return hooks . React
if ( id === 'react/jsx-runtime' ) return jsxRuntime
if ( id === 'react-dom' ) return { createPortal : ( child ) => child }
if ( id === 'scheduler' ) return {}
throw new Error ( 'unexpected external module: ' + id )
}
const exports = window . captured . factory ( requireExternal )
assert ( typeof exports . WorktableSection === 'function' , 'bundle did not export WorktableSection' )
2026-10-31 17:05:17 +08:00
return { component : exports . WorktableSection , hooks , window , values , consoleTest : exports . __consoleTest }
2026-10-27 23:09:34 +08:00
}
function walk ( node , predicate , out = []) {
if ( Array . isArray ( node )) { for ( const child of node ) walk ( child , predicate , out ); return out }
if ( ! node || typeof node !== 'object' ) return out
if ( typeof node . type !== 'undefined' && predicate ( node )) out . push ( node )
walk ( node . props ? . children , predicate , out )
return out
}
const classHas = ( node , name ) => String ( node . props ? . className ?? '' ). split ( /\s+/ ). includes ( name )
const byClass = ( tree , name ) => walk ( tree , ( node ) => classHas ( node , name ))
const click = ( node , rect = fakeElement ()) => {
assert ( typeof node ? . props ? . onClick === 'function' , 'target has no onClick handler' )
node . props . onClick ({ currentTarget : rect , target : rect , stopPropagation () {}, preventDefault () {} })
}
async function main () {
const { component , hooks , window , values } = loadComponent ()
const render = ( wide ) => hooks . render ( component , { wide , t : ( key ) => key })
let tree = render ( false )
assert ( byClass ( tree , 'dsh-wt_rail' ). length === 1 , 'collapsed rail was not rendered' )
assert ( byClass ( tree , 'dsh-wt_add' ). length === 0 , 'add popup unexpectedly started open' )
const addFromConsole = window . __dshCustomEnv ? . console ? . onAdd
assert ( typeof addFromConsole === 'function' , 'control-room onAdd action was not exposed' )
addFromConsole ()
tree = render ( false )
assert ( byClass ( tree , 'dsh-wt_add' ). length === 1 , 'control-room add action did not render once in collapsed rail' )
tree = render ( true )
assert ( byClass ( tree , 'dsh-wt_add' ). length === 1 , 'switching wide retained duplicate or lost add popup' )
click ( byClass ( tree , 'dsh-wt_popBackdrop' )[ 0 ])
tree = render ( true )
assert ( byClass ( tree , 'dsh-wt_add' ). length === 0 , 'add popup backdrop did not close' )
const viewOptionsButton = walk ( tree , ( node ) => node . type === 'button' && node . props ? .[ 'aria-label' ] === 'menu.viewOptions' )[ 0 ]
assert ( viewOptionsButton , 'view-options control missing in wide mode' )
click ( viewOptionsButton )
tree = render ( true )
assert ( byClass ( tree , 'dsh-wt_manage' ). length === 1 , 'manage popup did not open' )
tree = render ( false )
assert ( byClass ( tree , 'dsh-wt_manage' ). length === 1 , 'switching collapsed while popup open lost or duplicated the popup' )
tree = render ( true )
const layoutIcon = byClass ( tree , 'dsh-wt_manageIcon' ). at ( - 1 )
assert ( layoutIcon , 'layout icon control missing' )
click ( layoutIcon )
tree = render ( true )
assert ( byClass ( tree , 'dsh-wt_iconPop' ). length === 1 , 'icon popup did not open once' )
const iconCell = byClass ( tree , 'dsh-wt_iconCell' )[ 1 ]
assert ( iconCell , 'icon choices missing' )
const chosenIcon = iconCell . props . children
click ( iconCell )
tree = render ( true )
assert ( byClass ( tree , 'dsh-wt_iconPop' ). length === 0 , 'choosing an icon did not close its popup' )
const savedProjects = JSON . parse ( values . get ( 'dsh.worktable.projects.v1' ))
assert ( savedProjects . layouts [ 0 ]. icon === chosenIcon , 'chosen layout icon was not persisted' )
const layoutCard = byClass ( tree , 'dsh-wt_layout' ). at ( - 1 )
const bindButton = layoutCard && walk ( layoutCard , ( node ) => classHas ( node , 'dsh-wt_bindBtn' ))[ 0 ]
assert ( bindButton , 'layout binding control missing' )
click ( bindButton )
tree = render ( true )
assert ( byClass ( tree , 'dsh-wt_bindPop' ). length === 1 , 'binding popup did not open once' )
click ( byClass ( tree , 'dsh-wt_popBackdrop' ). at ( - 1 ))
tree = render ( true )
assert ( byClass ( tree , 'dsh-wt_bindPop' ). length === 0 , 'binding popup backdrop did not close' )
const deleteButton = walk ( tree , ( node ) => node . type === 'button' && node . props ? . title === 'manage.deleteLayout' )[ 0 ]
assert ( deleteButton , 'layout delete control missing' )
click ( deleteButton )
tree = render ( true )
assert ( walk ( tree , ( node ) => node . props ? . role === 'alertdialog' ). length === 1 , 'delete confirmation did not open' )
const cancelButton = walk ( tree , ( node ) => node . type === 'button' && node . props ? . children === 'confirm.cancel' )[ 0 ]
assert ( cancelButton , 'delete confirmation cancel control missing' )
click ( cancelButton )
tree = render ( true )
assert ( walk ( tree , ( node ) => node . props ? . role === 'alertdialog' ). length === 0 , 'delete confirmation did not close' )
console . log ( 'all sidebar-popup tests passed: add, icon, binding, delete confirmation, wide/collapsed switching' )
}
2026-10-31 17:05:17 +08:00
function testConsolePresentation () {
for ( const [ language , ended ] of [[ 'zh' , '本轮已结束' ], [ 'en' , 'Run ended' ]]) {
const { hooks , consoleTest : api } = loadComponent ()
const dictionary = api [ language ]
const actions = []
const cards = [ 'done' , 'need' , 'busy' ]. map (( status ) => ({
id : status , name : status , icon : '•' , status , runtimeMs : null , kids : 0 ,
preview : 'Actual assistant reply' , bound : true , self : false , glow : status !== 'busy' ,
}))
api . setSplitT (( key ) => dictionary [ key ] ?? key )
api . setSplitEnv ({ console : {
getCards : () => cards , getTheme : () => 'dark' , getBg : () => 'plain' ,
subscribe : () => () => {}, getPhotoLib : async () => ({ list : [], activeId : null }),
onAck : ( id ) => actions . push ( 'ack:' + id ), onOpen : ( id ) => actions . push ( 'open:' + id ),
} })
let tree = hooks . render ( api . ConsolePane , {})
for ( const status of [ 'done' , 'need' , 'busy' ]) {
const labels = byClass ( tree , 'dsh-wt_consoleStatus-' + status )
const expected = status === 'done' ? ended : dictionary [ 'console.' + status ]
assert ( labels . length === 1 && labels [ 0 ]. props . children === expected , language + ' incorrect ' + status + ' label' )
}
const tip = dictionary [ 'bind.tipDone' ]
assert ( tip . includes ( language === 'zh' ? ended : 'run ended' ), language + ' sidebar tip does not describe a run ending' )
assert ( ! /已完成|成功|completed|success/i . test ( tip ), language + ' sidebar tip still claims success' )
assert ( byClass ( tree , 'dsh-wt_consoleCard-glowDone' ). length === 1 , 'unread end reminder was lost' )
assert ( byClass ( tree , 'dsh-wt_consoleCard-glowNeed' ). length === 1 , 'pending reminder was lost' )
assert ( byClass ( tree , 'dsh-wt_consoleCard-busy' ). length === 1 , 'working indicator was lost' )
assert ( byClass ( tree , 'dsh-wt_consoleSweep' ). length === 1 , 'working animation was lost' )
const previews = byClass ( tree , 'dsh-wt_consolePreview' )
assert ( previews . length === 3 && previews . every (( node ) => node . props . children === 'Actual assistant reply' ), 'card preview was missing or replaced' )
click ( byClass ( tree , 'dsh-wt_consoleCard-glowDone' )[ 0 ])
assert ( actions . join ( ',' ) === 'ack:done,open:done' , 'ended card must acknowledge before opening' )
cards [ 0 ]. glow = false
tree = hooks . render ( api . ConsolePane , {})
assert ( byClass ( tree , 'dsh-wt_consoleCard-glowDone' ). length === 0 , 'acknowledged end still glows' )
assert ( byClass ( tree , 'dsh-wt_consoleStatus-done' )[ 0 ]. props . children === ended , 'acknowledgement erased ended status' )
const rule = ( selector ) => {
const start = api . css . indexOf ( selector + '{' )
assert ( start >= 0 , 'missing bundled CSS rule: ' + selector )
return api . css . slice ( start + selector . length + 1 , api . css . indexOf ( '}' , start ))
}
for ( const selector of [ '.dsh-wt_bindBtn[data-bound=done]' , '.dsh-wt_consoleStatus-done' , '.dsh-wt_consoleCard-glowDone' ]) {
assert ( ! /state-success|#3fb950|rgba?\(63,\s*185,\s*80/i . test ( rule ( selector )), 'ended reminder still uses success green: ' + selector )
}
assert ( rule ( '.dsh-wt_bindBtn[data-bound=need]' ). includes ( '#d29922' ), 'sidebar pending color changed' )
assert ( rule ( '.dsh-wt_bindBtn[data-bound=busy]' ). includes ( '#4f8ef7' ), 'sidebar working color changed' )
assert ( rule ( '.dsh-wt_consoleStatus-need' ). includes ( 'state-warning' ), 'control-room pending color changed' )
assert ( rule ( '.dsh-wt_consoleStatus-busy' ). includes ( 'state-accent-primary' ), 'control-room working color changed' )
assert ( api . sessionNotifyState ({ running : true , completed : true }) === null , 'stale end must not hide a running sidebar session' )
assert ( api . sessionNotifyState ({ running : true , completed : true , pendingInteraction : { key : 'fixture' } }) === 'need' , 'pending must remain first' )
assert ( api . sessionNotifyState ({ running : false , completed : true }) === 'done' , 'run ending lost its existing ack kind' )
}
console . log ( 'all control-room presentation tests passed: zh/en, neutral end, need/busy, preview, acknowledgement' )
}
async function testUpdatePresentation () {
const releaseUrl = 'https://api.github.com/repos/Aisland-SJL/dsh-worktable/releases/latest'
for ( const outcome of [ 'older' , 'same' , 'forbidden' , 'invalid' , 'invalid-prefix' , 'skip' ]) {
let response = { status : 200 , tag : 'v9.0.0' }
let requests = 0
const { component , hooks , values , consoleTest : api } = loadComponent ({ fetch : async ( url ) => {
assert ( url === releaseUrl , 'unexpected request in update fixture: ' + url )
requests ++
return { ok : response . status === 200 , status : response . status ,
json : async () => ({ tag_name : response . tag , body : 'Fixture release' , html_url : 'https://example.invalid/release' }) }
} })
assert ( api . cmpVer ( '0.5.0' , api . LOCAL_VERSION ) < 0 , 'bundled comparator does not reject the old release' )
const render = () => hooks . render ( component , { wide : true , t : ( key ) => key })
let tree = render ()
click ( walk ( tree , ( node ) => node . type === 'button' && node . props ? .[ 'aria-label' ] === 'menu.viewOptions' )[ 0 ])
tree = render ()
const check = async () => {
click ( byClass ( byClass ( tree , 'dsh-wt_versionRow' )[ 0 ], 'dsh-wt_updateBtn' )[ 0 ])
tree = render ()
assert ( byClass ( byClass ( tree , 'dsh-wt_versionRow' )[ 0 ], 'dsh-wt_updateBtn' )[ 0 ]. props . disabled , 'check button did not enter checking state' )
await new Promise ( setImmediate )
tree = render ()
}
await check ()
assert ( requests === 1 , 'manual check did not issue one fixture request' )
assert ( byClass ( tree , 'dsh-wt_updateCard' ). length === 1 && byClass ( tree , 'dsh-wt_updateBadge' ). length === 1 , 'genuine newer release did not show card and badge' )
assert ( JSON . stringify ( byClass ( tree , 'dsh-wt_updateVers' )[ 0 ]. props . children ). includes ( '9.0.0' ), 'newer release label missing' )
if ( outcome === 'skip' ) {
click ( walk ( tree , ( node ) => node . type === 'button' && node . props ? . children === 'update.skip' )[ 0 ])
tree = render ()
assert ( values . get ( 'dsh.worktable.skipVersion.v1' ) === '9.0.0' , 'skip button did not persist the shown release' )
assert ( byClass ( tree , 'dsh-wt_updateCard' ). length === 0 && byClass ( tree , 'dsh-wt_updateBadge' ). length === 0 , 'skip button left visible update UI' )
} else {
response = outcome === 'forbidden' ? { status : 403 } : { status : 200 ,
tag : outcome === 'older' ? 'v0.5.0' : outcome === 'same' ? 'v' + api . LOCAL_VERSION : outcome === 'invalid-prefix' ? 'vv9.0.0' : 'not-a-version' }
}
await check ()
assert ( requests === 2 , outcome + ': unexpected retries or skipped manual request' )
for ( const name of [ 'dsh-wt_updateCard' , 'dsh-wt_updateBadge' , 'dsh-wt_updateBtnCopy' ]) {
assert ( byClass ( tree , name ). length === 0 , outcome + ': stale update UI survived: ' + name )
}
if ([ 'forbidden' , 'invalid' , 'invalid-prefix' ]. includes ( outcome )) {
assert ( JSON . stringify ( byClass ( tree , 'dsh-wt_versionRow' )[ 0 ]. props . children ). includes ( 'update.checkFail' ), 'failed check was hidden by old update info' )
}
}
const localVersion = loadComponent (). consoleTest . LOCAL_VERSION
for ( const [ label , latest , skipped , shown ] of [
[ 'older' , '0.5.0' , '' , false ], [ 'same' , localVersion , '' , false ],
[ 'invalid' , 'not-a-version' , '' , false ], [ 'skipped' , '9.0.0' , '9.0.0' , false ],
[ 'newer' , '9.0.0' , '' , true ],
]) {
let requests = 0
const { hooks , values , consoleTest : api } = loadComponent ({
stored : {
'dsh.worktable.updateCheck.v1' : '0' ,
'dsh.worktable.lastUpdateCheck.v1' : String ( Date . now ()),
'dsh.worktable.skipVersion.v1' : skipped ,
'dsh.worktable.updateCache.v1' : JSON . stringify ({ status : 'uptodate' , info : { latest , notes : 'Fixture cache' , url : 'https://example.invalid/release' } }),
},
fetch : async () => { requests ++ ; return { ok : false , status : 404 } },
})
api . setSplitT (( key ) => key )
api . setSplitEnv ({ console : {
getCards : () => [], getTheme : () => 'dark' , getBg : () => 'plain' ,
subscribe : () => () => {}, getPhotoLib : async () => ({ list : [], activeId : null }),
} })
const render = () => hooks . render ( api . ConsolePane , {})
let tree = render ()
assert ( byClass ( tree , 'dsh-wt_dockBadge' ). length === Number ( shown ), label + ': wrong cached dock badge' )
click ( walk ( tree , ( node ) => node . type === 'button' && node . props ? .[ 'aria-label' ] === 'annot.updateTitle' )[ 0 ])
tree = render ()
for ( const name of [ 'dsh-wt_announceUpdate' , 'dsh-wt_announceUpg' ]) {
assert ( byClass ( tree , name ). length === Number ( shown ), label + ': wrong cached announcement: ' + name )
}
assert (( api . readCache (). info != null ) === shown , label + ': cache reader still exposes an ineligible release' )
if ( shown ) {
values . set ( 'dsh.worktable.skipVersion.v1' , latest )
tree = render ()
assert ( byClass ( tree , 'dsh-wt_announceUpdate' ). length === 0 && byClass ( tree , 'dsh-wt_dockBadge' ). length === 0 , 'already-mounted control room retained ignored update info' )
}
await new Promise ( setImmediate )
assert ( requests === 0 , label + ': disabled automatic update check made a request' )
}
console . log ( 'all update presentation tests passed: settings transitions and control-room cache, no network' )
}
2026-10-27 23:09:34 +08:00
await main ()
2026-10-31 17:05:17 +08:00
testConsolePresentation ()
await testUpdatePresentation ()