@@ -7,6 +7,13 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => {
77 return { ...actual , dispatchCommand : vi . fn ( async ( ) => ( { } ) ) } ;
88} ) ;
99
10+ vi . mock ( '../../platforms/ios/runner-client.ts' , async ( importOriginal ) => {
11+ const actual = await importOriginal < typeof import ( '../../platforms/ios/runner-client.ts' ) > ( ) ;
12+ return { ...actual , stopIosRunnerSession : vi . fn ( async ( ) => { } ) } ;
13+ } ) ;
14+
15+ vi . mock ( '../device-ready.ts' , ( ) => ( { ensureDeviceReady : vi . fn ( async ( ) => { } ) } ) ) ;
16+
1017import { dispatchCommand } from '../../core/dispatch.ts' ;
1118import { createRequestHandler } from '../request-router.ts' ;
1219import type { SessionState } from '../types.ts' ;
@@ -34,7 +41,7 @@ function makeIosSession(name: string): SessionState {
3441
3542beforeEach ( ( ) => {
3643 mockDispatch . mockReset ( ) ;
37- mockDispatch . mockResolvedValue ( { } ) ;
44+ mockDispatch . mockResolvedValue ( { nodes : [ ] } ) ;
3845} ) ;
3946
4047test ( 'direct daemon requests cannot bypass reject lock policy for existing sessions' , async ( ) => {
@@ -72,6 +79,240 @@ test('direct daemon requests cannot bypass reject lock policy for existing sessi
7279 }
7380} ) ;
7481
82+ test ( 'fresh named sessions with matching explicit udid bind and serialize on the selected device' , async ( ) => {
83+ const sessionStore = makeSessionStore ( 'agent-device-router-lock-' ) ;
84+ const order : string [ ] = [ ] ;
85+ const gates : Array < ( ) => void > = [ ] ;
86+ let active = 0 ;
87+ let maxActive = 0 ;
88+
89+ mockDispatch . mockImplementation ( async ( device , command ) => {
90+ order . push ( `start-${ command } -${ device . id } ` ) ;
91+ active += 1 ;
92+ maxActive = Math . max ( maxActive , active ) ;
93+ await new Promise < void > ( ( resolve ) => {
94+ gates . push ( ( ) => {
95+ active -= 1 ;
96+ order . push ( `end-${ command } -${ device . id } ` ) ;
97+ resolve ( ) ;
98+ } ) ;
99+ } ) ;
100+ return { nodes : [ ] } ;
101+ } ) ;
102+
103+ const handler = createRequestHandler ( {
104+ logPath : path . join ( os . tmpdir ( ) , 'daemon.log' ) ,
105+ token : 'test-token' ,
106+ sessionStore,
107+ leaseRegistry : new LeaseRegistry ( ) ,
108+ deviceInventoryProvider : async ( ) => [ makeIosSession ( 'inventory' ) . device ] ,
109+ trackDownloadableArtifact : ( ) => 'artifact-id' ,
110+ } ) ;
111+
112+ const first = handler ( {
113+ token : 'test-token' ,
114+ session : 'qa-ios-a' ,
115+ command : 'snapshot' ,
116+ positionals : [ ] ,
117+ flags : {
118+ udid : 'SIM-001' ,
119+ } ,
120+ meta : {
121+ requestId : 'req-fresh-lock-a' ,
122+ lockPolicy : 'reject' ,
123+ lockPlatform : 'ios' ,
124+ } ,
125+ } ) ;
126+
127+ await vi . waitFor ( ( ) => {
128+ expect ( order ) . toEqual ( [ 'start-snapshot-SIM-001' ] ) ;
129+ } ) ;
130+
131+ const second = handler ( {
132+ token : 'test-token' ,
133+ session : 'qa-ios-b' ,
134+ command : 'snapshot' ,
135+ positionals : [ ] ,
136+ flags : {
137+ udid : 'SIM-001' ,
138+ } ,
139+ meta : {
140+ requestId : 'req-fresh-lock-b' ,
141+ lockPolicy : 'reject' ,
142+ lockPlatform : 'ios' ,
143+ } ,
144+ } ) ;
145+
146+ await new Promise ( ( resolve ) => setTimeout ( resolve , 20 ) ) ;
147+ expect ( order ) . toEqual ( [ 'start-snapshot-SIM-001' ] ) ;
148+
149+ gates . shift ( ) ?.( ) ;
150+
151+ await vi . waitFor ( ( ) => {
152+ expect ( order ) . toEqual ( [
153+ 'start-snapshot-SIM-001' ,
154+ 'end-snapshot-SIM-001' ,
155+ 'start-snapshot-SIM-001' ,
156+ ] ) ;
157+ } ) ;
158+
159+ gates . shift ( ) ?.( ) ;
160+
161+ const [ firstResponse , secondResponse ] = await Promise . all ( [ first , second ] ) ;
162+
163+ expect ( firstResponse . ok ) . toBe ( true ) ;
164+ expect ( secondResponse . ok ) . toBe ( true ) ;
165+ expect ( maxActive ) . toBe ( 1 ) ;
166+ expect ( sessionStore . get ( 'qa-ios-a' ) ?. device . id ) . toBe ( 'SIM-001' ) ;
167+ expect ( sessionStore . get ( 'qa-ios-b' ) ?. device . id ) . toBe ( 'SIM-001' ) ;
168+ } ) ;
169+
170+ test ( 'fresh named sessions with only lock platform default serialize on the selected device' , async ( ) => {
171+ const sessionStore = makeSessionStore ( 'agent-device-router-lock-' ) ;
172+ const order : string [ ] = [ ] ;
173+ const gates : Array < ( ) => void > = [ ] ;
174+ let active = 0 ;
175+ let maxActive = 0 ;
176+
177+ mockDispatch . mockImplementation ( async ( device , command ) => {
178+ order . push ( `start-${ command } -${ device . id } ` ) ;
179+ active += 1 ;
180+ maxActive = Math . max ( maxActive , active ) ;
181+ await new Promise < void > ( ( resolve ) => {
182+ gates . push ( ( ) => {
183+ active -= 1 ;
184+ order . push ( `end-${ command } -${ device . id } ` ) ;
185+ resolve ( ) ;
186+ } ) ;
187+ } ) ;
188+ return { nodes : [ ] } ;
189+ } ) ;
190+
191+ const handler = createRequestHandler ( {
192+ logPath : path . join ( os . tmpdir ( ) , 'daemon.log' ) ,
193+ token : 'test-token' ,
194+ sessionStore,
195+ leaseRegistry : new LeaseRegistry ( ) ,
196+ deviceInventoryProvider : async ( ) => [ makeIosSession ( 'inventory' ) . device ] ,
197+ trackDownloadableArtifact : ( ) => 'artifact-id' ,
198+ } ) ;
199+
200+ const first = handler ( {
201+ token : 'test-token' ,
202+ session : 'qa-default-a' ,
203+ command : 'snapshot' ,
204+ positionals : [ ] ,
205+ flags : { } ,
206+ meta : {
207+ requestId : 'req-fresh-default-lock-a' ,
208+ lockPolicy : 'reject' ,
209+ lockPlatform : 'ios' ,
210+ } ,
211+ } ) ;
212+
213+ await vi . waitFor ( ( ) => {
214+ expect ( order ) . toEqual ( [ 'start-snapshot-SIM-001' ] ) ;
215+ } ) ;
216+
217+ const second = handler ( {
218+ token : 'test-token' ,
219+ session : 'qa-default-b' ,
220+ command : 'snapshot' ,
221+ positionals : [ ] ,
222+ flags : { } ,
223+ meta : {
224+ requestId : 'req-fresh-default-lock-b' ,
225+ lockPolicy : 'reject' ,
226+ lockPlatform : 'ios' ,
227+ } ,
228+ } ) ;
229+
230+ await new Promise ( ( resolve ) => setTimeout ( resolve , 20 ) ) ;
231+ expect ( order ) . toEqual ( [ 'start-snapshot-SIM-001' ] ) ;
232+
233+ gates . shift ( ) ?.( ) ;
234+
235+ await vi . waitFor ( ( ) => {
236+ expect ( order ) . toEqual ( [
237+ 'start-snapshot-SIM-001' ,
238+ 'end-snapshot-SIM-001' ,
239+ 'start-snapshot-SIM-001' ,
240+ ] ) ;
241+ } ) ;
242+
243+ gates . shift ( ) ?.( ) ;
244+
245+ const [ firstResponse , secondResponse ] = await Promise . all ( [ first , second ] ) ;
246+
247+ expect ( firstResponse . ok ) . toBe ( true ) ;
248+ expect ( secondResponse . ok ) . toBe ( true ) ;
249+ expect ( maxActive ) . toBe ( 1 ) ;
250+ expect ( sessionStore . get ( 'qa-default-a' ) ?. device . id ) . toBe ( 'SIM-001' ) ;
251+ expect ( sessionStore . get ( 'qa-default-b' ) ?. device . id ) . toBe ( 'SIM-001' ) ;
252+ } ) ;
253+
254+ test ( 'fresh named sessions reject incompatible selector combinations before binding' , async ( ) => {
255+ const cases = [
256+ {
257+ name : 'ios-serial' ,
258+ flags : { serial : 'emulator-5554' } ,
259+ meta : { lockPolicy : 'reject' , lockPlatform : 'ios' } ,
260+ conflict : / - - s e r i a l = e m u l a t o r - 5 5 5 4 / i,
261+ } ,
262+ {
263+ name : 'ios-android-platform' ,
264+ flags : { platform : 'android' , udid : 'SIM-001' } ,
265+ meta : { lockPolicy : 'reject' , lockPlatform : 'ios' } ,
266+ conflict : / - - p l a t f o r m = a n d r o i d / i,
267+ } ,
268+ {
269+ name : 'ios-desktop-target' ,
270+ flags : { target : 'desktop' } ,
271+ meta : { lockPolicy : 'reject' , lockPlatform : 'ios' } ,
272+ conflict : / - - t a r g e t = d e s k t o p / i,
273+ } ,
274+ {
275+ name : 'macos-udid' ,
276+ flags : { udid : 'SIM-001' , iosSimulatorDeviceSet : '/tmp/tenant-a/set' } ,
277+ meta : { lockPolicy : 'reject' , lockPlatform : 'macos' } ,
278+ conflict : / - - u d i d = S I M - 0 0 1 / i,
279+ } ,
280+ ] as const ;
281+
282+ for ( const testCase of cases ) {
283+ const sessionStore = makeSessionStore ( 'agent-device-router-lock-' ) ;
284+ const handler = createRequestHandler ( {
285+ logPath : path . join ( os . tmpdir ( ) , 'daemon.log' ) ,
286+ token : 'test-token' ,
287+ sessionStore,
288+ leaseRegistry : new LeaseRegistry ( ) ,
289+ deviceInventoryProvider : async ( ) => [ makeIosSession ( 'inventory' ) . device ] ,
290+ trackDownloadableArtifact : ( ) => 'artifact-id' ,
291+ } ) ;
292+
293+ const response = await handler ( {
294+ token : 'test-token' ,
295+ session : testCase . name ,
296+ command : 'snapshot' ,
297+ positionals : [ ] ,
298+ flags : testCase . flags ,
299+ meta : {
300+ requestId : `req-${ testCase . name } ` ,
301+ ...testCase . meta ,
302+ } ,
303+ } ) ;
304+
305+ expect ( response . ok ) . toBe ( false ) ;
306+ if ( ! response . ok ) {
307+ expect ( response . error . code ) . toBe ( 'INVALID_ARGS' ) ;
308+ expect ( response . error . message ) . toMatch ( testCase . conflict ) ;
309+ }
310+ expect ( mockDispatch ) . not . toHaveBeenCalled ( ) ;
311+ expect ( sessionStore . get ( testCase . name ) ) . toBeUndefined ( ) ;
312+ mockDispatch . mockClear ( ) ;
313+ }
314+ } ) ;
315+
75316test ( 'batch steps cannot bypass reject lock policy on nested direct requests' , async ( ) => {
76317 const sessionStore = makeSessionStore ( 'agent-device-router-lock-' ) ;
77318 sessionStore . set ( 'qa-ios' , makeIosSession ( 'qa-ios' ) ) ;
0 commit comments