Skip to content

Commit acea951

Browse files
committed
refactor(contracts): make the alert legs optional members and drop their denials
ADR 0019 keeps a stub that throws `unsupported` out of the interactor: an owner with no alert surface says so with its facts, and the facts are what the binding consults. Every alert leg was reachable only through `bindAlertLeg`, which the catalog calls for exactly the legs a binding's facts admitted — and web, Linux, HarmonyOS, Vega, Cloud WebDriver and the Limrun iOS direct session all refuse all four cells unconditionally. Their interactor denials were therefore unreachable code pretending to be an implementation. All four members become optional, so `bindAlertLeg` now fails closed through `requireInteractorMethod` when a fact admits a leg its interactor cannot serve, naming the leg with the label the catalog already used. Apple and Android keep their real mechanics.
1 parent 4bbecbd commit acea951

15 files changed

Lines changed: 64 additions & 95 deletions

File tree

‎packages/contracts/src/alert-runtime.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { expect, test, vi } from 'vitest';
2+
import { AppError } from '@agent-device/kernel/errors';
23
import { alertRuntimeOperationFacts, bindAlertLeg } from './alert-runtime.ts';
34
import { localInteractorSource } from './interactor-operation-binding.ts';
45
import type { AlertInteractorOptions, Interactor } from './interactor-types.ts';
@@ -82,3 +83,22 @@ test('an absent target field never reaches the owner as an explicit undefined',
8283
expect(readAlert).toHaveBeenCalledWith({ surface: 'frontmost-app' });
8384
expect(Object.keys(readAlert.mock.calls[0]?.[0] ?? {})).toEqual(['surface']);
8485
});
86+
87+
// Facts admitted the leg, so an interactor without it is an ownership bug the caller must see
88+
// rather than a refusal to degrade around. The label is the one the command already used.
89+
test('a leg whose fact admitted but whose interactor cannot serve it fails closed', async () => {
90+
const served = { acceptAlert: vi.fn(async () => ({ accepted: true })) } as unknown as Interactor;
91+
const source = localInteractorSource({
92+
device,
93+
resolveInteractor: async () => ({ ...served, acceptAlert: undefined }),
94+
});
95+
const operations = bindAlertLeg('acceptAlert', new AbortController().signal, source);
96+
97+
await expect(operations.acceptAlert({})).rejects.toSatisfy(
98+
(error: unknown) =>
99+
error instanceof AppError &&
100+
error.code === 'COMMAND_FAILED' &&
101+
error.details?.['reason'] === 'interactor-method-missing' &&
102+
error.message.includes('alert accept'),
103+
);
104+
});

‎packages/contracts/src/alert-runtime.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ function alertInteractorOptions(input: AlertRuntimeInput): AlertInteractorOption
104104

105105
type AlertLeg = 'readAlert' | 'awaitAlert' | 'acceptAlert' | 'dismissAlert';
106106

107+
/** How a fail-closed refusal names each leg to the caller, matching the command that asked for it. */
108+
export const ALERT_LEG_LABELS = {
109+
readAlert: 'alert get',
110+
awaitAlert: 'alert wait',
111+
acceptAlert: 'alert accept',
112+
dismissAlert: 'alert dismiss',
113+
} as const satisfies Record<AlertLeg, string>;
114+
107115
export function bindAlertLeg<Leg extends AlertLeg>(
108116
leg: Leg,
109117
signal: AbortSignal,
@@ -112,7 +120,11 @@ export function bindAlertLeg<Leg extends AlertLeg>(
112120
return Object.freeze({
113121
[leg]: async (input: AlertRuntimeInput) => {
114122
const interactor = await resolveAlertInteractor(signal, resolveInteractor, input);
115-
return await interactor[leg](alertInteractorOptions(input));
123+
// Loaded on the call rather than at module evaluation because this module's eager closure is
124+
// held at its merge-base size (`eager-closure-budgets`); a static edge would grow it.
125+
const { requireInteractorMethod } = await import('./interactor-operation-binding.ts');
126+
const method = requireInteractorMethod(interactor[leg], ALERT_LEG_LABELS[leg]);
127+
return await method.call(interactor, alertInteractorOptions(input));
116128
},
117129
}) as Readonly<Record<Leg, (input: AlertRuntimeInput) => Promise<Record<string, unknown>>>>;
118130
}

‎packages/contracts/src/interactor-operation-catalog.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { DeviceInfo } from '@agent-device/kernel/device';
2-
import { bindAlertLeg } from './alert-runtime.ts';
2+
import { ALERT_LEG_LABELS, bindAlertLeg } from './alert-runtime.ts';
33
import { bindAppEvent } from './app-event-runtime.ts';
44
import { bindBack } from './back-runtime.ts';
55
import { bindClipboardRead, bindClipboardWrite } from './clipboard-runtime.ts';
@@ -91,22 +91,22 @@ export const INTERACTOR_OPERATIONS = [
9191
{ operation: 'readSetting', label: 'settings read', bind: bindReadSetting },
9292
{
9393
operation: 'readAlert',
94-
label: 'alert get',
94+
label: ALERT_LEG_LABELS.readAlert,
9595
bind: (signal, resolve) => bindAlertLeg('readAlert', signal, resolve),
9696
},
9797
{
9898
operation: 'awaitAlert',
99-
label: 'alert wait',
99+
label: ALERT_LEG_LABELS.awaitAlert,
100100
bind: (signal, resolve) => bindAlertLeg('awaitAlert', signal, resolve),
101101
},
102102
{
103103
operation: 'acceptAlert',
104-
label: 'alert accept',
104+
label: ALERT_LEG_LABELS.acceptAlert,
105105
bind: (signal, resolve) => bindAlertLeg('acceptAlert', signal, resolve),
106106
},
107107
{
108108
operation: 'dismissAlert',
109-
label: 'alert dismiss',
109+
label: ALERT_LEG_LABELS.dismissAlert,
110110
bind: (signal, resolve) => bindAlertLeg('dismissAlert', signal, resolve),
111111
},
112112
] as const satisfies readonly InteractorOperationDefinition[];

‎packages/contracts/src/interactor-types.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -395,11 +395,15 @@ export type Interactor = {
395395
* poll: an alert is a transient device surface, and how long to look for it — and how to press
396396
* its buttons — is family mechanics, not something a caller can supply. `timeoutMs` is the
397397
* whole window the caller allows; the owner spends it however its backend requires.
398+
*
399+
* Optional: an owner with no alert surface leaves all four undefined. Its facts refuse every leg
400+
* before binding, so the absent members are never resolved, and a fact that admitted a leg the
401+
* interactor cannot serve fails closed instead of answering empty.
398402
*/
399-
readAlert(options?: AlertInteractorOptions): Promise<Record<string, unknown>>;
400-
awaitAlert(options?: AlertInteractorOptions): Promise<Record<string, unknown>>;
401-
acceptAlert(options?: AlertInteractorOptions): Promise<Record<string, unknown>>;
402-
dismissAlert(options?: AlertInteractorOptions): Promise<Record<string, unknown>>;
403+
readAlert?(options?: AlertInteractorOptions): Promise<Record<string, unknown>>;
404+
awaitAlert?(options?: AlertInteractorOptions): Promise<Record<string, unknown>>;
405+
acceptAlert?(options?: AlertInteractorOptions): Promise<Record<string, unknown>>;
406+
dismissAlert?(options?: AlertInteractorOptions): Promise<Record<string, unknown>>;
403407
};
404408

405409
/**

‎packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,10 @@ const RUNNER_TRANSPORT_METHODS: Record<
8181
// R59: same reading as `readTextAtPoint` — the macOS-helper branch is reachable only for a
8282
// local desktop surface, which a provider-owned mobile device never carries, so every
8383
// provider-backed alert leg rides the runner. Each spends one runner call when it succeeds.
84-
readAlert: { invoke: (i) => i.readAlert(), runnerCommand: 'alert' },
85-
awaitAlert: { invoke: (i) => i.awaitAlert(), runnerCommand: 'alert' },
86-
acceptAlert: { invoke: (i) => i.acceptAlert(), runnerCommand: 'alert' },
87-
dismissAlert: { invoke: (i) => i.dismissAlert(), runnerCommand: 'alert' },
84+
readAlert: { invoke: (i) => i.readAlert!(), runnerCommand: 'alert' },
85+
awaitAlert: { invoke: (i) => i.awaitAlert!(), runnerCommand: 'alert' },
86+
acceptAlert: { invoke: (i) => i.acceptAlert!(), runnerCommand: 'alert' },
87+
dismissAlert: { invoke: (i) => i.dismissAlert!(), runnerCommand: 'alert' },
8888
};
8989

9090
const LOCAL_TOOL_METHODS: Record<string, (interactor: Interactor) => Promise<unknown>> = {

‎packages/platform-vega/src/__tests__/unsupported-interactor.test.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,6 @@ const OPERATIONS = [
2323
'readClipboard',
2424
'writeClipboard',
2525
'setSetting',
26-
'readAlert',
27-
'awaitAlert',
28-
'acceptAlert',
29-
'dismissAlert',
3026
] as const;
3127

3228
test('every operation rejects as unsupported and names the platform', async () => {
@@ -46,7 +42,7 @@ test('the label is per-instance, so two platforms reject with their own wording'
4642
await expectUnsupported(vega, 'home', 'Vega OS');
4743
});
4844

49-
test('the factory covers the whole interactor surface', () => {
45+
test('the factory covers the whole required interactor surface', () => {
5046
const interactor = createUnsupportedInteractor('Vega OS');
5147

5248
assert.deepEqual(Object.keys(interactor).sort(), [...OPERATIONS].sort());

‎packages/platform-vega/src/unsupported-interactor.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,5 @@ export function createUnsupportedInteractor(platformLabel: string): Interactor {
3030
readClipboard: () => unsupported('readClipboard'),
3131
writeClipboard: () => unsupported('writeClipboard'),
3232
setSetting: () => unsupported('setSetting'),
33-
readAlert: () => unsupported('readAlert'),
34-
awaitAlert: () => unsupported('awaitAlert'),
35-
acceptAlert: () => unsupported('acceptAlert'),
36-
dismissAlert: () => unsupported('dismissAlert'),
3733
};
3834
}

‎packages/provider-limrun/src/ios.ts‎

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -351,22 +351,6 @@ class LimrunIosInteractor implements Interactor {
351351
throw unsupported('tv-remote', 'Limrun iOS direct sessions do not expose tv remote control.');
352352
}
353353

354-
async readAlert(): Promise<never> {
355-
throw unsupported('alert', LIMRUN_IOS_ALERT_UNSUPPORTED);
356-
}
357-
358-
async awaitAlert(): Promise<never> {
359-
throw unsupported('alert', LIMRUN_IOS_ALERT_UNSUPPORTED);
360-
}
361-
362-
async acceptAlert(): Promise<never> {
363-
throw unsupported('alert', LIMRUN_IOS_ALERT_UNSUPPORTED);
364-
}
365-
366-
async dismissAlert(): Promise<never> {
367-
throw unsupported('alert', LIMRUN_IOS_ALERT_UNSUPPORTED);
368-
}
369-
370354
async readClipboard(): Promise<never> {
371355
throw unsupported('clipboard', 'Limrun iOS direct sessions do not expose clipboard read yet.');
372356
}
@@ -471,10 +455,6 @@ export function isUserInstalledIosApp(app: LimrunIosApp): boolean {
471455
);
472456
}
473457

474-
/** One sentence for all four alert legs: this session has no XCUITest runner to read a sheet. */
475-
const LIMRUN_IOS_ALERT_UNSUPPORTED =
476-
'Limrun iOS direct sessions do not expose alert inspection yet.';
477-
478458
function unsupported(command: string, message: string): never {
479459
throw new AppError('UNSUPPORTED_OPERATION', message, { command });
480460
}

‎packages/provider-webdriver/src/webdriver-interactor.ts‎

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -381,24 +381,6 @@ class WebDriverInteractor implements Interactor {
381381
this.unsupported('settings');
382382
}
383383

384-
// The four alert legs share one declared capability: a driver that cannot read a native alert
385-
// cannot press its buttons either, and no provider in this family declares either half.
386-
async readAlert(): Promise<Record<string, unknown>> {
387-
this.unsupported('alert');
388-
}
389-
390-
async awaitAlert(): Promise<Record<string, unknown>> {
391-
this.unsupported('alert');
392-
}
393-
394-
async acceptAlert(): Promise<Record<string, unknown>> {
395-
this.unsupported('alert');
396-
}
397-
398-
async dismissAlert(): Promise<Record<string, unknown>> {
399-
this.unsupported('alert');
400-
}
401-
402384
/**
403385
* The cloud twin of the local Apple runner's focus -> readiness -> type
404386
* pipeline (RunnerTests+TextEntry.swift). A WebView input does not take first

‎src/core/interactors/harmonyos.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,5 @@ export function createHarmonyInteractor(device: DeviceInfo, _runner?: RunnerCont
6565
readClipboard: unsupported('clipboard'),
6666
writeClipboard: unsupported('clipboard'),
6767
setSetting: (setting, state, appId) => setHarmonySetting(device, setting, state, appId),
68-
// R59: the retired `alert` descriptor declared no HarmonyOS leaf, and hdc exposes no dialog
69-
// surface to read one from.
70-
readAlert: unsupported('alert'),
71-
awaitAlert: unsupported('alert'),
72-
acceptAlert: unsupported('alert'),
73-
dismissAlert: unsupported('alert'),
7468
};
7569
}

0 commit comments

Comments
 (0)