@@ -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,178 @@ 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+
75254test ( 'batch steps cannot bypass reject lock policy on nested direct requests' , async ( ) => {
76255 const sessionStore = makeSessionStore ( 'agent-device-router-lock-' ) ;
77256 sessionStore . set ( 'qa-ios' , makeIosSession ( 'qa-ios' ) ) ;
0 commit comments