diff --git a/src/adapter-a.ts b/src/adapter-a.ts index 5bd1061..000b805 100644 --- a/src/adapter-a.ts +++ b/src/adapter-a.ts @@ -23,7 +23,7 @@ export interface ADevice { export interface AClient { devices: { list(options?: { udid?: string }): Promise; - boot(options?: { udid?: string }): Promise; + boot(options?: { udid?: string; headless?: boolean }): Promise; shutdown(options?: { udid?: string }): Promise; }; apps: { @@ -48,6 +48,8 @@ export interface AClient { press(options: { target: ATarget; udid?: string; settle?: boolean }): Promise; longPress(options: { target: ATarget; udid?: string; durationMs?: number }): Promise; fill(options: { target: ATarget; text: string; udid?: string; settle?: boolean }): Promise; + /** Hide the on-screen keyboard (engine-backed, device-independent). */ + keyboardDismiss(options: { udid?: string }): Promise; scroll(options: { direction: string; udid?: string; settle?: boolean }): Promise; find(options: { query: string; action?: string; value?: string; first?: boolean; udid?: string }): Promise; }; @@ -70,18 +72,30 @@ export interface AClient { }; } -let cached: { key: string; client: AClient } | null = null; +/** One client per (engine, session) — the engine binds a session NAME to one + * device; a shared 'alloy' session made any cross-device sequence collide + * ("is owned by session alloy"). Per-device sessions (`alloy-`) give + * each device its own session + claim; DEVICE-SCOPED tools pass d.udid, + * device-agnostic ones (list/health) use the bare 'alloy' default. */ +const clients = new Map(); -export async function loadEngineAClient(resolved: ResolvedEngines['engineA']): Promise { - if (cached && cached.key === resolved.importUrl) return cached.client; +export function sessionForUdid(udid: string | undefined): string { + if (!udid) return 'alloy'; + return `alloy-${udid.replace(/-/g, '').slice(0, 8).toLowerCase()}`; +} + +export async function loadEngineAClient(resolved: ResolvedEngines['engineA'], session = 'alloy'): Promise { + const key = `${resolved.importUrl}::${session}`; + const cached = clients.get(key); + if (cached) return cached; const mod = (await import(resolved.importUrl)) as Record; const factoryName = resolved.clientFactory ?? 'createClient'; const factory = mod[factoryName]; if (typeof factory !== 'function') { throw new AlloyError('ENGINE_UNAVAILABLE', 'engine A client factory missing at configured entry', { engine: 'A' }); } - const client = (factory as (config?: { session?: string }) => AClient)({ session: 'alloy' }); - cached = { key: resolved.importUrl, client }; + const client = (factory as (config?: { session?: string }) => AClient)({ session }); + clients.set(key, client); return client; } @@ -127,12 +141,12 @@ export function toSettingsOptions( } export function resetEngineACacheForTests(): void { - cached = null; + clients.clear(); } /** Production seam (see resetEngineBCache): drop the cached engine-A client so * the next loadEngineAClient() re-creates it — same stale-handle recovery class * as engine B. */ export function resetEngineACache(): void { - cached = null; + clients.clear(); } diff --git a/src/registrations.ts b/src/registrations.ts index 2442577..33a38df 100644 --- a/src/registrations.ts +++ b/src/registrations.ts @@ -8,6 +8,7 @@ import { buildHealthReport, probeImports, type HealthReport } from './health.ts' import type { ServerState } from './server.ts'; import { loadEngineAClient, + sessionForUdid, toTarget, toFindQuery, toAlertAction, @@ -74,12 +75,14 @@ export function registerPhase0Tools(deps: StubDeps): void { // ── engine A surface (Phase 1) ─────────────────────────────────────────── defineToolFromRow(mustRow('alloy_devices'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); - const d = input as { action: 'list' | 'boot' | 'shutdown'; udid?: string }; + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); + const d = input as { action: 'list' | 'boot' | 'shutdown'; udid?: string; headless?: boolean }; if (d.action === 'list') return client.devices.list(); if (d.action === 'boot') { - const o: { udid?: string } = {}; + const o: { udid?: string; headless?: boolean } = {}; opt(o, 'udid', d.udid); + opt(o, 'headless', d.headless); return client.devices.boot(o); } const o: { udid?: string } = {}; @@ -88,7 +91,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_apps'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { action: 'open' | 'install' | 'reinstall' | 'list'; app?: string; @@ -140,7 +144,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_release'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string }; // Release engine session (SESSION_NOT_FOUND means nothing to close — not an error) try { @@ -156,7 +161,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_snapshot'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; interactiveOnly: boolean }; return client.capture.snapshot({ udid: d.udid, interactiveOnly: d.interactiveOnly }); }); @@ -168,7 +174,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_screenshot'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; outPath?: string }; const o: { udid?: string; outPath?: string } = {}; opt(o, 'udid', d.udid); @@ -177,15 +184,17 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_act'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; - action: 'press' | 'fill' | 'scroll' | 'longpress'; + action: 'press' | 'fill' | 'scroll' | 'longpress' | 'keyboardDismiss'; target?: string; text?: string; direction?: 'up' | 'down' | 'left' | 'right'; settle: boolean; }; + if (d.action === 'keyboardDismiss') return client.interactions.keyboardDismiss({ udid: d.udid }); if (d.action === 'scroll') { return client.interactions.scroll({ direction: d.direction!, udid: d.udid, settle: d.settle }); } @@ -200,7 +209,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_find'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; by: 'text' | 'label' | 'role' | 'id'; value: string; action: 'none' | 'tap' }; const o: Record = { query: toFindQuery(d.by, d.value), @@ -212,13 +222,15 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_alert'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; action: 'inspect' | 'accept' | 'dismiss' }; return client.command.alert({ action: toAlertAction(d.action), udid: d.udid }); }); defineToolFromRow(mustRow('alloy_settings'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; area: string; values?: Record }; return client.settings.update(toSettingsOptions(d.area, d.values, d.udid)); }); @@ -232,7 +244,13 @@ export function registerPhase0Tools(deps: StubDeps): void { return callBTool(client, 'describe', { udid: d.udid }); }); - defineToolFromRow(mustRow('alloy_native_tree'), async (input) => { + defineToolFromRow(mustRow('alloy_restart_app'), async (input) => { + const client = await loadEngineBClient(requireEngineB(deps).engineB); + const d = input as { udid: string; bundleId: string }; + return callBTool(client, 'restart-app', { udid: d.udid, bundleId: d.bundleId }); + }); + +defineToolFromRow(mustRow('alloy_native_tree'), async (input) => { const client = await loadEngineBClient(requireEngineB(deps).engineB); const d = input as { udid: string; @@ -241,6 +259,9 @@ export function registerPhase0Tools(deps: StubDeps): void { id?: string; x?: number; y?: number; + /** interactable-at: engine B requires the app's bundle id (precheck + * gate + view-space mapping). find-views ignores it. */ + bundleId?: string; }; if (d.query === 'hierarchy') { return callBTool(client, 'native-full-hierarchy', { udid: d.udid }); @@ -252,6 +273,7 @@ export function registerPhase0Tools(deps: StubDeps): void { } return callBTool(client, 'native-user-interactable-view-at-point', { udid: d.udid, + bundleId: d.bundleId!, x: d.x!, y: d.y!, }); @@ -319,7 +341,8 @@ export function registerPhase0Tools(deps: StubDeps): void { // ── Phase 3: diagnostics (engine A) + js runtime (engine B) ────────────── defineToolFromRow(mustRow('alloy_network'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; limit: number; include?: 'none' | 'headers' | 'bodies' | 'all' }; const o: Record = { action: 'dump', limit: d.limit, udid: d.udid }; if (d.include !== undefined) o['include'] = d.include; @@ -327,7 +350,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_logs'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; action: 'mark' | 'capture' | 'start' | 'stop' | 'clear'; @@ -341,13 +365,15 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_perf'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; area: 'frames' | 'memory' | 'cpu' | 'trace'; action: string }; return client.observability.perf({ area: d.area, action: d.action, udid: d.udid }); }); defineToolFromRow(mustRow('alloy_push'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; app: string; payload: Record }; return client.apps.push({ app: d.app, payload: d.payload, udid: d.udid }); }); @@ -373,7 +399,8 @@ export function registerPhase0Tools(deps: StubDeps): void { }); defineToolFromRow(mustRow('alloy_replay'), async (input) => { - const client = await loadEngineAClient(requireEngineA(deps).engineA); + const d0 = input as { udid?: string }; + const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid)); const d = input as { udid: string; scriptPath: string; timeoutMs?: number }; const o: Record = { path: d.scriptPath, udid: d.udid }; if (d.timeoutMs !== undefined) o['timeoutMs'] = d.timeoutMs; diff --git a/src/routing.ts b/src/routing.ts index 112506b..843923b 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -77,6 +77,7 @@ export const routingTable: readonly RoutingRow[] = [ .object({ action: z.enum(['list', 'boot', 'shutdown']).default('list'), udid: deviceRef.optional(), + headless: z.boolean().optional(), }) .strict() .superRefine((v, ctx) => { @@ -92,6 +93,13 @@ export const routingTable: readonly RoutingRow[] = [ summary: 'Release a held device lease and end the engine session on it', schema: z.object({ udid: deviceRef }).strict(), }, + { + tool: 'alloy_restart_app', + engine: 'B', + lease: 'per-call', + summary: 'Restart app with native-devtools dylib injected (engine B restart-app)', + schema: z.object({ udid: deviceRef, bundleId: z.string().min(1) }).strict(), + }, { tool: 'alloy_snapshot', engine: 'A', @@ -122,11 +130,14 @@ export const routingTable: readonly RoutingRow[] = [ tool: 'alloy_act', engine: 'A', lease: 'per-call', + // Lionidas mirror: server/src/manual/alloy-act-contract.test.ts pins this + // verb set (mirror + live-schema tiers). Changing actions/fields here? + // Update that mirror in the same change. summary: 'Press, click, fill, scroll, or long-press with post-action settle diff', schema: z .object({ udid: deviceRef, - action: z.enum(['press', 'fill', 'scroll', 'longpress']), + action: z.enum(['press', 'fill', 'scroll', 'longpress', 'keyboardDismiss']), target: z .union([ z.string().min(1), @@ -146,8 +157,8 @@ export const routingTable: readonly RoutingRow[] = [ if (v.action === 'scroll' && !v.direction) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'direction is required for scroll' }); } - if (v.action !== 'scroll' && !v.target) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'target is required unless action=scroll' }); + if (v.action !== 'scroll' && v.action !== 'keyboardDismiss' && !v.target) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'target is required unless action=scroll or keyboardDismiss' }); } }), }, @@ -210,9 +221,14 @@ export const routingTable: readonly RoutingRow[] = [ id: z.string().min(1).optional(), x: z.number().finite().optional(), y: z.number().finite().optional(), + bundleId: z.string().min(1).optional(), }) .strict() .superRefine((v, ctx) => { + if (v.query === 'interactable-at') { + if (v.x === undefined || v.y === undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'x and y required for interactable-at' }); + if (!v.bundleId) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'bundleId required for interactable-at' }); + } if (v.query === 'find-views' && !v.className && !v.id) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'className or id required for find-views' }); } diff --git a/test/contract.helpers.ts b/test/contract.helpers.ts index 9406237..4cf87ac 100644 --- a/test/contract.helpers.ts +++ b/test/contract.helpers.ts @@ -85,7 +85,7 @@ export function fakeEngineAClient() { export function makeFakeResolvedEngines(): ResolvedEngines { const dir = mkdtempSync(join(tmpdir(), 'alloy-fake-')); mkdirSync(join(dir, 'node_modules')); - writeFileSync(join(dir, 'entry-a.mjs'), `export function createClient() { return globalThis.__alloyFakeA; }\n`); + writeFileSync(join(dir, 'entry-a.mjs'), `export function createClient(cfg) { (globalThis.__alloySessions ??= []).push(cfg?.session); return globalThis.__alloyFakeA; }\n`); writeFileSync(join(dir, 'entry-b.mjs'), `export function createClient() { return globalThis.__alloyFakeB; }\n`); writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fake-engine', version: '1.0.0' })); return { diff --git a/test/contract.test.ts b/test/contract.test.ts index 903a03e..bc26156 100644 --- a/test/contract.test.ts +++ b/test/contract.test.ts @@ -40,6 +40,7 @@ const VALID_INPUTS: Record = { alloy_devices: { action: 'list' }, alloy_apps: { action: 'list' }, alloy_release: { udid: 'SIM-1' }, + alloy_restart_app: { udid: 'SIM-1', bundleId: 'com.example.app' }, alloy_snapshot: { udid: 'SIM-1' }, alloy_stream: { udid: 'SIM-1' }, alloy_screenshot: { udid: 'SIM-1' }, @@ -126,6 +127,17 @@ describe('routing table contract (generated)', () => { expect(open.foreground).toBe(true); }); + it('per-device engine sessions: two udid dispatches create two named sessions', async () => { + const rt = freshRuntime(); + (globalThis as Record)['__alloySessions'] = []; + await dispatch('alloy_apps', { action: 'open', app: 'com.example.app', udid: 'SIM-AAAAAAAA-1' }, rt.deps); + await dispatch('alloy_apps', { action: 'open', app: 'com.example.app', udid: 'SIM-BBBBBBBB-2' }, rt.deps); + const sessions = (globalThis as Record)['__alloySessions'] as string[]; + expect(sessions).toContain('alloy-simaaaaa'); + expect(sessions).toContain('alloy-simbbbbb'); + expect(new Set(sessions).size).toBe(2); + }); + it('open acquires held lease; release clears it and closes engine session', async () => { const rt = freshRuntime(); await dispatch( diff --git a/test/devices-headless.test.ts b/test/devices-headless.test.ts new file mode 100644 index 0000000..633c5ff --- /dev/null +++ b/test/devices-headless.test.ts @@ -0,0 +1,41 @@ +/** alloy_devices boot input threading: headless reaches the engine-A client. */ +import { describe, expect, it } from 'vitest'; +import { clearRegistryForTests, dispatch } from '../src/tools.ts'; +import { registerPhase0Tools } from '../src/registrations.ts'; +import { createServerState } from '../src/server.ts'; +import { LeaseManager } from '../src/lease.ts'; +import { + calls, + installFakeEngineAClient, + makeFakeResolvedEngines, +} from './contract.helpers.ts'; + +function freshDeps() { + clearRegistryForTests(); + installFakeEngineAClient(); + const state = createServerState(); + state.resolved = makeFakeResolvedEngines(); + registerPhase0Tools({ state }); + return { state, deps: { leases: new LeaseManager(), adapters: {} } }; +} + +describe('alloy_devices headless boot', () => { + it('boot with headless forwards the option to the engine client', async () => { + const { deps } = freshDeps(); + calls.length = 0; + const r = await dispatch('alloy_devices', { action: 'boot', udid: 'SIM-1', headless: true }, deps); + expect(r.ok).toBe(true); + const bootCall = calls.find((c) => c.ns === 'devices' && c.method === 'boot'); + expect(bootCall).toBeTruthy(); + expect(bootCall?.args).toEqual({ udid: 'SIM-1', headless: true }); + }); + + it('boot without headless forwards only the udid (default GUI behavior)', async () => { + const { deps } = freshDeps(); + calls.length = 0; + const r = await dispatch('alloy_devices', { action: 'boot', udid: 'SIM-1' }, deps); + expect(r.ok).toBe(true); + const bootCall = calls.find((c) => c.ns === 'devices' && c.method === 'boot'); + expect(bootCall?.args).toEqual({ udid: 'SIM-1' }); + }); +});