Skip to content

Commit ce31c8a

Browse files
committed
refactor(ios): route Appium snapshots through engine
1 parent 1826b2e commit ce31c8a

11 files changed

Lines changed: 584 additions & 94 deletions

‎packages/provider-webdriver/src/runtime-session.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export class WebDriverSessionManager {
113113
client,
114114
backend: snapshotBackendForPlatform(prepared.platform),
115115
capabilities,
116+
targetId: device.id,
116117
}),
117118
}),
118119
);

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,26 @@ test('fill refuses empty text as an unsupported clear rather than a vacuous succ
249249
assert.deepEqual(world.transcript, []);
250250
});
251251

252+
test('iOS WebDriver interactor routes snapshots through the acquisition adapter', async () => {
253+
const source = vi.fn(
254+
async () =>
255+
'<AppiumAUT><XCUIElementTypeApplication x="0" y="0" width="390" height="844" /></AppiumAUT>',
256+
);
257+
const interactor = createWebDriverInteractor({
258+
client: { source } as unknown as WebDriverClient,
259+
backend: 'xctest',
260+
capabilities: createCloudWebDriverCapabilities({ provider: 'test', platform: 'ios' }),
261+
targetId: 'ios-1',
262+
});
263+
264+
const result = await interactor.snapshot({ raw: true, depth: 1 });
265+
266+
assert.equal(result.backend, 'xctest');
267+
assert.equal(result.producer, 'appium-source');
268+
assert.equal(source.mock.calls.length, 1);
269+
assert.equal(result.nodes?.[0]?.type, 'XCUIElementTypeApplication');
270+
});
271+
252272
async function runFill(world: ReturnType<typeof createTextEntryWorld>) {
253273
vi.useFakeTimers();
254274
try {

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type { W3CPointerAction, WebDriverClient, WebDriverWindowRect } from './w
2525
import { touchPointer } from './webdriver-gestures.ts';
2626
import { scrollFrameFromWebDriverSource } from './webdriver-scroll-frame.ts';
2727
import { parseWebDriverSource } from './webdriver-source.ts';
28+
import { captureWebDriverIosSnapshot } from './webdriver-ios-snapshot.ts';
2829
import { setWebDriverOrientation } from './webdriver-orientation.ts';
2930

3031
/**
@@ -99,25 +100,34 @@ export type WebDriverInteractorOptions = {
99100
client: WebDriverClient;
100101
backend: Extract<SnapshotResult['backend'], 'android' | 'xctest'>;
101102
capabilities: CloudWebDriverProviderCapabilities;
103+
targetId?: string;
102104
};
103105

104106
export function createWebDriverInteractor(options: WebDriverInteractorOptions): Interactor {
105-
return new WebDriverInteractor(options.client, options.backend, options.capabilities);
107+
return new WebDriverInteractor(
108+
options.client,
109+
options.backend,
110+
options.capabilities,
111+
options.targetId,
112+
);
106113
}
107114

108115
class WebDriverInteractor implements Interactor {
109116
private readonly client: WebDriverClient;
110117
private readonly backend: Extract<SnapshotResult['backend'], 'android' | 'xctest'>;
111118
private readonly capabilities: CloudWebDriverProviderCapabilities;
119+
private readonly targetId: string | undefined;
112120

113121
constructor(
114122
client: WebDriverClient,
115123
backend: Extract<SnapshotResult['backend'], 'android' | 'xctest'>,
116124
capabilities: CloudWebDriverProviderCapabilities,
125+
targetId?: string,
117126
) {
118127
this.client = client;
119128
this.backend = backend;
120129
this.capabilities = capabilities;
130+
this.targetId = targetId;
121131
}
122132

123133
async open(
@@ -292,14 +302,16 @@ class WebDriverInteractor implements Interactor {
292302
await this.client.screenshot(outPath);
293303
}
294304

295-
async snapshot(_options?: SnapshotOptions): Promise<SnapshotResult> {
305+
async snapshot(options?: SnapshotOptions): Promise<SnapshotResult> {
296306
this.requireSupport('snapshot');
307+
if (this.backend === 'xctest') {
308+
return await captureWebDriverIosSnapshot(this.client, options, this.targetId);
309+
}
297310
// Spelled as a correlated pair per channel so the SnapshotProvenance union accepts it.
298311
return {
299-
...(this.backend === 'xctest'
300-
? { backend: 'xctest' as const, producer: 'appium-source' as const }
301-
: { backend: 'android' as const, producer: 'appium-source' as const }),
302-
nodes: parseWebDriverSource(await this.client.source()),
312+
backend: 'android' as const,
313+
producer: 'appium-source' as const,
314+
nodes: parseWebDriverSource(await this.client.source(), { mode: 'legacy-derived' }),
303315
};
304316
}
305317

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import assert from 'node:assert/strict';
2+
import { test, vi } from 'vitest';
3+
import { AppError } from '@agent-device/kernel/errors';
4+
import {
5+
acquireWebDriverIosSnapshot,
6+
captureWebDriverIosSnapshot,
7+
publishWebDriverIosSnapshot,
8+
} from './webdriver-ios-snapshot.ts';
9+
10+
const SOURCE = `<AppiumAUT>
11+
<XCUIElementTypeApplication type="XCUIElementTypeApplication" name="Example" label="Example" enabled="true" visible="true" x="0" y="0" width="390" height="844">
12+
<XCUIElementTypeOther name="Content" x="0" y="0" width="390" height="844">
13+
<XCUIElementTypeButton name="Continue" label="Continue" enabled="true" visible="true" x="24" y="700" width="160" height="48" />
14+
</XCUIElementTypeOther>
15+
</XCUIElementTypeApplication>
16+
</AppiumAUT>`;
17+
18+
test('Appium iOS snapshots acquire facts and publish regular output through the engine', async () => {
19+
const source = vi.fn(async () => SOURCE);
20+
const result = await captureWebDriverIosSnapshot({ source }, undefined, 'cloud-ios-1');
21+
22+
assert.equal(result.backend, 'xctest');
23+
assert.equal(result.producer, 'appium-source');
24+
assert.equal(result.truncated, false);
25+
assert.deepEqual(
26+
result.nodes?.map((node) => [node.type, node.label, node.parentIndex]),
27+
[
28+
['XCUIElementTypeApplication', 'Example', undefined],
29+
['XCUIElementTypeOther', 'Content', 0],
30+
['XCUIElementTypeButton', 'Continue', 1],
31+
],
32+
);
33+
assert.equal(result.nodes?.find((node) => node.label === 'Continue')?.hittable, false);
34+
assert.equal(source.mock.calls.length, 1);
35+
assert.ok(result.warnings?.some((warning) => warning.includes('hittability evidence')));
36+
assert.equal(JSON.stringify(result).includes('iosSnapshot'), false);
37+
assert.equal(JSON.stringify(result).includes('apple-runner'), false);
38+
});
39+
40+
test('Appium iOS options become an engine plan and engine-owned projection', () => {
41+
const acquired = acquireWebDriverIosSnapshot(SOURCE, {
42+
raw: true,
43+
interactiveOnly: true,
44+
depth: 1,
45+
scope: 'Continue',
46+
customActions: true,
47+
});
48+
49+
assert.equal(acquired.input.stage, 'acquired');
50+
assert.deepEqual(acquired.plan.narrowing, {
51+
depth: null,
52+
scope: null,
53+
interactiveOnly: false,
54+
});
55+
assert.deepEqual(acquired.input.acquisition.hint, {
56+
projection: 'raw',
57+
rawTraversalDepth: null,
58+
regularPresentedDepth: null,
59+
interactiveOnly: false,
60+
customActions: true,
61+
acquisitionIntent: 'full',
62+
});
63+
assert.deepEqual(acquired.input.acquisition.lineage, {});
64+
assert.deepEqual(acquired.input.acquisition.viewport, {
65+
kind: 'reported',
66+
rect: { x: 0, y: 0, width: 390, height: 844 },
67+
});
68+
69+
const published = publishWebDriverIosSnapshot(acquired);
70+
assert.deepEqual(
71+
published.result.nodes?.map((node) => [node.type, node.label, node.depth, node.parentIndex]),
72+
[['XCUIElementTypeButton', 'Continue', 0, undefined]],
73+
);
74+
assert.deepEqual(published.publication.comparisonIdentity.lineage, {});
75+
});
76+
77+
test('Appium iOS interactive requests stay provider-unpruned and use engine presentation', () => {
78+
const acquired = acquireWebDriverIosSnapshot(SOURCE, { interactiveOnly: true }, 'ios-1');
79+
80+
assert.equal(acquired.input.acquisition.hint.interactiveOnly, true);
81+
assert.equal(acquired.plan.narrowing.interactiveOnly, false);
82+
const published = publishWebDriverIosSnapshot(acquired);
83+
assert.equal(
84+
published.result.nodes?.some((node) => node.label === 'Continue'),
85+
true,
86+
);
87+
assert.equal(published.result.nodes?.find((node) => node.label === 'Continue')?.hittable, false);
88+
});
89+
90+
test('Appium iOS regular presentation fails typed when page source has no viewport', () => {
91+
const acquired = acquireWebDriverIosSnapshot(
92+
'<AppiumAUT><XCUIElementTypeOther name="Content"><XCUIElementTypeButton name="Continue" enabled="true" x="0" y="0" width="100" height="40" /></XCUIElementTypeOther></AppiumAUT>',
93+
);
94+
95+
assert.deepEqual(acquired.input.acquisition.viewport, {
96+
kind: 'missing',
97+
reason: 'not-provided',
98+
});
99+
assert.deepEqual(acquired.input.acquisition.residue, [
100+
{ kind: 'unavailable-fact', fact: 'hittability' },
101+
{ kind: 'missing-viewport', reason: 'not-provided' },
102+
]);
103+
assert.throws(
104+
() => publishWebDriverIosSnapshot(acquired),
105+
(error: unknown) =>
106+
error instanceof AppError &&
107+
error.details?.reason === 'missing-viewport' &&
108+
!('iosSnapshotEngine' in (error.details ?? {})),
109+
);
110+
});
111+
112+
test('Appium iOS does not promote a non-viewport root rectangle to viewport evidence', () => {
113+
const acquired = acquireWebDriverIosSnapshot(
114+
'<AppiumAUT><XCUIElementTypeOther name="Content" x="0" y="0" width="390" height="844"><XCUIElementTypeButton name="Continue" enabled="true" x="0" y="0" width="100" height="40" /></XCUIElementTypeOther></AppiumAUT>',
115+
);
116+
117+
assert.deepEqual(acquired.input.acquisition.viewport, {
118+
kind: 'missing',
119+
reason: 'not-provided',
120+
});
121+
});
122+
123+
test('Appium iOS regular presentation fails typed when root geometry is invalid', () => {
124+
const acquired = acquireWebDriverIosSnapshot(
125+
'<AppiumAUT><XCUIElementTypeApplication x="0" y="0" width="invalid" height="844"><XCUIElementTypeButton name="Continue" enabled="true" x="0" y="0" width="100" height="40" /></XCUIElementTypeApplication></AppiumAUT>',
126+
);
127+
128+
assert.deepEqual(acquired.input.acquisition.viewport, {
129+
kind: 'missing',
130+
reason: 'invalid',
131+
});
132+
assert.throws(
133+
() => publishWebDriverIosSnapshot(acquired),
134+
(error: unknown) => error instanceof AppError && error.details?.reason === 'invalid-viewport',
135+
);
136+
});
137+
138+
test('Appium iOS truncation is typed and disclosed at response level', async () => {
139+
const result = await captureWebDriverIosSnapshot(
140+
{ source: async () => SOURCE.replace('<AppiumAUT>', '<AppiumAUT truncated="true">') },
141+
{ raw: true },
142+
);
143+
144+
assert.equal(result.truncated, true);
145+
assert.deepEqual(result.warnings, [
146+
'Appium page source does not provide hittability evidence; regular snapshot nodes are not actionable.',
147+
'Appium page source is truncated; the snapshot hierarchy may be incomplete.',
148+
]);
149+
});

0 commit comments

Comments
 (0)