|
| 1 | +import assert from 'node:assert/strict'; |
| 2 | +import { test } from 'vitest'; |
| 3 | +import type { DaemonWireRequest } from '@agent-device/contracts/command'; |
| 4 | +import { PRE_DISPATCH_REFUSAL_REASONS, normalizeError } from '@agent-device/kernel/errors'; |
| 5 | +import type { SessionState } from './session-state.ts'; |
| 6 | +import { |
| 7 | + namedSessionNotFoundError, |
| 8 | + requireNamedSessionHoldsDevice, |
| 9 | +} from './explicit-session-resolution.ts'; |
| 10 | + |
| 11 | +function request(overrides: Partial<DaemonWireRequest> = {}): DaemonWireRequest { |
| 12 | + return { |
| 13 | + token: 't', |
| 14 | + session: 'e2e-ios-0', |
| 15 | + command: 'snapshot', |
| 16 | + positionals: [], |
| 17 | + ...overrides, |
| 18 | + }; |
| 19 | +} |
| 20 | + |
| 21 | +function heldSession(address = 'e2e-ios-0'): SessionState { |
| 22 | + return { |
| 23 | + name: address, |
| 24 | + device: { platform: 'android', id: 'emulator-5554', name: 'Pixel 9', kind: 'emulator' }, |
| 25 | + createdAt: 0, |
| 26 | + actions: [], |
| 27 | + }; |
| 28 | +} |
| 29 | + |
| 30 | +function normalizeRefusal(error: unknown) { |
| 31 | + assert.ok(error instanceof Error); |
| 32 | + return normalizeError(error); |
| 33 | +} |
| 34 | + |
| 35 | +test('a named session the daemon does not hold is refused on the session, not the device', () => { |
| 36 | + assert.throws( |
| 37 | + () => |
| 38 | + requireNamedSessionHoldsDevice({ |
| 39 | + req: request({ meta: { sessionExplicit: true } }), |
| 40 | + sessionAddress: 'e2e-ios-0', |
| 41 | + session: undefined, |
| 42 | + }), |
| 43 | + (error: unknown) => { |
| 44 | + const refusal = normalizeRefusal(error); |
| 45 | + assert.equal(refusal.code, 'SESSION_NOT_FOUND'); |
| 46 | + assert.equal( |
| 47 | + refusal.message, |
| 48 | + 'No active session "e2e-ios-0". Run agent-device open <app> --session e2e-ios-0 first, ' + |
| 49 | + 'or omit --session to run against a device selector.', |
| 50 | + ); |
| 51 | + assert.equal(refusal.details?.reason, PRE_DISPATCH_REFUSAL_REASONS.namedSessionNotFound); |
| 52 | + assert.equal(refusal.details?.dispatched, 'no'); |
| 53 | + assert.equal(refusal.details?.session, 'e2e-ios-0'); |
| 54 | + assert.match(String(refusal.hint), /agent-device session list/); |
| 55 | + return true; |
| 56 | + }, |
| 57 | + ); |
| 58 | +}); |
| 59 | + |
| 60 | +test('the session flag alone names a session as firmly as the client meta does', () => { |
| 61 | + // The predicate itself is pinned in @agent-device/kernel/contracts; what the guard owes is |
| 62 | + // that BOTH carriers reach the refusal. |
| 63 | + const fromMeta = request({ meta: { sessionExplicit: true } }); |
| 64 | + const fromFlag = request({ flags: { session: 'e2e-ios-0' } }); |
| 65 | + for (const req of [fromMeta, fromFlag]) { |
| 66 | + assert.throws( |
| 67 | + () => |
| 68 | + requireNamedSessionHoldsDevice({ req, sessionAddress: 'e2e-ios-0', session: undefined }), |
| 69 | + { code: 'SESSION_NOT_FOUND' }, |
| 70 | + ); |
| 71 | + } |
| 72 | +}); |
| 73 | + |
| 74 | +test('a request that never named a session keeps its sessionless device resolution', () => { |
| 75 | + requireNamedSessionHoldsDevice({ |
| 76 | + req: request({ session: 'cwd:1a2b3c4d5e6f7a8b:ios' }), |
| 77 | + sessionAddress: 'cwd:1a2b3c4d5e6f7a8b:ios', |
| 78 | + session: undefined, |
| 79 | + }); |
| 80 | + requireNamedSessionHoldsDevice({ |
| 81 | + req: request({ session: 'default', flags: {} }), |
| 82 | + sessionAddress: 'default', |
| 83 | + session: undefined, |
| 84 | + }); |
| 85 | +}); |
| 86 | + |
| 87 | +test('a session the daemon holds never reaches the refusal', () => { |
| 88 | + requireNamedSessionHoldsDevice({ |
| 89 | + req: request({ meta: { sessionExplicit: true } }), |
| 90 | + sessionAddress: 'e2e-ios-0', |
| 91 | + session: heldSession(), |
| 92 | + }); |
| 93 | +}); |
| 94 | + |
| 95 | +test('a named session beside a device selector keeps the target the caller stated', () => { |
| 96 | + for (const flags of [ |
| 97 | + { udid: 'SIM-1' }, |
| 98 | + { serial: 'emulator-5554' }, |
| 99 | + { device: 'iPhone 17' }, |
| 100 | + { platform: 'ios' as const }, |
| 101 | + { target: 'tv' as const }, |
| 102 | + ]) { |
| 103 | + requireNamedSessionHoldsDevice({ |
| 104 | + req: request({ flags, meta: { sessionExplicit: true } }), |
| 105 | + sessionAddress: 'e2e-ios-0', |
| 106 | + session: undefined, |
| 107 | + }); |
| 108 | + } |
| 109 | +}); |
| 110 | + |
| 111 | +test('the recovery command echoes the address back, quoted only where the shell needs it', () => { |
| 112 | + // A scoped store key is already a safe shell word, so the hint hands back the exact address |
| 113 | + // `session list` printed; a name with a space has to arrive quoted to run as one argument. |
| 114 | + const scoped = normalizeRefusal(namedSessionNotFoundError('cwd:1a2b3c4d5e6f7a8b:ios')); |
| 115 | + assert.match(String(scoped.message), /--session cwd:1a2b3c4d5e6f7a8b:ios first/); |
| 116 | + assert.equal(scoped.details?.session, 'cwd:1a2b3c4d5e6f7a8b:ios'); |
| 117 | + const spaced = normalizeRefusal(namedSessionNotFoundError('my session')); |
| 118 | + assert.match(String(spaced.message), /--session 'my session' first/); |
| 119 | +}); |
0 commit comments