11// Imessage tests cover status plugin behavior.
2+ import { EventEmitter } from "node:events" ;
3+ import { PassThrough } from "node:stream" ;
24import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime" ;
35import * as processRuntime from "openclaw/plugin-sdk/process-runtime" ;
46import * as setupRuntime from "openclaw/plugin-sdk/setup" ;
@@ -20,6 +22,26 @@ const getIMessageSetupStatus = createPluginSetupWizardStatus({
2022
2123const spawnMock = vi . hoisted ( ( ) => vi . fn ( ) ) ;
2224
25+ function createMockChildProcess ( ) {
26+ const child = new EventEmitter ( ) as EventEmitter & {
27+ stdin : PassThrough ;
28+ stdout : PassThrough ;
29+ stderr : PassThrough ;
30+ killed : boolean ;
31+ kill : ( signal ?: string ) => boolean ;
32+ } ;
33+ child . stdin = new PassThrough ( ) ;
34+ child . stdout = new PassThrough ( ) ;
35+ child . stderr = new PassThrough ( ) ;
36+ child . killed = false ;
37+ child . kill = ( signal ?: string ) => {
38+ child . killed = true ;
39+ child . emit ( "close" , 0 , signal ?? null ) ;
40+ return true ;
41+ } ;
42+ return child ;
43+ }
44+
2345vi . mock ( "node:child_process" , async ( ) => {
2446 const actual = await vi . importActual < typeof import ( "node:child_process" ) > ( "node:child_process" ) ;
2547 return {
@@ -67,6 +89,96 @@ describe("createIMessageRpcClient", () => {
6789
6890 expect ( internals . buildCloseError ( 1 , null ) . message ) . toBe ( PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR ) ;
6991 } ) ;
92+
93+ it . each ( [
94+ [ "U+2028" , "\u2028" ] ,
95+ [ "U+2029" , "\u2029" ] ,
96+ ] ) (
97+ "frames stdout on LF only so raw %s inside JSON strings stays intact" ,
98+ async ( _ , separator ) => {
99+ const { IMessageRpcClient } = await import ( "./client.js" ) ;
100+ const client = new IMessageRpcClient ( ) ;
101+ const internals = client as unknown as {
102+ handleStdoutChunk : ( chunk : Buffer | string ) => void ;
103+ pending : Map <
104+ string ,
105+ {
106+ resolve : ( value : unknown ) => void ;
107+ reject : ( error : Error ) => void ;
108+ }
109+ > ;
110+ } ;
111+ const result = new Promise ( ( resolve , reject ) => {
112+ internals . pending . set ( "1" , { resolve, reject } ) ;
113+ } ) ;
114+ const text = `line one${ separator } line two` ;
115+ const payload = `${ JSON . stringify ( {
116+ jsonrpc : "2.0" ,
117+ id : 1 ,
118+ result : { messages : [ { text } ] } ,
119+ } ) } \n`;
120+ const bytes = Buffer . from ( payload , "utf8" ) ;
121+ const separatorIndex = bytes . indexOf ( Buffer . from ( separator , "utf8" ) ) ;
122+
123+ internals . handleStdoutChunk ( bytes . subarray ( 0 , separatorIndex + 1 ) ) ;
124+ internals . handleStdoutChunk ( bytes . subarray ( separatorIndex + 1 ) ) ;
125+
126+ await expect ( result ) . resolves . toEqual ( {
127+ messages : [ { text } ] ,
128+ } ) ;
129+ } ,
130+ ) ;
131+
132+ it ( "handles multiple LF-delimited stdout responses in one chunk" , async ( ) => {
133+ const { IMessageRpcClient } = await import ( "./client.js" ) ;
134+ const client = new IMessageRpcClient ( ) ;
135+ const internals = client as unknown as {
136+ handleStdoutChunk : ( chunk : Buffer | string ) => void ;
137+ pending : Map <
138+ string ,
139+ {
140+ resolve : ( value : unknown ) => void ;
141+ reject : ( error : Error ) => void ;
142+ }
143+ > ;
144+ } ;
145+ const first = new Promise ( ( resolve , reject ) => {
146+ internals . pending . set ( "1" , { resolve, reject } ) ;
147+ } ) ;
148+ const second = new Promise ( ( resolve , reject ) => {
149+ internals . pending . set ( "2" , { resolve, reject } ) ;
150+ } ) ;
151+
152+ internals . handleStdoutChunk (
153+ `${ JSON . stringify ( { jsonrpc : "2.0" , id : 1 , result : { ok : "first" } } ) } \n${ JSON . stringify ( {
154+ jsonrpc : "2.0" ,
155+ id : 2 ,
156+ result : { ok : "second" } ,
157+ } ) } \n`,
158+ ) ;
159+
160+ await expect ( first ) . resolves . toEqual ( { ok : "first" } ) ;
161+ await expect ( second ) . resolves . toEqual ( { ok : "second" } ) ;
162+ } ) ;
163+
164+ it ( "ignores stdout from a stale child after stop so late notifications cannot leak (#89830)" , async ( ) => {
165+ vi . stubEnv ( "VITEST" , "" ) ;
166+ vi . stubEnv ( "NODE_ENV" , "" ) ;
167+ const child = createMockChildProcess ( ) ;
168+ spawnMock . mockReturnValue ( child ) ;
169+ const onNotification = vi . fn ( ) ;
170+ const { IMessageRpcClient } = await import ( "./client.js" ) ;
171+ const client = new IMessageRpcClient ( { onNotification } ) ;
172+
173+ await client . start ( ) ;
174+ await client . stop ( ) ;
175+
176+ // A not-yet-exited imsg child emits a complete notification after stop().
177+ // The `this.child !== child` guard must drop it before handleStdoutChunk.
178+ child . stdout . write ( '{"jsonrpc":"2.0","method":"messages.changed","params":{}}\n' ) ;
179+
180+ expect ( onNotification ) . not . toHaveBeenCalled ( ) ;
181+ } ) ;
70182} ) ;
71183
72184describe ( "imessage setup status" , ( ) => {
0 commit comments