11import { afterEach , describe , expect , it , vi } from "vitest" ;
22
3- // Mock loadConfig so the single-arg setActiveWebListener overload resolves
4- // the configured default account as "work" (matching the regression test).
5- // All other tests pass explicit accountIds and are unaffected by this mock.
63vi . mock ( "openclaw/plugin-sdk/config-runtime" , ( ) => ( {
74 loadConfig : ( ) => ( {
85 channels : { whatsapp : { accounts : { work : { enabled : true } } , defaultAccount : "work" } } ,
@@ -17,14 +14,6 @@ async function importActiveListenerModule(cacheBust: string): Promise<ActiveList
1714 return ( await import ( `${ activeListenerModuleUrl } ?t=${ cacheBust } ` ) ) as ActiveListenerModule ;
1815}
1916
20- afterEach ( async ( ) => {
21- const mod = await importActiveListenerModule ( `cleanup-${ Date . now ( ) } ` ) ;
22- mod . setActiveWebListener ( null ) ;
23- mod . setActiveWebListener ( "work" , null ) ;
24- mod . setActiveWebListener ( "default" , null ) ;
25- } ) ;
26-
27- /** Minimal listener stub */
2817function makeListener ( ) {
2918 return {
3019 sendMessage : vi . fn ( async ( ) => ( { messageId : "msg-1" } ) ) ,
@@ -34,92 +23,53 @@ function makeListener() {
3423 } ;
3524}
3625
37- describe ( "active WhatsApp listener singleton" , ( ) => {
38- it ( "shares listeners across duplicate module instances (bundle-fragmentation fix)" , async ( ) => {
39- // Simulates the scenario where two bundled copies of active-listener.ts are loaded
40- // (e.g. channel-web-*.js calls setActiveWebListener, outbound-*.js calls
41- // requireActiveWebListener). Without resolveGlobalSingleton they would each hold
42- // their own Map and the listener would never be found by the outbound path.
43- const first = await importActiveListenerModule ( `first-${ Date . now ( ) } ` ) ;
44- const second = await importActiveListenerModule ( `second-${ Date . now ( ) } ` ) ;
26+ afterEach ( ( ) => {
27+ vi . doUnmock ( "./connection-controller-registry.js" ) ;
28+ } ) ;
29+
30+ describe ( "active WhatsApp listener view" , ( ) => {
31+ it ( "reads controller-backed state across duplicate module instances" , async ( ) => {
4532 const listener = makeListener ( ) ;
33+ vi . doMock ( "./connection-controller-registry.js" , ( ) => ( {
34+ getRegisteredWhatsAppConnectionController : ( accountId : string ) =>
35+ accountId === "work"
36+ ? {
37+ getActiveListener : ( ) => listener ,
38+ }
39+ : null ,
40+ } ) ) ;
4641
47- first . setActiveWebListener ( "work" , listener ) ;
42+ const first = await importActiveListenerModule ( `first-${ Date . now ( ) } ` ) ;
43+ const second = await importActiveListenerModule ( `second-${ Date . now ( ) } ` ) ;
4844
45+ expect ( first . getActiveWebListener ( "work" ) ) . toBe ( listener ) ;
4946 expect ( second . getActiveWebListener ( "work" ) ) . toBe ( listener ) ;
50- expect ( second . requireActiveWebListener ( "work" ) ) . toEqual ( {
51- accountId : "work" ,
52- listener,
53- } ) ;
5447 } ) ;
5548
56- it ( "single-arg overload registers under configured default account, not always 'default'" , async ( ) => {
57- // Regression: setActiveWebListener(listener) used DEFAULT_ACCOUNT_ID ("default")
58- // even when the configured default account is named "work". This caused
59- // requireActiveWebListener("work") to throw while the listener was silently
60- // registered under the wrong key.
61- const mod = await importActiveListenerModule ( `named-account-${ Date . now ( ) } ` ) ;
49+ it ( "resolves the configured default account when accountId is omitted" , async ( ) => {
6250 const listener = makeListener ( ) ;
63-
64- // Single-arg call — should resolve accountId from loadConfig() default, which
65- // vitest config maps to "work" (see mock below).
66- mod . setActiveWebListener ( listener ) ;
67-
68- // "work" must be resolvable — previously this threw
69- expect ( mod . requireActiveWebListener ( "work" ) ) . toEqual ( {
70- accountId : "work" ,
71- listener,
72- } ) ;
51+ vi . doMock ( "./connection-controller-registry.js" , ( ) => ( {
52+ getRegisteredWhatsAppConnectionController : ( accountId : string ) =>
53+ accountId === "work"
54+ ? {
55+ getActiveListener : ( ) => listener ,
56+ }
57+ : null ,
58+ } ) ) ;
59+
60+ const mod = await importActiveListenerModule ( `default-${ Date . now ( ) } ` ) ;
61+
62+ expect ( mod . resolveWebAccountId ( ) ) . toBe ( "work" ) ;
63+ expect ( mod . getActiveWebListener ( ) ) . toBe ( listener ) ;
7364 } ) ;
7465
75- it ( "single-arg overload still works when default account is 'default'" , async ( ) => {
76- // Backward-compat: configs that rely on the "default" account name must
77- // continue to work after the fix. Use single-arg overload with a temporary
78- // spy that returns "default" as the configured default account.
79- const configRuntime = await import ( "openclaw/plugin-sdk/config-runtime" ) ;
80- const spy = vi . spyOn ( configRuntime , "loadConfig" ) . mockReturnValue ( {
81- channels : {
82- whatsapp : { accounts : { default : { enabled : true } } , defaultAccount : "default" } ,
83- } ,
84- } as ReturnType < typeof configRuntime . loadConfig > ) ;
66+ it ( "returns null when the controller has no active listener for the account" , async ( ) => {
67+ vi . doMock ( "./connection-controller-registry.js" , ( ) => ( {
68+ getRegisteredWhatsAppConnectionController : ( ) => null ,
69+ } ) ) ;
8570
86- try {
87- const mod = await importActiveListenerModule ( `default-account-${ Date . now ( ) } ` ) ;
88- const listener = makeListener ( ) ;
89-
90- // Single-arg call — should resolve to "default" via the spy
91- mod . setActiveWebListener ( listener ) ;
92-
93- expect ( mod . requireActiveWebListener ( "default" ) ) . toEqual ( {
94- accountId : "default" ,
95- listener,
96- } ) ;
97- // The legacy no-arg lookup (undefined → "default") must also work
98- expect ( mod . requireActiveWebListener ( ) ) . toEqual ( {
99- accountId : "default" ,
100- listener,
101- } ) ;
102- } finally {
103- spy . mockRestore ( ) ;
104- }
105- } ) ;
106-
107- it ( "requireActiveWebListener throws a clear error when listener is missing" , async ( ) => {
10871 const mod = await importActiveListenerModule ( `missing-${ Date . now ( ) } ` ) ;
10972
110- expect ( ( ) => mod . requireActiveWebListener ( "work" ) ) . toThrowError (
111- / N o a c t i v e W h a t s A p p W e b l i s t e n e r \( a c c o u n t : w o r k \) / ,
112- ) ;
113- } ) ;
114-
115- it ( "setActiveWebListener with null removes the listener" , async ( ) => {
116- const mod = await importActiveListenerModule ( `remove-${ Date . now ( ) } ` ) ;
117- const listener = makeListener ( ) ;
118-
119- mod . setActiveWebListener ( "work" , listener ) ;
120- expect ( mod . getActiveWebListener ( "work" ) ) . toBe ( listener ) ;
121-
122- mod . setActiveWebListener ( "work" , null ) ;
12373 expect ( mod . getActiveWebListener ( "work" ) ) . toBeNull ( ) ;
12474 } ) ;
12575} ) ;
0 commit comments