mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
46eff36f85
* refactor: migrate focus to the request-bound device runtime Wave 5's first unit (#1739, ADR 0019). `focus x y` and `find <q> focus` now reach the device through one admitted, request-bound `focusPoint` operation instead of the `handleFocusCommand` interactor leaf and its dispatch-table arm. - New `FocusRuntimeOperations` contract with local and provider interactor binders, mirroring the screenshot/element-text seam rather than inventing a second way for one operation class to reach its mechanics. - Exact-owner facts replace the capability bucket: apple simulator/device, android emulator/device/unknown, harmonyos emulator/device, linux device, web device, vega none, providers wherever their interactor is reachable. That is the retired bucket's cell table, restated as facts. - `focus` leaves BASE_COMMAND_CAPABILITY_MATRIX and both hand-maintained overlays (HARMONYOS_SUPPORTED_COMMANDS, WEB_INTERACTION_COMMANDS). - R40 is the new parametrized cutover row; `focusPoint` has exactly one owner. - The `x y` positional parse moves to utils and is shared with the still-legacy touch siblings, so a migrated command cannot drift from them. `find` stays legacy: this unit owns its focus leg only, its `type` leg still dispatches, and R35 waits on the Wave 5 `type` unit. * test(focus): cover the owning interactor binders, lower the find ratchet Review follow-ups on #1925. P1: focus-runtime.test.ts bound a fake focusPoint, so deleting the interactor call inside bindLocalFocusInteractor left focus a successful no-op with every test green. Adds packages/contracts/src/focus-runtime.test.ts, which executes both binders and asserts resolver context, positional (x, y) forwarding, the structured missing-provider failure, and that an already-cancelled request never resolves an interactor at all. Two planted mutants confirm it bites: removing `await interactor.focus(input.point.x, input.point.y)` and transposing its two arguments each fail exactly the two forwarding tests, while the daemon-level focus and find suites stay green — which is the gap the reviewer named. Coverage: find.test.ts shrank to 1204 lines when its focus assertion moved off the dispatch mock; the ratchet pin follows it down. * test(focus): add live Linux focus coverage to the desktop replay The Linux `focus` claim rested on the provider scenario at command-contract level. The desktop replay runs on real Linux hardware in the Smoke lane, so it now runs a coordinate focus and re-asserts the session survived it. Coordinate, not selector: the step exists to prove the migrated `focusPoint` path executes on real hardware, so it must not be able to fail on match ambiguity or CI layout drift. Reclassifies focus contract -> live in the Linux coverage manifest and updates the two pinned counts. The manifest gate is two-sided — a live claim must name a command the replay actually invokes — so the claim cannot drift from the file.
203 lines
7.8 KiB
TypeScript
203 lines
7.8 KiB
TypeScript
import type {
|
|
DeviceBinding,
|
|
CaptureSnapshotInput,
|
|
PlatformRuntimeHost,
|
|
PlatformRuntimeOperations,
|
|
PlatformRuntimeOwner,
|
|
RuntimeFacts,
|
|
RuntimeOperationUnavailability,
|
|
} from '@agent-device/contracts/platform';
|
|
import {
|
|
applicationLifecycleOperationFacts,
|
|
availableApplicationLifecycleOperations,
|
|
bindLocalFocusInteractor,
|
|
bindLocalScreenshotInteractor,
|
|
bindElementTextRuntime,
|
|
captureSnapshotSignal,
|
|
createUnavailablePlatformRuntimeFacts,
|
|
elementTextRuntimeOperationFacts,
|
|
focusRuntimeOperationFacts,
|
|
localRuntimeOwner,
|
|
sameRuntimeOwner,
|
|
screenshotRuntimeOperationFacts,
|
|
snapshotRuntimeOperationFacts,
|
|
} from '@agent-device/contracts/platform';
|
|
import type { DeviceInfo } from '@agent-device/kernel/device';
|
|
import { AppError } from '@agent-device/kernel/errors';
|
|
import { bindLinuxApplicationLifecycle } from './lifecycle.ts';
|
|
|
|
const supported = Object.freeze({ available: true } as const);
|
|
const linuxOwner = localRuntimeOwner('linux');
|
|
const unsupportedPlatformLeaf = unavailableLinuxRuntimeFact('unsupported-platform-leaf');
|
|
const elementTextKindUnavailable = unavailableLinuxRuntimeFact('unsupported-device-kind');
|
|
const focusKindUnavailable = unavailableLinuxRuntimeFact(
|
|
'unsupported-device-kind',
|
|
'focus is supported only for the Linux desktop device.',
|
|
);
|
|
const runtimeHintsUnavailable = unavailableLinuxRuntimeFact(
|
|
'unsupported-platform-leaf',
|
|
'Runtime hints are supported only for local iOS-family simulators and Android devices.',
|
|
);
|
|
const appleRunnerUnavailable = unavailableLinuxRuntimeFact(
|
|
'unsupported-platform-leaf',
|
|
'Apple runner preparation is supported only for Apple targets.',
|
|
);
|
|
const providerPortReverseUnavailable = unavailableLinuxRuntimeFact(
|
|
'unsupported-provider-mode',
|
|
'Port reverse is supported only by an owning provider runtime.',
|
|
);
|
|
const openTargetKindUnavailable = unavailableLinuxRuntimeFact(
|
|
'unsupported-device-kind',
|
|
'open is supported only for the Linux desktop device.',
|
|
);
|
|
const closeTargetKindUnavailable = unavailableLinuxRuntimeFact(
|
|
'unsupported-device-kind',
|
|
'close is supported only for the Linux desktop device.',
|
|
);
|
|
const snapshotKindUnavailable = unavailableLinuxRuntimeFact(
|
|
'unsupported-device-kind',
|
|
'snapshot is supported only for the Linux desktop device.',
|
|
);
|
|
const screenshotKindUnavailable = unavailableLinuxRuntimeFact(
|
|
'unsupported-device-kind',
|
|
'screenshot is supported only for the Linux desktop device.',
|
|
);
|
|
const snapshotCustomActionsUnavailable = unavailableLinuxRuntimeFact(
|
|
'unsupported-platform-leaf',
|
|
'Re-run without --actions, or target an iOS simulator.',
|
|
);
|
|
export function createLinuxPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner {
|
|
return Object.freeze({
|
|
owner: linuxOwner,
|
|
ownsDevice: (device) => device.platform === 'linux',
|
|
inspectFacts: async (device) => linuxFacts(device),
|
|
bind: async (request) => {
|
|
if (
|
|
request.intent.kind === 'exact-owner' &&
|
|
!sameRuntimeOwner(request.intent.owner, linuxOwner)
|
|
) {
|
|
throw new AppError('UNSUPPORTED_OPERATION', 'Linux runtime owner identity does not match');
|
|
}
|
|
if (request.device.platform !== 'linux') {
|
|
throw new AppError('UNSUPPORTED_PLATFORM', 'Linux runtime cannot bind this device');
|
|
}
|
|
const facts = linuxFacts(request.device);
|
|
const lifecycle = bindLinuxApplicationLifecycle({
|
|
host: host.localInteractors,
|
|
device: request.device,
|
|
signal: request.scope.signal,
|
|
});
|
|
return Object.freeze({
|
|
device: request.device,
|
|
owner: linuxOwner,
|
|
facts,
|
|
operations: Object.freeze({
|
|
...availableApplicationLifecycleOperations(lifecycle, facts.operations),
|
|
...(facts.operations.captureSnapshot.available
|
|
? linuxSnapshotOperations(host, request)
|
|
: {}),
|
|
...(facts.operations.captureScreenshot.available
|
|
? bindLocalScreenshotInteractor({
|
|
device: request.device,
|
|
signal: request.scope.signal,
|
|
resolveInteractor: host.localInteractors.resolve,
|
|
})
|
|
: {}),
|
|
...(facts.operations.focusPoint.available
|
|
? bindLocalFocusInteractor({
|
|
device: request.device,
|
|
signal: request.scope.signal,
|
|
resolveInteractor: host.localInteractors.resolve,
|
|
})
|
|
: {}),
|
|
...(facts.operations.readTextAtPoint.available
|
|
? bindElementTextRuntime({
|
|
device: request.device,
|
|
signal: request.scope.signal,
|
|
resolveInteractor: host.localInteractors.resolve,
|
|
})
|
|
: {}),
|
|
}),
|
|
[Symbol.asyncDispose]: async () => undefined,
|
|
}) satisfies DeviceBinding<PlatformRuntimeOperations>;
|
|
},
|
|
shutdown: async () => undefined,
|
|
});
|
|
}
|
|
|
|
function linuxFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperations> {
|
|
const openTarget = device.kind === 'device' ? supported : openTargetKindUnavailable;
|
|
const closeTarget = device.kind === 'device' ? supported : closeTargetKindUnavailable;
|
|
const unavailable = createUnavailablePlatformRuntimeFacts(device, linuxOwner, {
|
|
appLog: unsupportedPlatformLeaf,
|
|
network: unsupportedPlatformLeaf,
|
|
screenshot: screenshotKindUnavailable,
|
|
snapshot: snapshotKindUnavailable,
|
|
viewport: unsupportedPlatformLeaf,
|
|
focus: focusKindUnavailable,
|
|
elementText: elementTextKindUnavailable,
|
|
readiness: unsupportedPlatformLeaf,
|
|
lifecycle: applicationLifecycleOperationFacts({
|
|
resolveOpenTarget: openTarget,
|
|
prepareApplicationOpen: openTarget,
|
|
openApplication: openTarget,
|
|
applyRuntimeHints: runtimeHintsUnavailable,
|
|
clearRuntimeHints: runtimeHintsUnavailable,
|
|
closeApplication: closeTarget,
|
|
finalizeApplicationClose: closeTarget,
|
|
prepareAppleRunner: appleRunnerUnavailable,
|
|
configureProviderPortReverse: providerPortReverseUnavailable,
|
|
}),
|
|
});
|
|
return Object.freeze({
|
|
device: unavailable.device,
|
|
operations: {
|
|
...unavailable.operations,
|
|
...snapshotRuntimeOperationFacts({
|
|
capture: device.kind === 'device' ? supported : snapshotKindUnavailable,
|
|
customActions: snapshotCustomActionsUnavailable,
|
|
withoutActiveApp: device.kind === 'device' ? supported : snapshotKindUnavailable,
|
|
}),
|
|
...screenshotRuntimeOperationFacts({
|
|
capture: device.kind === 'device' ? supported : screenshotKindUnavailable,
|
|
}),
|
|
// Parity with the retired `focus` capability bucket (`{ device: true }`): the desktop is
|
|
// the only Linux cell with a pointer to drive.
|
|
...focusRuntimeOperationFacts({
|
|
focus: device.kind === 'device' ? supported : focusKindUnavailable,
|
|
}),
|
|
// The Linux read is value-first (AXValue/title/description) where the captured tree is
|
|
// label-first, so the desktop row genuinely reads differently from its snapshot text.
|
|
...elementTextRuntimeOperationFacts({
|
|
readTextAtPoint: device.kind === 'device' ? supported : elementTextKindUnavailable,
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
|
|
function linuxSnapshotOperations(
|
|
host: PlatformRuntimeHost,
|
|
request: Parameters<PlatformRuntimeOwner['bind']>[0],
|
|
) {
|
|
const captureSnapshot = async (input: CaptureSnapshotInput) =>
|
|
await host.snapshot.captureSurface(
|
|
request.device,
|
|
input.options,
|
|
captureSnapshotSignal(request.scope.signal, input),
|
|
);
|
|
return Object.freeze({
|
|
captureSnapshot,
|
|
captureSnapshotWithCustomActions: captureSnapshot,
|
|
captureSnapshotWithoutActiveApp: captureSnapshot,
|
|
});
|
|
}
|
|
|
|
function unavailableLinuxRuntimeFact(
|
|
reason: RuntimeOperationUnavailability['reason'],
|
|
hint?: string,
|
|
): RuntimeOperationUnavailability {
|
|
return Object.freeze(
|
|
hint === undefined ? { available: false, reason } : { available: false, reason, hint },
|
|
);
|
|
}
|