1+ import { spawn } from "node:child_process" ;
12import fs from "node:fs" ;
23import os from "node:os" ;
34import path from "node:path" ;
@@ -18,7 +19,9 @@ import {
1819 renderLaneGraph ,
1920 resolveAdeCodeModulePath ,
2021 resolveRoots ,
22+ runCli ,
2123 shouldAutoRegisterProjectForPlan ,
24+ shouldBlockManualMachineRuntimeSpawn ,
2225 shouldEnforceMachineRuntimeBuildCompatibility ,
2326 shouldAttemptDesktopSocketConnection ,
2427 summarizeExecution ,
@@ -31,6 +34,8 @@ type ResolveRootsOptions = Parameters<typeof resolveRoots>[0];
3134process . env . ADE_ENABLE_AUTOMATIONS = "1" ;
3235process . env . ADE_ENABLE_MACOS_VM = "1" ;
3336
37+ const crdtHostIt = process . platform === "darwin" ? it : it . skip ;
38+
3439function withEnv < T > ( updates : Record < string , string | undefined > , run : ( ) => T ) : T {
3540 const previous = new Map < string , string | undefined > ( ) ;
3641 for ( const key of Object . keys ( updates ) ) {
@@ -80,6 +85,35 @@ function expectExecutePlan(
8085 return plan ;
8186}
8287
88+ function writeSyncHostSingletonLock ( args : {
89+ lockPath : string ;
90+ pid : number ;
91+ port : number ;
92+ packageChannel : string | null ;
93+ adeHome : string ;
94+ } ) : void {
95+ const now = "2026-06-11T00:00:00.000Z" ;
96+ fs . mkdirSync ( path . dirname ( args . lockPath ) , { recursive : true } ) ;
97+ fs . writeFileSync ( args . lockPath , `${ JSON . stringify ( {
98+ version : 1 ,
99+ owner : {
100+ id : "other-channel-brain" ,
101+ pid : args . pid ,
102+ port : args . port ,
103+ appName : args . packageChannel === "beta" ? "ADE Beta" : "ADE" ,
104+ packageChannel : args . packageChannel ,
105+ adeHome : args . adeHome ,
106+ serviceName : args . packageChannel === "beta" ? "com.ade.runtime.beta" : "com.ade.runtime" ,
107+ socketPath : path . join ( args . adeHome , "sock" , "ade.sock" ) ,
108+ projectRoot : "/Users/admin/Projects/ADE" ,
109+ commandLine : null ,
110+ quitCommand : `ADE_HOME='${ args . adeHome } ' ade brain stop --text` ,
111+ createdAt : now ,
112+ updatedAt : now ,
113+ } ,
114+ } , null , 2 ) } \n`, "utf8" ) ;
115+ }
116+
83117describe ( "ADE CLI" , ( ) => {
84118 it ( "parses global options without stealing command flags" , ( ) => {
85119 const parsed = parseCliArgs ( [
@@ -330,6 +364,62 @@ describe("ADE CLI", () => {
330364 } ) ;
331365 } ) ;
332366
367+ crdtHostIt ( "serve fails instead of exiting successfully when another channel owns mobile sync" , async ( ) => {
368+ const adeHome = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "ade-cli-serve-conflict-" ) ) ;
369+ const projectRoot = path . join ( adeHome , "project" ) ;
370+ const lockPath = path . join ( adeHome , "sync-host-lock.json" ) ;
371+ const socketPath = path . join ( adeHome , "sock" , "ade.sock" ) ;
372+ fs . mkdirSync ( projectRoot , { recursive : true } ) ;
373+ const originalEnv = {
374+ ADE_HOME : process . env . ADE_HOME ,
375+ ADE_PROJECT_ROOT : process . env . ADE_PROJECT_ROOT ,
376+ ADE_PACKAGE_CHANNEL : process . env . ADE_PACKAGE_CHANNEL ,
377+ ADE_SYNC_HOST_LOCK_PATH : process . env . ADE_SYNC_HOST_LOCK_PATH ,
378+ ADE_SYNC_HOST_SINGLETON_TEST_MODE : process . env . ADE_SYNC_HOST_SINGLETON_TEST_MODE ,
379+ } ;
380+ const ownerProcess = spawn ( process . execPath , [ "-e" , "setInterval(() => {}, 1000);" ] , {
381+ stdio : "ignore" ,
382+ } ) ;
383+ ownerProcess . on ( "error" , ( ) => { } ) ;
384+ ownerProcess . unref ( ) ;
385+ if ( ! ownerProcess . pid ) {
386+ throw new Error ( "Failed to start fake sync-host owner process." ) ;
387+ }
388+
389+ try {
390+ process . env . ADE_HOME = adeHome ;
391+ process . env . ADE_PROJECT_ROOT = projectRoot ;
392+ delete process . env . ADE_PACKAGE_CHANNEL ;
393+ process . env . ADE_SYNC_HOST_LOCK_PATH = lockPath ;
394+ process . env . ADE_SYNC_HOST_SINGLETON_TEST_MODE = "1" ;
395+ writeSyncHostSingletonLock ( {
396+ lockPath,
397+ pid : ownerProcess . pid ,
398+ port : 8801 ,
399+ packageChannel : "beta" ,
400+ adeHome : path . join ( os . homedir ( ) , ".ade-beta" ) ,
401+ } ) ;
402+
403+ await expect ( runCli ( [ "serve" , "--socket" , socketPath ] ) ) . rejects . toThrow (
404+ "ADE brain refusing to run without mobile sync." ,
405+ ) ;
406+ expect ( fs . existsSync ( socketPath ) ) . toBe ( false ) ;
407+ } finally {
408+ if ( originalEnv . ADE_HOME === undefined ) delete process . env . ADE_HOME ;
409+ else process . env . ADE_HOME = originalEnv . ADE_HOME ;
410+ if ( originalEnv . ADE_PROJECT_ROOT === undefined ) delete process . env . ADE_PROJECT_ROOT ;
411+ else process . env . ADE_PROJECT_ROOT = originalEnv . ADE_PROJECT_ROOT ;
412+ if ( originalEnv . ADE_PACKAGE_CHANNEL === undefined ) delete process . env . ADE_PACKAGE_CHANNEL ;
413+ else process . env . ADE_PACKAGE_CHANNEL = originalEnv . ADE_PACKAGE_CHANNEL ;
414+ if ( originalEnv . ADE_SYNC_HOST_LOCK_PATH === undefined ) delete process . env . ADE_SYNC_HOST_LOCK_PATH ;
415+ else process . env . ADE_SYNC_HOST_LOCK_PATH = originalEnv . ADE_SYNC_HOST_LOCK_PATH ;
416+ if ( originalEnv . ADE_SYNC_HOST_SINGLETON_TEST_MODE === undefined ) delete process . env . ADE_SYNC_HOST_SINGLETON_TEST_MODE ;
417+ else process . env . ADE_SYNC_HOST_SINGLETON_TEST_MODE = originalEnv . ADE_SYNC_HOST_SINGLETON_TEST_MODE ;
418+ ownerProcess . kill ( "SIGKILL" ) ;
419+ fs . rmSync ( adeHome , { recursive : true , force : true } ) ;
420+ }
421+ } ) ;
422+
333423 it ( "recognizes the hidden PTY host worker entrypoint" , ( ) => {
334424 expect ( buildCliPlan ( [ "__ade-pty-host-worker" ] ) ) . toEqual ( {
335425 kind : "pty-host-worker" ,
@@ -345,6 +435,19 @@ describe("ADE CLI", () => {
345435 expect ( isEphemeralRuntimeSocketPath ( "tcp://127.0.0.1:8765" ) ) . toBe ( false ) ;
346436 } ) ;
347437
438+ it ( "blocks manual service-socket runtime spawn when service mutation is disabled" , ( ) => {
439+ expect ( shouldBlockManualMachineRuntimeSpawn ( "/Users/example/.ade-beta/sock/ade.sock" , {
440+ ADE_DISABLE_RUNTIME_SERVICE_INSTALL : "1" ,
441+ } ) ) . toBe ( true ) ;
442+ expect ( shouldBlockManualMachineRuntimeSpawn ( "/Users/example/.ade-beta/sock/ade.sock" , { } ) ) . toBe ( false ) ;
443+ expect ( shouldBlockManualMachineRuntimeSpawn ( "tcp://127.0.0.1:9999" , {
444+ ADE_DISABLE_RUNTIME_SERVICE_INSTALL : "1" ,
445+ } ) ) . toBe ( false ) ;
446+ expect ( shouldBlockManualMachineRuntimeSpawn ( path . join ( os . tmpdir ( ) , "ade-code-test" , "ade.sock" ) , {
447+ ADE_DISABLE_RUNTIME_SERVICE_INSTALL : "1" ,
448+ } ) ) . toBe ( false ) ;
449+ } ) ;
450+
348451 it ( "parses runtime idle expiry with a minimum clamp" , ( ) => {
349452 expect ( readRuntimeIdleExitMs ( { ADE_RUNTIME_IDLE_EXIT_MS : "30000" } as NodeJS . ProcessEnv ) ) . toBe ( 30_000 ) ;
350453 expect ( readRuntimeIdleExitMs ( { ADE_RUNTIME_IDLE_EXIT_MS : "100" } as NodeJS . ProcessEnv ) ) . toBe ( 5_000 ) ;
@@ -4229,8 +4332,10 @@ describe("ADE CLI", () => {
42294332 it ( "formats preview-match and preview-ensure text as Preview Lab output" , ( ) => {
42304333 const matchPlan = expectExecutePlan ( buildCliPlan ( [ "ios-sim" , "preview-match" , "--source" , "Views/HomeView.swift" ] ) ) ;
42314334 const ensurePlan = expectExecutePlan ( buildCliPlan ( [ "ios-sim" , "preview-ensure" ] ) ) ;
4335+ const currentPlan = expectExecutePlan ( buildCliPlan ( [ "ios-sim" , "preview-current" ] ) ) ;
42324336 expect ( inferFormatter ( matchPlan ) ) . toBe ( "ios-sim-preview" ) ;
42334337 expect ( inferFormatter ( ensurePlan ) ) . toBe ( "ios-sim-preview" ) ;
4338+ expect ( inferFormatter ( currentPlan ) ) . toBe ( "ios-sim-preview" ) ;
42344339
42354340 const output = formatOutput ( {
42364341 status : "missing-preview" ,
@@ -4249,6 +4354,62 @@ describe("ADE CLI", () => {
42494354 expect ( output ) . toContain ( "ADE iOS Preview match" ) ;
42504355 expect ( output ) . toMatch ( / s t a t u s \s + m i s s i n g - p r e v i e w / ) ;
42514356 expect ( output ) . toMatch ( / s u g g e s t e d f i l e \s + a p p s \/ i o s \/ A D E \/ V i e w s \/ H o m e P r e v i e w s \. s w i f t / ) ;
4357+
4358+ const currentOutput = formatOutput ( {
4359+ ok : false ,
4360+ match : {
4361+ status : "no-context" ,
4362+ confidence : "none" ,
4363+ target : null ,
4364+ selectedSourceFile : null ,
4365+ selectedSourceLine : null ,
4366+ reason : "Select a source-backed simulator element first." ,
4367+ } ,
4368+ target : null ,
4369+ render : null ,
4370+ error : "Select a source-backed simulator element first." ,
4371+ } , {
4372+ text : true ,
4373+ pretty : false ,
4374+ } as any , "ios-sim-preview" ) ;
4375+ expect ( currentOutput ) . toContain ( "ADE iOS Preview current" ) ;
4376+ expect ( currentOutput ) . toMatch ( / s t a t u s \s + n o - c o n t e x t / ) ;
4377+ } ) ;
4378+
4379+ it ( "ios-sim preview-current renders the currently selected simulator preview" , ( ) => {
4380+ const plan = expectExecutePlan ( buildCliPlan ( [
4381+ "ios-sim" ,
4382+ "preview-current" ,
4383+ "--source" ,
4384+ "Views/HomeView.swift" ,
4385+ "--line" ,
4386+ "44" ,
4387+ "--label" ,
4388+ "Settings" ,
4389+ "--component-id" ,
4390+ "settings-row" ,
4391+ "--tab" ,
4392+ "tab-1" ,
4393+ "--timeout" ,
4394+ "30" ,
4395+ "--project-root" ,
4396+ "/tmp/app" ,
4397+ ] ) ) ;
4398+ expect ( plan . steps [ 0 ] ?. params ) . toMatchObject ( {
4399+ arguments : {
4400+ domain : "ios_simulator" ,
4401+ action : "renderCurrentPreview" ,
4402+ args : {
4403+ projectRoot : "/tmp/app" ,
4404+ sourceFile : "Views/HomeView.swift" ,
4405+ sourceLine : 44 ,
4406+ elementLabel : "Settings" ,
4407+ componentId : "settings-row" ,
4408+ tabIdentifier : "tab-1" ,
4409+ timeoutSec : 30 ,
4410+ } ,
4411+ } ,
4412+ } ) ;
42524413 } ) ;
42534414
42544415 it ( "ios-sim preview-render requires a source file and forwards render options" , ( ) => {
0 commit comments