@@ -124,6 +124,176 @@ describe("generatePlist", () => {
124124 ) ;
125125 } ) ;
126126
127+ // -----------------------------------------------------------------------
128+ // ProgramArguments ordering — controller
129+ // -----------------------------------------------------------------------
130+ it ( "controller ProgramArguments: [nodePath, controllerEntryPath] in exact order" , async ( ) => {
131+ const { generatePlist } = await import (
132+ "../../apps/desktop/main/services/plist-generator"
133+ ) ;
134+ const plist = generatePlist ( "controller" , mockEnv ) ;
135+
136+ // Extract ProgramArguments array content
137+ const argsMatch = plist . match (
138+ / < k e y > P r o g r a m A r g u m e n t s < \/ k e y > \s * < a r r a y > ( [ \s \S ] * ?) < \/ a r r a y > / ,
139+ ) ;
140+ expect ( argsMatch ) . not . toBeNull ( ) ;
141+ const argsBlock = argsMatch ?. [ 1 ] ?? "" ;
142+ const strings = [ ...argsBlock . matchAll ( / < s t r i n g > ( [ ^ < ] * ) < \/ s t r i n g > / g) ] . map (
143+ ( m ) => m [ 1 ] ,
144+ ) ;
145+ expect ( strings ) . toEqual ( [
146+ "/usr/local/bin/node" ,
147+ "/app/controller/dist/index.js" ,
148+ ] ) ;
149+ } ) ;
150+
151+ // -----------------------------------------------------------------------
152+ // ProgramArguments ordering — openclaw
153+ // -----------------------------------------------------------------------
154+ it ( "openclaw ProgramArguments: [nodePath, openclawPath, gateway, run] in exact order" , async ( ) => {
155+ const { generatePlist } = await import (
156+ "../../apps/desktop/main/services/plist-generator"
157+ ) ;
158+ const plist = generatePlist ( "openclaw" , mockEnv ) ;
159+
160+ const argsMatch = plist . match (
161+ / < k e y > P r o g r a m A r g u m e n t s < \/ k e y > \s * < a r r a y > ( [ \s \S ] * ?) < \/ a r r a y > / ,
162+ ) ;
163+ expect ( argsMatch ) . not . toBeNull ( ) ;
164+ const argsBlock = argsMatch ?. [ 1 ] ?? "" ;
165+ const strings = [ ...argsBlock . matchAll ( / < s t r i n g > ( [ ^ < ] * ) < \/ s t r i n g > / g) ] . map (
166+ ( m ) => m [ 1 ] ,
167+ ) ;
168+ expect ( strings ) . toEqual ( [
169+ "/usr/local/bin/node" ,
170+ "/app/openclaw/openclaw.mjs" ,
171+ "gateway" ,
172+ "run" ,
173+ ] ) ;
174+ } ) ;
175+
176+ it ( "openclaw dev mode inserts --auth none after gateway run" , async ( ) => {
177+ const { generatePlist } = await import (
178+ "../../apps/desktop/main/services/plist-generator"
179+ ) ;
180+ const plist = generatePlist ( "openclaw" , { ...mockEnv , isDev : true } ) ;
181+
182+ const argsMatch = plist . match (
183+ / < k e y > P r o g r a m A r g u m e n t s < \/ k e y > \s * < a r r a y > ( [ \s \S ] * ?) < \/ a r r a y > / ,
184+ ) ;
185+ expect ( argsMatch ) . not . toBeNull ( ) ;
186+ const argsBlock = argsMatch ?. [ 1 ] ?? "" ;
187+ const strings = [ ...argsBlock . matchAll ( / < s t r i n g > ( [ ^ < ] * ) < \/ s t r i n g > / g) ] . map (
188+ ( m ) => m [ 1 ] ,
189+ ) ;
190+ expect ( strings ) . toEqual ( [
191+ "/usr/local/bin/node" ,
192+ "/app/openclaw/openclaw.mjs" ,
193+ "gateway" ,
194+ "run" ,
195+ "--auth" ,
196+ "none" ,
197+ ] ) ;
198+ } ) ;
199+
200+ // -----------------------------------------------------------------------
201+ // Openclaw plist completeness — WorkingDirectory, error log, KeepAlive
202+ // -----------------------------------------------------------------------
203+ it ( "openclaw plist has correct WorkingDirectory" , async ( ) => {
204+ const { generatePlist } = await import (
205+ "../../apps/desktop/main/services/plist-generator"
206+ ) ;
207+ const plist = generatePlist ( "openclaw" , mockEnv ) ;
208+
209+ expect ( plist ) . toContain (
210+ "<key>WorkingDirectory</key>\n <string>/app</string>" ,
211+ ) ;
212+ } ) ;
213+
214+ it ( "openclaw plist has StandardErrorPath" , async ( ) => {
215+ const { generatePlist } = await import (
216+ "../../apps/desktop/main/services/plist-generator"
217+ ) ;
218+ const plist = generatePlist ( "openclaw" , mockEnv ) ;
219+
220+ expect ( plist ) . toContain (
221+ "<string>/Users/testuser/.nexu/logs/openclaw.error.log</string>" ,
222+ ) ;
223+ } ) ;
224+
225+ it ( "openclaw plist KeepAlive restarts on non-zero exit" , async ( ) => {
226+ const { generatePlist } = await import (
227+ "../../apps/desktop/main/services/plist-generator"
228+ ) ;
229+ const plist = generatePlist ( "openclaw" , mockEnv ) ;
230+
231+ // SuccessfulExit=false means launchd restarts when exit code != 0
232+ expect ( plist ) . toContain ( "<key>SuccessfulExit</key>" ) ;
233+ expect ( plist ) . toMatch ( / < k e y > S u c c e s s f u l E x i t < \/ k e y > \s * < f a l s e \/ > / ) ;
234+ } ) ;
235+
236+ it ( "openclaw plist has ThrottleInterval to prevent rapid respawn" , async ( ) => {
237+ const { generatePlist } = await import (
238+ "../../apps/desktop/main/services/plist-generator"
239+ ) ;
240+ const plist = generatePlist ( "openclaw" , mockEnv ) ;
241+
242+ expect ( plist ) . toContain ( "<key>ThrottleInterval</key>" ) ;
243+ expect ( plist ) . toMatch (
244+ / < k e y > T h r o t t l e I n t e r v a l < \/ k e y > \s * < i n t e g e r > \d + < \/ i n t e g e r > / ,
245+ ) ;
246+ } ) ;
247+
248+ it ( "controller plist has correct WorkingDirectory" , async ( ) => {
249+ const { generatePlist } = await import (
250+ "../../apps/desktop/main/services/plist-generator"
251+ ) ;
252+ const plist = generatePlist ( "controller" , mockEnv ) ;
253+
254+ expect ( plist ) . toContain (
255+ "<key>WorkingDirectory</key>\n <string>/app/controller</string>" ,
256+ ) ;
257+ } ) ;
258+
259+ it ( "both plists have RunAtLoad=false (explicit start only)" , async ( ) => {
260+ const { generatePlist } = await import (
261+ "../../apps/desktop/main/services/plist-generator"
262+ ) ;
263+ const controller = generatePlist ( "controller" , mockEnv ) ;
264+ const openclaw = generatePlist ( "openclaw" , mockEnv ) ;
265+
266+ expect ( controller ) . toMatch ( / < k e y > R u n A t L o a d < \/ k e y > \s * < f a l s e \/ > / ) ;
267+ expect ( openclaw ) . toMatch ( / < k e y > R u n A t L o a d < \/ k e y > \s * < f a l s e \/ > / ) ;
268+ } ) ;
269+
270+ // -----------------------------------------------------------------------
271+ // XML escaping robustness
272+ // -----------------------------------------------------------------------
273+ it ( "escapes ampersand, angle brackets, quotes, and apostrophes in all path fields" , async ( ) => {
274+ const { generatePlist } = await import (
275+ "../../apps/desktop/main/services/plist-generator"
276+ ) ;
277+
278+ const nastEnv = {
279+ ...mockEnv ,
280+ nodePath : "/usr/bin/node's" ,
281+ controllerCwd : '/app/"controller"' ,
282+ openclawPath : "/path/with&special<chars>.mjs" ,
283+ openclawConfigPath : "/Users/test'user/.nexu/config" ,
284+ } ;
285+
286+ const controller = generatePlist ( "controller" , nastEnv ) ;
287+ const openclaw = generatePlist ( "openclaw" , nastEnv ) ;
288+
289+ // Verify escaped forms present, raw forms absent
290+ expect ( controller ) . toContain ( "node's" ) ;
291+ expect ( controller ) . not . toContain ( "node's</string>" ) ;
292+ expect ( controller ) . toContain ( ""controller"" ) ;
293+ expect ( openclaw ) . toContain ( "&special<chars>" ) ;
294+ expect ( openclaw ) . toContain ( "test'user" ) ;
295+ } ) ;
296+
127297 it ( "sets ELECTRON_RUN_AS_NODE=1 for both services" , async ( ) => {
128298 const { generatePlist } = await import (
129299 "../../apps/desktop/main/services/plist-generator"
0 commit comments