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('')));
});
94 changes: 94 additions & 0 deletions packages/contracts/src/element-text-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { Point } from '@agent-device/kernel/snapshot';
import type { RunnerContext } from './interactor-types.ts';
import type { RuntimeOperationFact } from './platform-runtime.ts';
import type { SessionSurface } from './session-surface.ts';

/** Runner metadata the selected read implementation needs, without request-owned state. */
export type ElementTextRuntimeExecution = Readonly<Omit<RunnerContext, 'appBundleId' | 'signal'>>;

/**
* 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 }>;
execution?: ElementTextRuntimeExecution;
}>;

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

/**
* The existing per-family read mechanics, injected by composition. Families reach their own
* tools through this port rather than importing root modules, matching the snapshot runtime's
* interactor-resolver seam.
*/
export type ElementTextRuntimeHost = Readonly<{
readTextAtPoint(device: DeviceInfo, input: ReadTextAtPointInput): Promise<ElementTextReadOutcome>;
}>;

/** Captures one selected owner's read authority for the lifetime of a request binding. */
export function bindElementTextRuntime(
params: Readonly<{
device: DeviceInfo;
host: ElementTextRuntimeHost;
}>,
): ElementTextRuntimeOperations {
return Object.freeze({
readTextAtPoint: async (input: ReadTextAtPointInput) =>
await params.host.readTextAtPoint(params.device, input),
});
}
31 changes: 30 additions & 1 deletion packages/contracts/src/facades/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,18 @@ export {
appsRuntimeUse,
captureSnapshotUse,
defineUse,
resolveSelectorCaptureRuntimePlan,
resolveSnapshotRuntimePlan,
selectorCaptureRuntimePlanUses,
snapshotRuntimePlanUses,
viewportRuntimeUse,
} from '../platform-runtime-operations.ts';
export type { SnapshotRuntimePlan } from '../platform-runtime-operations.ts';
export type {
SelectorCaptureRuntimePlan,
SnapshotRuntimePlan,
} from '../platform-runtime-operations.ts';
export { resolveWaitRuntimePlan, waitRuntimePlanUses } from '../wait-runtime-plan.ts';
export type { WaitRuntimePlan, WaitRuntimeTarget } from '../wait-runtime-plan.ts';
export type {
PlatformRuntimeHost,
PlatformRuntimeModule,
Expand All @@ -238,8 +245,16 @@ export type { DeviceReadinessRuntimePlan } from '../platform-runtime-operations.
export {
bindLocalSnapshotInteractor,
bindProviderSnapshotInteractor,
captureSnapshotSignal,
snapshotRuntimeOperationFacts,
} from '../snapshot-runtime.ts';
export { findTextRuntimeOperationFacts } from '../find-text-runtime.ts';
export type {
FindTextInput,
FindTextResult,
FindTextRuntimeOperationFacts,
FindTextRuntimeOperations,
} from '../find-text-runtime.ts';
export type {
CaptureSnapshotInput,
LocalSnapshotInteractorResolver,
Expand All @@ -256,6 +271,20 @@ export type {
ViewportRuntimeOperationFacts,
ViewportRuntimeOperations,
} from '../viewport-runtime.ts';
export {
bindElementTextRuntime,
elementTextRead,
elementTextRuntimeOperationFacts,
} from '../element-text-runtime.ts';
export type {
ElementTextReadOutcome,
ElementTextRuntimeExecution,
ElementTextRuntimeHost,
ElementTextRuntimeOperationFacts,
ElementTextRuntimeOperations,
ElementTextUnreadableReason,
ReadTextAtPointInput,
} from '../element-text-runtime.ts';
export type {
AppStateRuntimeCommand,
AppStateRuntimeCommandResult,
Expand Down
44 changes: 44 additions & 0 deletions packages/contracts/src/find-text-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { SessionSurface } from './session-surface.ts';
import type { RuntimeOperationFact } from './platform-runtime.ts';
import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts';

/**
* A native, tree-independent answer to "is this text on screen right now".
*
* `surface` and `appBundleId` are the session facts an owner may need to decide it cannot answer
* for this request — the daemon does not pre-filter by family, so an owner that has no reading
* for the current surface reports `found: false` and the caller consults the canonical tree.
*/
export type FindTextInput = Readonly<{
text: string;
options?: Readonly<{ appBundleId?: string; surface?: SessionSurface }>;
execution?: SnapshotRuntimeExecution;
/** Per-capture cancellation; see `CaptureSnapshotInput.signal`. */
signal?: AbortSignal;
}>;

/**
* Deliberately asymmetric, and the reason this is a `preferred` operation rather than a second
* execution path (ADR 0019 §2/§9):
*
* - `found: true` is **authoritative** — the owner observed the text and the wait is satisfied.
* - `found: false` is **not** authoritative. It means "not proven by this owner", and the caller
* must still consult the canonical tree in the same poll. An owner that cannot answer at all
* reports `false` rather than throwing.
*
* So the required tree path remains semantically complete on its own: removing this operation
* changes how fast a satisfied wait returns, never whether it can be satisfied.
*/
export type FindTextResult = Readonly<{ found: boolean }>;

export type FindTextRuntimeOperations = Readonly<{
findText(input: FindTextInput): Promise<FindTextResult>;
}>;

export type FindTextRuntimeOperationFacts = Readonly<{ findText: RuntimeOperationFact }>;

export function findTextRuntimeOperationFacts(
input: FindTextRuntimeOperationFacts,
): FindTextRuntimeOperationFacts {
return Object.freeze({ findText: input.findText });
}
11 changes: 11 additions & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,17 @@ 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 text-presence reading, when the backend has one that does not require a tree capture.
* A `true` answer is authoritative; anything else means "not proven here" and the caller
* consults the canonical tree (see `FindTextResult`).
*/
findText?(
text: string,
options?: { appBundleId?: string; signal?: AbortSignal },
): Promise<{
found: boolean;
}>;
gestureViewport?(): Promise<Rect>;
back(mode?: BackMode): Promise<void>;
home(): Promise<void>;
Expand Down
Loading
Loading