Skip to content
Merged
97 changes: 97 additions & 0 deletions packages/contracts/src/element-text-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import {
bindElementTextRuntime,
elementTextRead,
type ElementTextReadOutcome,
type ElementTextUnreadableReason,
} from './element-text-runtime.ts';
import type { Interactor } from './interactor-types.ts';
import type { DeviceInfo } from '@agent-device/kernel/device';

/**
* 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'];

/**
* 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('')));
});

/**
* A runtime owner whose facts advertised `readTextAtPoint` but whose interactor cannot perform
* it is a CONTRACT BUG, not a refusal. Classifying it as an unreadable reason would place it
* inside the closed set that licenses falling back to already-captured text — so the command
* would answer from a stale tree precisely because the runtime lied about itself.
*
* Reverting the guard to `{ status: 'unreadable', reason: … }` makes this test fail: the call
* resolves instead of rejecting, which is the exact silent degradation it exists to forbid.
*/
test('an advertised read with no interactor implementation fails as a contract bug', async () => {
const runtime = bindElementTextRuntime({
device: { platform: 'ios' } as unknown as DeviceInfo,
signal: new AbortController().signal,
// An interactor with NO readTextAtPoint — the mismatch the facts promised away.
resolveInteractor: async () => ({}) as unknown as Interactor,
});

await assert.rejects(
() => runtime.readTextAtPoint({ point: { x: 1, y: 2 } }),
(error: unknown) =>
error instanceof AppError &&
error.details?.reason === 'runtime-contract-invalid' &&
/advertised readTextAtPoint/.test(error.message),
);
});
120 changes: 120 additions & 0 deletions packages/contracts/src/element-text-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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 { invalidRuntimeContract } from './runtime-contract-error.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 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,
});
// Facts advertised the read but the owner's interactor cannot perform it. That is a
// contract violation, not a refusal: classifying it as `surface-not-readable` would put
// it inside the closed reason set and license the caller to fall back to already-captured
// text, answering from a stale tree because the runtime lied. Fail as the contract bug it
// is (ADR 0019 §2) so no consumer can silently degrade.
if (typeof interactor.readTextAtPoint !== 'function') {
throw invalidRuntimeContract(
'Runtime owner advertised readTextAtPoint without an interactor implementation',
);
}
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
Loading
Loading