Skip to content

Commit b396bd0

Browse files
authored
refactor(contracts): own the unsupported-interactor stub both platforms duplicated (#4002)
Web and Vega each carried a byte-identical createUnsupportedInteractor, with a fallow duplication suppression on the Vega copy. Contracts is the one zone below both, so the factory moves there beside the Interactor type it must satisfy and both platforms import it through @agent-device/contracts/unsupported-interactor. Behavior is unchanged: same UNSUPPORTED_OPERATION code, same operation names and per-instance platform labels, same async rejection with no partial result, and the optional members stay absent so a fact-gated binder still sees an owner's absence rather than a faked mechanic. Vega's local suppression is removed. The two platform test files asserted the shared module through their own copies; they move to one file beside the new owner. Each platform's own coverage stays where it belongs: in the composed-interactor tests that exercise the members that platform does not override. Manifest and export-snapshot changes are the R11 declaration this cross-package edge requires; no dependency is added — both platforms already depend on @agent-device/contracts.
1 parent 33a6bbc commit b396bd0

9 files changed

Lines changed: 106 additions & 157 deletions

File tree

‎packages/contracts/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,10 @@
288288
"types": "./src/interactor-types.ts",
289289
"default": "./src/interactor-types.ts"
290290
},
291+
"./unsupported-interactor": {
292+
"types": "./src/unsupported-interactor.ts",
293+
"default": "./src/unsupported-interactor.ts"
294+
},
291295
"./keyboard": {
292296
"types": "./src/keyboard.ts",
293297
"default": "./src/keyboard.ts"
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { AppError } from '@agent-device/kernel/errors';
4+
import { createUnsupportedInteractor } from './unsupported-interactor.ts';
5+
6+
// The stub's members are what a composing platform does NOT override, so each platform's own
7+
// interactor test proves its own list through its composed interactor. This is the factory's
8+
// behaviour shared by every owner that composes it: one platform-side copy of each case would
9+
// assert the shared production module twice without adding an owner.
10+
const REQUIRED_OPERATIONS = [
11+
'open',
12+
'openDevice',
13+
'close',
14+
'tap',
15+
'longPress',
16+
'focus',
17+
'type',
18+
'fill',
19+
'scroll',
20+
'screenshot',
21+
'snapshot',
22+
'back',
23+
'setOrientation',
24+
'setSetting',
25+
] as const;
26+
27+
test('the factory covers the whole required interactor surface', () => {
28+
const interactor = createUnsupportedInteractor('Vega OS');
29+
30+
assert.deepEqual(Object.keys(interactor).sort(), [...REQUIRED_OPERATIONS].sort());
31+
});
32+
33+
test('every required operation rejects as unsupported and names itself and the platform', async () => {
34+
const interactor = createUnsupportedInteractor('web');
35+
36+
for (const operation of REQUIRED_OPERATIONS) {
37+
const call = interactor[operation] as () => Promise<unknown>;
38+
await expectUnsupported(call, operation, 'web');
39+
}
40+
});
41+
42+
test('the label is per-instance, so two owners refuse with their own wording', async () => {
43+
const web = createUnsupportedInteractor('web').setSetting as () => Promise<unknown>;
44+
const vega = createUnsupportedInteractor('Vega OS').setSetting as () => Promise<unknown>;
45+
46+
await expectUnsupported(web, 'setSetting', 'web');
47+
await expectUnsupported(vega, 'setSetting', 'Vega OS');
48+
});
49+
50+
// A fused double-click has no shared-series stand-in, the system buttons ride fact-gated binders,
51+
// and neither the clipboard nor a TV remote has a shared fallback either: an absent member is the
52+
// owner's absence, a throw would fake one. The web and Vega fact sets refuse these operations, so
53+
// their binders must never resolve a member this stub carries.
54+
test('operations with no shared fallback are left undefined, not denied', () => {
55+
const interactor = createUnsupportedInteractor('Vega OS');
56+
57+
assert.equal(interactor.doubleTap, undefined);
58+
assert.equal(interactor.home, undefined);
59+
assert.equal(interactor.appSwitcher, undefined);
60+
assert.equal(interactor.readClipboard, undefined);
61+
assert.equal(interactor.writeClipboard, undefined);
62+
assert.equal(interactor.tvRemote, undefined);
63+
});
64+
65+
test('a refusal carries no partial result the caller could mistake for success', async () => {
66+
const interactor = createUnsupportedInteractor('Linux desktop');
67+
68+
await assert.rejects(
69+
async () => await interactor.snapshot(),
70+
(error: unknown) =>
71+
error instanceof AppError && error.message.endsWith('supported on Linux desktop'),
72+
);
73+
});
74+
75+
async function expectUnsupported(
76+
call: () => Promise<unknown>,
77+
operation: string,
78+
platform: string,
79+
): Promise<void> {
80+
await assert.rejects(
81+
call,
82+
(error: unknown) =>
83+
error instanceof AppError &&
84+
error.code === 'UNSUPPORTED_OPERATION' &&
85+
error.message === `${operation} is not supported on ${platform}`,
86+
);
87+
}

packages/platform-vega/src/unsupported-interactor.ts renamed to packages/contracts/src/unsupported-interactor.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import { AppError } from '@agent-device/kernel/errors';
2-
import type { Interactor } from '@agent-device/contracts/interactor-types';
2+
import type { Interactor } from './interactor-types.ts';
33

4-
// fallow-ignore-next-line code-duplication
4+
/**
5+
* The required `Interactor` surface an owner has not proven, refused one operation at a time.
6+
* A platform composes its interactor from this stub and overrides what its backend serves, so
7+
* what stays here is what that owner's runtime facts refuse. A refusal names the operation and
8+
* the caller's platform label and resolves nothing: a partial result would read as a successful
9+
* interaction over an untouched device. Optional members stay absent, never denied — the fused
10+
* double-click, the system buttons, the clipboard halves, and the TV remote ride fact-gated
11+
* binders, so an absent member is the owner's absence and a denial here would fake a mechanic.
12+
* The `Interactor` return type is the pin: a new required member fails to compile until this
13+
* stub refuses it too.
14+
*/
515
export function createUnsupportedInteractor(platformLabel: string): Interactor {
616
const unsupported = async (operation: string): Promise<never> => {
717
throw new AppError(

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

Lines changed: 0 additions & 72 deletions
This file was deleted.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { isDeepLinkTarget } from '@agent-device/contracts/command';
22
import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types';
3+
import { createUnsupportedInteractor } from '@agent-device/contracts/unsupported-interactor';
34
import type { DeviceInfo } from '@agent-device/kernel/device';
45
import { AppError } from '@agent-device/kernel/errors';
5-
import { createUnsupportedInteractor } from './unsupported-interactor.ts';
66
import { closeVegaApp, openVegaApp, openVegaDevice } from './app-lifecycle.ts';
77
import { pressVegaTvRemote } from './input-actions.ts';
88

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

Lines changed: 0 additions & 53 deletions
This file was deleted.

‎packages/platform-web/src/interactor.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { Interactor } from '@agent-device/contracts/interactor-types';
2+
import { createUnsupportedInteractor } from '@agent-device/contracts/unsupported-interactor';
23
import { AppError } from '@agent-device/kernel/errors';
34
import { stripAtPrefix } from '@agent-device/selectors/interaction-positionals';
45
import { withDiagnosticTimer } from '@agent-device/host-kit/diagnostics';
56
import { resolveWebProviderForHost, type WebProvider } from './index.ts';
67
import type { HostEnvironment } from '@agent-device/kernel/source-value';
7-
import { createUnsupportedInteractor } from './unsupported-interactor.ts';
88

99
export async function createWebInteractor(
1010
host: HostEnvironment,

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

Lines changed: 0 additions & 28 deletions
This file was deleted.

‎scripts/layering/contracts-exports.snapshot.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@
131131
"@agent-device/contracts/tv-remote",
132132
"@agent-device/contracts/tv-remote-runtime",
133133
"@agent-device/contracts/type-text-runtime",
134+
"@agent-device/contracts/unsupported-interactor",
134135
"@agent-device/contracts/viewport-runtime",
135136
"@agent-device/contracts/wait",
136137
"@agent-device/contracts/wait-runtime-plan"

0 commit comments

Comments
 (0)