Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions packages/contracts/src/element-text-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import {
elementTextRead,
type ElementTextReadOutcome,
type ElementTextUnreadableReason,
} from './element-text-runtime.ts';

/**
* The reasons this suite exercises. Kept local on purpose: exhaustiveness is enforced at the
* CONSUMER by `classifiedFallbackReason`'s `never` arm (a new reason is a compile error there),
* so a second exported runtime list would be an unconsumed parallel source of truth that could
* silently drift. The annotation is what ties this list back to the union.
*/
const UNREADABLE_REASONS: readonly ElementTextUnreadableReason[] = [
'no-text-at-point',
'surface-not-readable',
];

/**
* ADR 0019 §2 contract coverage for the preferred element-text read.
*
* A preferred operation may fall its consumer back to the required path only through a TYPED
* reason. These tests pin that the reason set is closed and exhaustively enumerated, so a new
* reason cannot be added without a consumer having to classify it — which is what keeps the
* retired generic `catch` from creeping back as "some other failure, just fall back".
*/

test('the outcome union is closed: every value is a read or a classified unreadable', () => {
const outcomes: readonly ElementTextReadOutcome[] = [
elementTextRead('live value'),
...UNREADABLE_REASONS.map((reason) => ({ status: 'unreadable', reason }) as const),
];
for (const outcome of outcomes) {
if (outcome.status === 'read') {
assert.equal(typeof outcome.text, 'string');
continue;
}
assert.ok(
(UNREADABLE_REASONS as readonly string[]).includes(outcome.reason),
`unreadable outcome carries an unclassified reason: ${outcome.reason}`,
);
}
});

test('a non-blank owner answer is a read that preserves the exact text', () => {
const outcome = elementTextRead(' padded value ');
assert.deepEqual(outcome, { status: 'read', text: ' padded value ' });
});

// Blank is a classification, not a read: an owner answering with whitespace has said there is
// nothing at this point, and saying so by reason keeps consumers off "empty or failed?" guesswork.
for (const [label, value] of [
['empty string', ''],
['whitespace', ' \n\t '],
['undefined', undefined],
['null', null],
] as const) {
test(`a ${label} owner answer classifies as no-text-at-point`, () => {
assert.deepEqual(elementTextRead(value), {
status: 'unreadable',
reason: 'no-text-at-point',
});
});
}

test('read outcomes are frozen so a consumer cannot mutate a classification', () => {
assert.ok(Object.isFrozen(elementTextRead('value')));
assert.ok(Object.isFrozen(elementTextRead('')));
});
116 changes: 116 additions & 0 deletions packages/contracts/src/element-text-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { Point } from '@agent-device/kernel/snapshot';
import type { Interactor, RunnerContext } from './interactor-types.ts';
import type { RuntimeOperationFact } from './platform-runtime.ts';
import type { SessionSurface } from './session-surface.ts';
import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts';

/**
* Neutral intent for one point-addressed element read. The point is already resolved from the
* node the caller matched, so the operation names no command, request, session, or CLI flag.
*/
export type ReadTextAtPointInput = Readonly<{
point: Point;
options?: Readonly<{ appBundleId?: string; surface?: SessionSurface }>;
/** Same runner metadata a capture needs; reuses that type rather than restating it. */
execution?: SnapshotRuntimeExecution;
}>;

/**
* Why an owner that HAS a live read still produced no text for this point.
*
* Closed on purpose (ADR 0019 §2): a consumer may fall back to the required path only for a
* reason named here. Anything else — a runner transport failure, a helper crash, a bug — is an
* unexpected error and propagates, because silently answering from a stale captured tree after
* an unclassified failure is exactly the "generic catch fallback" the ADR forbids.
*/
export type ElementTextUnreadableReason =
/** The owner queried successfully and there is nothing readable at this point. */
| 'no-text-at-point'
/** The owner's read surface exists but declined this query (unsupported element/surface). */
| 'surface-not-readable';

/** The closed outcome of one live element-text read. */
export type ElementTextReadOutcome =
| Readonly<{ status: 'read'; text: string }>
| Readonly<{ status: 'unreadable'; reason: ElementTextUnreadableReason }>;

/**
* Normalizes a raw owner read into the closed outcome. Blank text is not a read: an owner that
* answers with whitespace has told us there is nothing at this point, and saying so by reason
* keeps every consumer off "did it fail or is it empty?" guesswork.
*/
export function elementTextRead(text: string | undefined | null): ElementTextReadOutcome {
if (typeof text !== 'string' || text.trim().length === 0) {
return Object.freeze({ status: 'unreadable', reason: 'no-text-at-point' } as const);
}
return Object.freeze({ status: 'read', text } as const);
}

export type ElementTextRuntimeOperations = Readonly<{
/**
* The live text an owner reads at a point, which can exceed the readable text carried by an
* already-captured snapshot node (an editable field whose value is longer than its label).
* Declared `preferred`, never `required`: every consumer's required path answers from the
* snapshot tree, so an owner without this operation still executes the command completely.
*
* Returns a closed typed outcome rather than a bare string, so a consumer never has to
* distinguish "no text here" from "the read blew up" by catching.
*/
readTextAtPoint(input: ReadTextAtPointInput): Promise<ElementTextReadOutcome>;
}>;

export type ElementTextRuntimeOperationFacts = Readonly<{
readTextAtPoint: RuntimeOperationFact;
}>;

export function elementTextRuntimeOperationFacts(
input: ElementTextRuntimeOperationFacts,
): ElementTextRuntimeOperationFacts {
return Object.freeze({ readTextAtPoint: input.readTextAtPoint });
}

/** Resolves the selected owner's interactor, exactly as the snapshot runtime does. */
export type ElementTextInteractorResolver = (
device: DeviceInfo,
runner: RunnerContext,
) => Promise<Interactor>;

/**
* Binds the owner's live point read for the lifetime of a request binding.
*
* Rides the same `Interactor` seam `findText` uses rather than a bespoke host port: two
* operations of the same class reaching their mechanics two different ways is duplication of
* mechanism, and Wave 5/6 retires the seam for both together.
*/
export function bindElementTextRuntime(
params: Readonly<{
device: DeviceInfo;
signal: AbortSignal;
resolveInteractor: ElementTextInteractorResolver;
}>,
): ElementTextRuntimeOperations {
return Object.freeze({
readTextAtPoint: async (input: ReadTextAtPointInput) => {
const signal = params.signal;
signal.throwIfAborted();
const interactor = await params.resolveInteractor(params.device, {
...input.execution,
appBundleId: input.options?.appBundleId,
signal,
});
// An owner whose facts advertised the read but whose interactor has none is a runtime
// contract error surfaced as a declined read, not a silent empty answer.
if (!interactor.readTextAtPoint) {
return Object.freeze({ status: 'unreadable', reason: 'surface-not-readable' } as const);
}
return elementTextRead(
await interactor.readTextAtPoint(input.point, {
appBundleId: input.options?.appBundleId,
surface: input.options?.surface,
signal,
}),
);
},
});
}
21 changes: 20 additions & 1 deletion packages/contracts/src/facades/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,18 @@ export {
captureSnapshotUse,
defineUse,
resolveScreenshotRuntimePlan,
resolveSelectorCaptureRuntimePlan,
resolveSnapshotRuntimePlan,
screenshotRuntimePlanUses,
selectorCaptureRuntimePlanUses,
snapshotRuntimePlanUses,
viewportRuntimeUse,
} from '../platform-runtime-operations.ts';
export type { ScreenshotRuntimePlan, SnapshotRuntimePlan } from '../platform-runtime-operations.ts';
export type {
ScreenshotRuntimePlan,
SelectorCaptureRuntimePlan,
SnapshotRuntimePlan,
} from '../platform-runtime-operations.ts';
export type {
PlatformRuntimeHost,
PlatformRuntimeModule,
Expand Down Expand Up @@ -270,6 +276,19 @@ export type {
ViewportRuntimeOperationFacts,
ViewportRuntimeOperations,
} from '../viewport-runtime.ts';
export {
bindElementTextRuntime,
elementTextRead,
elementTextRuntimeOperationFacts,
} from '../element-text-runtime.ts';
export type {
ElementTextReadOutcome,
ElementTextInteractorResolver,
ElementTextRuntimeOperationFacts,
ElementTextRuntimeOperations,
ElementTextUnreadableReason,
ReadTextAtPointInput,
} from '../element-text-runtime.ts';
export type {
AppStateRuntimeCommand,
AppStateRuntimeCommandResult,
Expand Down
10 changes: 10 additions & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,16 @@ export type Interactor = {
screenshot(outPath: string, options?: ScreenshotOptions): Promise<void>;
setViewport?(width: number, height: number): Promise<Record<string, unknown> | void>;
snapshot(options?: SnapshotOptions): Promise<SnapshotResult>;
/**
* Native reading of the live text at a point, when the backend has one. Answers the text the
* owner can see right now, which can exceed what an already-captured node carries (an editable
* field whose value is longer than its label). Optional: a backend without it leaves the
* captured tree as the complete answer.
*/
readTextAtPoint?(
point: Point,
options?: { appBundleId?: string; surface?: SessionSurface; signal?: AbortSignal },
): Promise<string | undefined>;
gestureViewport?(): Promise<Rect>;
back(mode?: BackMode): Promise<void>;
home(): Promise<void>;
Expand Down
90 changes: 77 additions & 13 deletions packages/contracts/src/platform-runtime-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { ScreenRecordingRuntimeOperations } from './screen-recording-runtim
import type { ScreenshotRuntimeOperations } from './screenshot-runtime.ts';
import type { SnapshotRuntimeHost, SnapshotRuntimeOperations } from './snapshot-runtime.ts';
import type { ViewportRuntimeOperations } from './viewport-runtime.ts';
import type { ElementTextRuntimeOperations } from './element-text-runtime.ts';
import type {
DeviceReadinessRuntimeHost,
DeviceReadinessRuntimeOperations,
Expand Down Expand Up @@ -47,6 +48,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations &
ScreenshotRuntimeOperations &
SnapshotRuntimeOperations &
ViewportRuntimeOperations &
ElementTextRuntimeOperations &
DeviceReadinessRuntimeOperations &
DeviceShutdownRuntimeOperations &
ApplicationLifecycleRuntimeOperations;
Expand Down Expand Up @@ -80,6 +82,31 @@ const captureSnapshotWithCustomActionsWithoutActiveAppUse = defineUse({
],
});

/**
* The selector family's capture uses. Declared ALONGSIDE the snapshot uses above, never in place
* of them: `snapshot`/`diff` keep binding exactly what they bind today. The only difference is the
* PREFERRED element read — every selector read's required path answers from the captured tree, so
* an owner without the read still executes the command completely (ADR 0019 §2), but an owner that
* has one lets `get text` return the live value a truncated snapshot node cannot.
*/
const selectorCaptureUse = defineUse({
required: ['captureSnapshot'],
preferred: ['readTextAtPoint'],
});
const selectorCaptureWithoutActiveAppUse = defineUse({
required: ['captureSnapshot', 'captureSnapshotWithoutActiveApp'],
preferred: ['readTextAtPoint'],
});

/**
* The selector family (`find`, `get`, `is`, `wait`) resolves targets from the plain accessibility
* capture: it exposes no `--actions` surface, so only the active-app split applies.
*/
export const selectorCaptureRuntimePlanUses = Object.freeze([
selectorCaptureUse,
selectorCaptureWithoutActiveAppUse,
] as const);

export const snapshotRuntimePlanUses = Object.freeze([
captureSnapshotUse,
captureSnapshotWithCustomActionsUse,
Expand Down Expand Up @@ -109,30 +136,67 @@ export type SnapshotRuntimePlan =
use: typeof captureSnapshotWithoutActiveAppUse;
}>;

/**
* Same two `kind`s the snapshot plan uses for this split — deliberately, so the shared
* admit-then-bind path keeps ONE set of arms rather than growing a parallel dispatch — but
* carrying the selector uses, which add the preferred element read.
*/
export type SelectorCaptureRuntimePlan =
| Readonly<{
kind: 'selector-active-app';
operation: 'captureSnapshot';
use: typeof selectorCaptureUse;
}>
| Readonly<{
kind: 'selector-without-active-app';
operation: 'captureSnapshotWithoutActiveApp';
use: typeof selectorCaptureWithoutActiveAppUse;
}>;

/**
* The active-app split every selector capture selects from. The selector family exposes no
* `--actions` surface, so custom actions are outside its declaration.
*/
export function resolveSelectorCaptureRuntimePlan(
input: Readonly<{ hasActiveApp: boolean }>,
): SelectorCaptureRuntimePlan {
return input.hasActiveApp
? Object.freeze({
kind: 'selector-active-app',
operation: 'captureSnapshot',
use: selectorCaptureUse,
})
: Object.freeze({
kind: 'selector-without-active-app',
operation: 'captureSnapshotWithoutActiveApp',
use: selectorCaptureWithoutActiveAppUse,
});
}

/** Selects one owner-fact-backed capture plan from normalized command/session intent. */
export function resolveSnapshotRuntimePlan(input: {
customActions: boolean;
hasActiveApp: boolean;
}): SnapshotRuntimePlan {
if (input.customActions) {
if (!input.customActions) {
return input.hasActiveApp
? Object.freeze({
kind: 'custom-actions-active-app',
operation: 'captureSnapshotWithCustomActions',
use: captureSnapshotWithCustomActionsUse,
})
? Object.freeze({ kind: 'active-app', operation: 'captureSnapshot', use: captureSnapshotUse })
: Object.freeze({
kind: 'custom-actions-without-active-app',
operation: 'captureSnapshotWithCustomActions',
use: captureSnapshotWithCustomActionsWithoutActiveAppUse,
kind: 'without-active-app',
operation: 'captureSnapshotWithoutActiveApp',
use: captureSnapshotWithoutActiveAppUse,
});
}
return input.hasActiveApp
? Object.freeze({ kind: 'active-app', operation: 'captureSnapshot', use: captureSnapshotUse })
? Object.freeze({
kind: 'custom-actions-active-app',
operation: 'captureSnapshotWithCustomActions',
use: captureSnapshotWithCustomActionsUse,
})
: Object.freeze({
kind: 'without-active-app',
operation: 'captureSnapshotWithoutActiveApp',
use: captureSnapshotWithoutActiveAppUse,
kind: 'custom-actions-without-active-app',
operation: 'captureSnapshotWithCustomActions',
use: captureSnapshotWithCustomActionsWithoutActiveAppUse,
});
}

Expand Down
5 changes: 5 additions & 0 deletions packages/contracts/src/platform-runtime-unavailable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ test('generic unavailable binding preserves exact provider ownership and mode',
network: { available: false, reason: 'owner-capability-missing' },
screenshot: { available: false, reason: 'unsupported-device-kind' },
viewport: { available: false, reason: 'unsupported-platform-leaf' },
elementText: { available: false, reason: 'unsupported-provider-mode' },
lifecycle,
});

Expand All @@ -45,6 +46,10 @@ test('generic unavailable binding preserves exact provider ownership and mode',
available: false,
reason: 'unsupported-device-kind',
});
assert.deepEqual(binding.facts.operations.readTextAtPoint, {
available: false,
reason: 'unsupported-provider-mode',
});
assert.deepEqual(binding.operations, {});
await binding[Symbol.asyncDispose]();
});
Loading
Loading