Skip to content

Commit 987b6f2

Browse files
committed
fix: allow fresh session device binding
1 parent 491ad7e commit 987b6f2

4 files changed

Lines changed: 415 additions & 23 deletions

File tree

src/daemon/__tests__/request-lock-policy.test.ts

Lines changed: 151 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,28 @@ const ANDROID_SESSION: SessionState = {
3232
},
3333
};
3434

35-
test('rejects fresh-session selector conflicts under request lock policy', () => {
35+
test('allows fresh-session selectors under request lock policy', () => {
36+
const req = applyRequestLockPolicy({
37+
token: 'token',
38+
session: 'qa-ios',
39+
command: 'snapshot',
40+
positionals: [],
41+
flags: {
42+
device: 'iPhone 16',
43+
udid: 'SIM-001',
44+
},
45+
meta: {
46+
lockPolicy: 'reject',
47+
lockPlatform: 'ios',
48+
},
49+
});
50+
51+
assert.equal(req.flags?.platform, 'ios');
52+
assert.equal(req.flags?.device, 'iPhone 16');
53+
assert.equal(req.flags?.udid, 'SIM-001');
54+
});
55+
56+
test('rejects fresh-session selector kinds outside the lock platform family', () => {
3657
assert.throws(
3758
() =>
3859
applyRequestLockPolicy({
@@ -41,14 +62,97 @@ test('rejects fresh-session selector conflicts under request lock policy', () =>
4162
command: 'snapshot',
4263
positionals: [],
4364
flags: {
44-
device: 'Pixel 9',
65+
serial: 'emulator-5554',
4566
},
4667
meta: {
4768
lockPolicy: 'reject',
4869
lockPlatform: 'ios',
4970
},
5071
}),
51-
/--device=Pixel 9/i,
72+
/--serial=emulator-5554/i,
73+
);
74+
});
75+
76+
test('rejects fresh-session platform selectors outside the lock platform family', () => {
77+
assert.throws(
78+
() =>
79+
applyRequestLockPolicy({
80+
token: 'token',
81+
session: 'qa-ios',
82+
command: 'snapshot',
83+
positionals: [],
84+
flags: {
85+
platform: 'android',
86+
udid: 'SIM-001',
87+
},
88+
meta: {
89+
lockPolicy: 'reject',
90+
lockPlatform: 'ios',
91+
},
92+
}),
93+
/--platform=android/i,
94+
);
95+
});
96+
97+
test('rejects fresh-session target selectors outside the lock platform target family', () => {
98+
assert.throws(
99+
() =>
100+
applyRequestLockPolicy({
101+
token: 'token',
102+
session: 'qa-ios',
103+
command: 'snapshot',
104+
positionals: [],
105+
flags: {
106+
target: 'desktop',
107+
},
108+
meta: {
109+
lockPolicy: 'reject',
110+
lockPlatform: 'ios',
111+
},
112+
}),
113+
/--target=desktop/i,
114+
);
115+
});
116+
117+
test('rejects fresh macos sessions with ios-only selector flags', () => {
118+
assert.throws(
119+
() =>
120+
applyRequestLockPolicy({
121+
token: 'token',
122+
session: 'qa-macos',
123+
command: 'snapshot',
124+
positionals: [],
125+
flags: {
126+
udid: 'SIM-001',
127+
iosSimulatorDeviceSet: '/tmp/tenant-a/set',
128+
},
129+
meta: {
130+
lockPolicy: 'reject',
131+
lockPlatform: 'macos',
132+
},
133+
}),
134+
/--udid=SIM-001/i,
135+
);
136+
});
137+
138+
test('rejects fresh Apple desktop sessions with ios-only selector flags', () => {
139+
assert.throws(
140+
() =>
141+
applyRequestLockPolicy({
142+
token: 'token',
143+
session: 'qa-desktop',
144+
command: 'snapshot',
145+
positionals: [],
146+
flags: {
147+
target: 'desktop',
148+
udid: 'SIM-001',
149+
},
150+
meta: {
151+
lockPolicy: 'reject',
152+
lockPlatform: 'apple',
153+
},
154+
}),
155+
/--udid=SIM-001/i,
52156
);
53157
});
54158

@@ -74,7 +178,7 @@ test('allows open to choose a fresh-session target under request lock policy', (
74178
assert.equal(req.flags?.udid, 'SIM-001');
75179
});
76180

77-
test('strips fresh-session selector conflicts and restores lock platform', () => {
181+
test('strips only fresh-session selector conflicts and restores lock platform', () => {
78182
const req = applyRequestLockPolicy({
79183
token: 'token',
80184
session: 'qa-ios',
@@ -92,10 +196,52 @@ test('strips fresh-session selector conflicts and restores lock platform', () =>
92196
});
93197

94198
assert.equal(req.flags?.platform, 'ios');
95-
assert.equal(req.flags?.target, undefined);
199+
assert.equal(req.flags?.target, 'tv');
96200
assert.equal(req.flags?.serial, undefined);
97201
});
98202

203+
test('strip policy removes fresh-session target conflicts', () => {
204+
const req = applyRequestLockPolicy({
205+
token: 'token',
206+
session: 'qa-ios',
207+
command: 'snapshot',
208+
positionals: [],
209+
flags: {
210+
target: 'desktop',
211+
udid: 'SIM-001',
212+
},
213+
meta: {
214+
lockPolicy: 'strip',
215+
lockPlatform: 'ios',
216+
},
217+
});
218+
219+
assert.equal(req.flags?.platform, 'ios');
220+
assert.equal(req.flags?.target, undefined);
221+
assert.equal(req.flags?.udid, 'SIM-001');
222+
});
223+
224+
test('strip policy keeps compatible fresh-session selectors', () => {
225+
const req = applyRequestLockPolicy({
226+
token: 'token',
227+
session: 'qa-ios',
228+
command: 'snapshot',
229+
positionals: [],
230+
flags: {
231+
device: 'iPhone 16',
232+
udid: 'SIM-001',
233+
},
234+
meta: {
235+
lockPolicy: 'strip',
236+
lockPlatform: 'ios',
237+
},
238+
});
239+
240+
assert.equal(req.flags?.platform, 'ios');
241+
assert.equal(req.flags?.device, 'iPhone 16');
242+
assert.equal(req.flags?.udid, 'SIM-001');
243+
});
244+
99245
test('rejects existing-session selector conflicts under request lock policy', () => {
100246
assert.throws(
101247
() =>

src/daemon/__tests__/request-router-lock-policy.test.ts

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
1017
import { dispatchCommand } from '../../core/dispatch.ts';
1118
import { createRequestHandler } from '../request-router.ts';
1219
import type { SessionState } from '../types.ts';
@@ -34,7 +41,7 @@ function makeIosSession(name: string): SessionState {
3441

3542
beforeEach(() => {
3643
mockDispatch.mockReset();
37-
mockDispatch.mockResolvedValue({});
44+
mockDispatch.mockResolvedValue({ nodes: [] });
3845
});
3946

4047
test('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+
75254
test('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

Comments
 (0)