Skip to content

Commit 054dcd4

Browse files
authored
refactor(snapshot): type the acquisition producer beside the platform channel (#2000)
* refactor(snapshot): type the acquisition producer beside the platform channel Three producers with different guarantees share the backend: 'xctest' stamp (Apple runner, Appium page-source, limrun element trees), and the 'android' channel conflates the local uiautomator path with Appium trees the same way. Add SnapshotProducer as a required field on SnapshotResult so the compiler enumerates every producer, and carry it into SnapshotState. Types-before-semantics step for #1983; no consumer behavior changes. * refactor(snapshot): make provenance one kernel-owned pair table Review follow-up on #2000: backend and producer were independent unions, so cross-channel pairs type-checked. SnapshotProvenance now owns the legal channel<->producer pairs; SnapshotBackend is its projection, SnapshotResult embeds the strict pair, SnapshotState embeds the optional-producer variant, and buildSnapshotState carries the pair through a narrowing helper so the fields never decorrelate. Negative type-level regression pins that invalid pairs cannot compile.
1 parent dd5dd43 commit 054dcd4

40 files changed

Lines changed: 277 additions & 41 deletions

‎CONTEXT.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ A backend-owned accessibility value before snapshot presentation.
187187
**Snapshot acquisition**:
188188
One backend attempt's raw accessibility nodes and attempt-level capture facts.
189189

190+
**Snapshot producer**:
191+
The acquisition component that produced a snapshot's raw tree — the third identity axis beside
192+
the platform channel and the in-plan capture strategy. Producers on one channel carry different
193+
guarantees, so presentation, scope, and geometry logic keys on the producer, never the channel
194+
alone.
195+
190196
**Presentation options**:
191197
The policy input controlling how one snapshot acquisition becomes a public projection.
192198

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import type {
1212
RawSnapshotNode,
1313
Point,
1414
Rect,
15-
SnapshotBackend,
1615
SnapshotOptions as BaseSnapshotOptions,
16+
SnapshotProvenance,
1717
} from '@agent-device/kernel/snapshot';
1818

1919
export type RunnerContext = {
@@ -239,13 +239,15 @@ export type KeyboardEnterResult =
239239
| Readonly<{ kind: 'android-acknowledged' }>
240240
| Readonly<{ kind: 'harmonyos-acknowledged' }>;
241241

242+
/**
243+
* Every acquisition carries its provenance pair atomically: the channel alone cannot say who
244+
* acquired the tree or which guarantees it carries, and `SnapshotProvenance`
245+
* (`@agent-device/kernel/snapshot`) makes a cross-channel `backend`/`producer` combination
246+
* fail to compile.
247+
*/
242248
export type SnapshotResult = Omit<BackendSnapshotResult, 'backend' | 'nodes'> & {
243249
nodes?: RawSnapshotNode[];
244-
backend: Extract<
245-
SnapshotBackend,
246-
'android' | 'harmonyos-arkui' | 'xctest' | 'linux-atspi' | 'macos-helper' | 'web'
247-
>;
248-
};
250+
} & SnapshotProvenance;
249251

250252
export type Interactor = {
251253
open(
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect, test } from 'vitest';
2+
import {
3+
snapshotStateProvenance,
4+
type SnapshotProvenance,
5+
type SnapshotStateProvenance,
6+
} from './snapshot.ts';
7+
8+
// The negative half of this test is type-level: each @ts-expect-error line fails `pnpm
9+
// typecheck` the moment the provenance table would accept a cross-channel pair again.
10+
test('the provenance table rejects cross-channel pairs at compile time', () => {
11+
const shared: SnapshotProvenance = { backend: 'xctest', producer: 'appium-source' };
12+
// @ts-expect-error the android channel cannot carry the apple-runner producer
13+
const crossChannel: SnapshotProvenance = { backend: 'android', producer: 'apple-runner' };
14+
// @ts-expect-error the web channel has exactly one producer
15+
const foreignProducer: SnapshotProvenance = { backend: 'web', producer: 'limrun-ios-tree' };
16+
// @ts-expect-error a state pair may omit the producer but not carry a foreign one
17+
const stateMismatch: SnapshotStateProvenance = {
18+
backend: 'macos-helper',
19+
producer: 'linux-atspi',
20+
};
21+
const stateWithoutProducer: SnapshotStateProvenance = { backend: 'android' };
22+
23+
expect([shared, crossChannel, foreignProducer, stateMismatch, stateWithoutProducer]).toHaveLength(
24+
5,
25+
);
26+
});
27+
28+
test('snapshotStateProvenance extracts exactly the pair', () => {
29+
expect(snapshotStateProvenance(undefined)).toEqual({});
30+
expect(snapshotStateProvenance({})).toEqual({});
31+
expect(snapshotStateProvenance({ backend: 'xctest', producer: 'limrun-ios-tree' })).toEqual({
32+
backend: 'xctest',
33+
producer: 'limrun-ios-tree',
34+
});
35+
});

‎packages/kernel/src/snapshot.ts‎

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,69 @@ export type SnapshotNode = RawSnapshotNode & {
147147
inheritsIdentifier?: true;
148148
};
149149

150-
export type SnapshotBackend =
151-
| 'xctest'
152-
| 'android'
153-
| 'harmonyos-arkui'
154-
| 'macos-helper'
155-
| 'linux-atspi'
156-
| 'web';
150+
/**
151+
* The channel↔producer pairs that can actually occur. One channel is fed by several producers
152+
* with different guarantees: `xctest` trees come from the local Apple runner, Appium
153+
* page-source XML, or a limrun element tree, and only the runner's output has been through the
154+
* runner's presentation (clip fold, effective geometry, scope). Logic that assumes
155+
* presentation, scope, or geometry guarantees must key on the producer, never on the channel
156+
* alone.
157+
*
158+
* This table is the single owner of both vocabularies: the platform channel
159+
* (`SnapshotBackend` is its `backend` projection) and the acquisition producer (the third
160+
* axis beside the channel and the in-plan capture strategy `SnapshotCaptureBackend`). Every
161+
* carrier embeds the pair atomically — a cross-channel pair does not compile (pinned by
162+
* snapshot-provenance.test.ts).
163+
*/
164+
export type SnapshotProvenance =
165+
| { backend: 'xctest'; producer: 'apple-runner' | 'appium-source' | 'limrun-ios-tree' }
166+
| { backend: 'android'; producer: 'android-uiautomator' | 'appium-source' }
167+
| { backend: 'harmonyos-arkui'; producer: 'harmonyos-uitest' }
168+
| { backend: 'macos-helper'; producer: 'macos-helper' }
169+
| { backend: 'linux-atspi'; producer: 'linux-atspi' }
170+
| { backend: 'web'; producer: 'agent-browser' };
171+
172+
export type SnapshotBackend = SnapshotProvenance['backend'];
173+
174+
type OptionalProducerProvenance<Pair> = Pair extends {
175+
backend: infer Backend;
176+
producer: infer Producer;
177+
}
178+
? { backend: Backend; producer?: Producer }
179+
: never;
180+
181+
/**
182+
* The provenance carrier for {@link SnapshotState}: the producer may be absent (legacy states
183+
* and fixtures predate it), but a present pair still has to come from the
184+
* {@link SnapshotProvenance} table — the channel may not carry a foreign producer.
185+
*/
186+
export type SnapshotStateProvenance =
187+
| OptionalProducerProvenance<SnapshotProvenance>
188+
| { backend?: undefined; producer?: undefined };
189+
190+
/**
191+
* Narrows a provenance-carrying value to just its pair without decorrelating the two fields
192+
* (reading `backend` and `producer` separately would lose the pairing for the type system).
193+
*/
194+
export function snapshotStateProvenance(
195+
value: SnapshotStateProvenance | undefined,
196+
): SnapshotStateProvenance {
197+
if (value === undefined || value.backend === undefined) return {};
198+
switch (value.backend) {
199+
case 'xctest':
200+
return { backend: value.backend, producer: value.producer };
201+
case 'android':
202+
return { backend: value.backend, producer: value.producer };
203+
case 'harmonyos-arkui':
204+
return { backend: value.backend, producer: value.producer };
205+
case 'macos-helper':
206+
return { backend: value.backend, producer: value.producer };
207+
case 'linux-atspi':
208+
return { backend: value.backend, producer: value.producer };
209+
case 'web':
210+
return { backend: value.backend, producer: value.producer };
211+
}
212+
}
157213

158214
export function isSnapshotBackend(value: unknown): value is SnapshotBackend {
159215
return (
@@ -179,7 +235,6 @@ export type SnapshotState = {
179235
nodes: SnapshotNode[];
180236
createdAt: number;
181237
truncated?: boolean;
182-
backend?: SnapshotBackend;
183238
snapshotQuality?: SnapshotQualityVerdict;
184239
comparisonSafe?: boolean;
185240
presentationKey?: string;
@@ -189,7 +244,7 @@ export type SnapshotState = {
189244
* occlusion (see core/android-system-surface-disclosure.ts).
190245
*/
191246
systemSurfaceOnly?: boolean;
192-
};
247+
} & SnapshotStateProvenance;
193248

194249
export type SnapshotUnchanged = {
195250
ageMs: number;

‎packages/platform-apple/src/runtime.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ test.each(['frontmost-app', 'desktop', 'menubar'] as const)(
249249
const host = platformRuntimeHostFixture();
250250
const captureSurface = vi.fn(async () => ({
251251
backend: 'macos-helper' as const,
252+
producer: 'macos-helper' as const,
252253
nodes: [],
253254
truncated: false,
254255
}));
@@ -551,6 +552,7 @@ test('the macOS surface branch composes the per-capture signal with the binding
551552
const host = platformRuntimeHostFixture();
552553
const captureSurface = vi.fn<SnapshotRuntimeHost['captureSurface']>(async () => ({
553554
backend: 'macos-helper' as const,
555+
producer: 'macos-helper' as const,
554556
nodes: [],
555557
truncated: false,
556558
}));

‎packages/platform-linux/src/runtime.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ test.each([
7979
async ({ device, legacy }) => {
8080
const captureSurface = vi.fn(async () => ({
8181
backend: 'linux-atspi' as const,
82+
producer: 'linux-atspi' as const,
8283
nodes: [],
8384
truncated: false,
8485
}));
@@ -144,6 +145,7 @@ test.each([
144145
function lifecycleHost(
145146
captureSurface: SnapshotRuntimeHost['captureSurface'] = async () => ({
146147
backend: 'linux-atspi' as const,
148+
producer: 'linux-atspi' as const,
147149
nodes: [],
148150
truncated: false,
149151
}),
@@ -209,6 +211,7 @@ function expectLinuxNavigationAndKeyboardFacts(
209211
test('the Linux surface capture composes the per-capture signal with the request scope signal', async () => {
210212
const captureSurface = vi.fn<SnapshotRuntimeHost['captureSurface']>(async () => ({
211213
backend: 'linux-atspi' as const,
214+
producer: 'linux-atspi' as const,
212215
nodes: [],
213216
truncated: false,
214217
}));
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { expect, test } from 'vitest';
2+
import { createLimrunIosInteractor, type LimrunIosSession } from './ios.ts';
3+
4+
test('limrun iOS snapshot stamps the xctest channel with its own producer', async () => {
5+
const session = {
6+
platform: 'ios',
7+
client: {
8+
elementTree: async () =>
9+
JSON.stringify({
10+
elementType: 'Application',
11+
children: [{ elementType: 'Button', label: 'Continue', enabled: true }],
12+
}),
13+
},
14+
} as unknown as LimrunIosSession;
15+
16+
const result = await createLimrunIosInteractor(session).snapshot();
17+
18+
expect(result.backend).toBe('xctest');
19+
expect(result.producer).toBe('limrun-ios-tree');
20+
expect(result.nodes?.map((node) => node.label)).toEqual([undefined, 'Continue']);
21+
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ class LimrunIosInteractor implements Interactor {
257257
async snapshot(_options?: SnapshotOptions): Promise<SnapshotResult> {
258258
const treeJson = await this.session.client.elementTree();
259259
const parsed = JSON.parse(treeJson) as IosTreeNode | IosTreeNode[];
260-
return { nodes: flattenIosTree(parsed), backend: 'xctest' };
260+
return { nodes: flattenIosTree(parsed), backend: 'xctest', producer: 'limrun-ios-tree' };
261261
}
262262

263263
async back(): Promise<void> {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,11 @@ class WebDriverInteractor implements Interactor {
280280

281281
async snapshot(_options?: SnapshotOptions): Promise<SnapshotResult> {
282282
this.requireSupport('snapshot');
283+
// Spelled as a correlated pair per channel so the SnapshotProvenance union accepts it.
283284
return {
284-
backend: this.backend,
285+
...(this.backend === 'xctest'
286+
? { backend: 'xctest' as const, producer: 'appium-source' as const }
287+
: { backend: 'android' as const, producer: 'appium-source' as const }),
285288
nodes: parseWebDriverSource(await this.client.source()),
286289
};
287290
}

‎src/__tests__/test-utils/snapshot-builders.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
attachRefs,
33
type RawSnapshotNode,
44
type SnapshotState,
5+
type SnapshotStateProvenance,
56
} from '@agent-device/kernel/snapshot';
67

78
export function buildNodes(raw: RawSnapshotNode[]) {
@@ -10,7 +11,9 @@ export function buildNodes(raw: RawSnapshotNode[]) {
1011

1112
export function makeSnapshotState(
1213
raw: RawSnapshotNode[],
13-
overrides?: Partial<SnapshotState>,
14+
// The provenance pair stays correlated: overrides carry it as one value, never as two
15+
// independently typed fields.
16+
overrides?: Omit<Partial<SnapshotState>, 'backend' | 'producer'> & SnapshotStateProvenance,
1417
): SnapshotState {
1518
return {
1619
nodes: attachRefs(raw),

0 commit comments

Comments
 (0)