Skip to content

Commit cb28f68

Browse files
authored
fix: align web snapshot and capability support (#828)
* fix: align web snapshot and capability support * test: cover desktop snapshot presentation
1 parent bc5726e commit cb28f68

7 files changed

Lines changed: 187 additions & 28 deletions

File tree

src/core/__tests__/capabilities.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ test('web supports only the initial browser interaction slice', () => {
393393
'click',
394394
'close',
395395
'fill',
396+
'focus',
396397
'find',
397398
'get',
398399
'is',
@@ -416,7 +417,6 @@ test('web supports only the initial browser interaction slice', () => {
416417
'clipboard',
417418
'diff',
418419
'fling',
419-
'focus',
420420
'home',
421421
'install',
422422
'install-from-source',

src/core/capabilities.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,20 @@ const synthesisGestureUnsupportedHint = (device: DeviceInfo): string | undefined
4141
const LINUX_DEVICE: KindMatrix = { device: true };
4242
const LINUX_NONE: KindMatrix = {};
4343
const WEB_DEVICE: KindMatrix = { device: true };
44+
const WEB_RUNTIME_COMMANDS = ['open', 'close'] as const;
45+
const WEB_QUERY_COMMANDS = ['find', 'get', 'is', 'screenshot', 'snapshot', 'wait'] as const;
46+
const WEB_INTERACTION_COMMANDS = ['click', 'fill', 'focus', 'press', 'scroll', 'type'] as const;
47+
const WEB_SUPPORTED_COMMANDS = new Set<string>([
48+
...WEB_RUNTIME_COMMANDS,
49+
...WEB_QUERY_COMMANDS,
50+
...WEB_INTERACTION_COMMANDS,
51+
]);
4452
const ALL_DEVICE_COMMAND_CAPABILITY = {
4553
apple: { simulator: true, device: true },
4654
android: { emulator: true, device: true, unknown: true },
4755
linux: LINUX_DEVICE,
4856
} as const satisfies CommandCapability;
49-
const WEB_COMMAND_CAPABILITY = {
50-
...ALL_DEVICE_COMMAND_CAPABILITY,
51-
web: WEB_DEVICE,
52-
} as const satisfies CommandCapability;
53-
const APP_RUNTIME_CAPABILITY = WEB_COMMAND_CAPABILITY;
57+
const APP_RUNTIME_CAPABILITY = ALL_DEVICE_COMMAND_CAPABILITY;
5458
const APP_INVENTORY_CAPABILITY = {
5559
apple: { simulator: true, device: true },
5660
android: { emulator: true, device: true, unknown: true },
@@ -63,7 +67,7 @@ const APP_INSTALL_CAPABILITY = {
6367
supports: isNotMacOs,
6468
} as const satisfies CommandCapability;
6569

66-
const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
70+
const BASE_COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
6771
// Apple simulator-only.
6872
alert: {
6973
// macOS desktop targets report kind=device, so this stays enabled here and the
@@ -130,7 +134,6 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
130134
apple: { simulator: true, device: true },
131135
android: { emulator: true, device: true, unknown: true },
132136
linux: LINUX_DEVICE,
133-
web: WEB_DEVICE,
134137
},
135138
clipboard: {
136139
apple: { simulator: true, device: true },
@@ -154,20 +157,19 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
154157
apple: { simulator: true, device: true },
155158
android: { emulator: true, device: true, unknown: true },
156159
linux: LINUX_DEVICE,
157-
web: WEB_DEVICE,
158160
},
159161
fling: {
160162
apple: { simulator: true, device: true },
161163
android: { emulator: true, device: true, unknown: true },
162164
linux: LINUX_NONE,
163165
},
164-
snapshot: WEB_COMMAND_CAPABILITY,
166+
snapshot: ALL_DEVICE_COMMAND_CAPABILITY,
165167
diff: ALL_DEVICE_COMMAND_CAPABILITY,
166-
screenshot: WEB_COMMAND_CAPABILITY,
167-
wait: WEB_COMMAND_CAPABILITY,
168-
get: WEB_COMMAND_CAPABILITY,
169-
find: WEB_COMMAND_CAPABILITY,
170-
is: WEB_COMMAND_CAPABILITY,
168+
screenshot: ALL_DEVICE_COMMAND_CAPABILITY,
169+
wait: ALL_DEVICE_COMMAND_CAPABILITY,
170+
get: ALL_DEVICE_COMMAND_CAPABILITY,
171+
find: ALL_DEVICE_COMMAND_CAPABILITY,
172+
is: ALL_DEVICE_COMMAND_CAPABILITY,
171173
focus: {
172174
apple: { simulator: true, device: true },
173175
android: { emulator: true, device: true, unknown: true },
@@ -208,7 +210,6 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
208210
apple: { simulator: true, device: true },
209211
android: { emulator: true, device: true, unknown: true },
210212
linux: LINUX_DEVICE,
211-
web: WEB_DEVICE,
212213
},
213214
push: {
214215
apple: { simulator: true },
@@ -237,7 +238,6 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
237238
apple: { simulator: true, device: true },
238239
android: { emulator: true, device: true, unknown: true },
239240
linux: LINUX_DEVICE,
240-
web: WEB_DEVICE,
241241
},
242242
swipe: {
243243
apple: { simulator: true, device: true },
@@ -256,9 +256,28 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
256256
android: { emulator: true, device: true, unknown: true },
257257
linux: LINUX_NONE,
258258
},
259-
type: WEB_COMMAND_CAPABILITY,
259+
type: ALL_DEVICE_COMMAND_CAPABILITY,
260260
};
261261

262+
const COMMAND_CAPABILITY_MATRIX = addWebCommandCapabilities(BASE_COMMAND_CAPABILITY_MATRIX);
263+
264+
function addWebCommandCapabilities(
265+
matrix: Record<string, CommandCapability>,
266+
): Record<string, CommandCapability> {
267+
const result: Record<string, CommandCapability> = {};
268+
for (const [command, capability] of Object.entries(matrix)) {
269+
result[command] = WEB_SUPPORTED_COMMANDS.has(command)
270+
? { ...capability, web: WEB_DEVICE }
271+
: capability;
272+
}
273+
for (const command of WEB_SUPPORTED_COMMANDS) {
274+
if (!(command in matrix)) {
275+
throw new Error(`Web command "${command}" missing from capability matrix`);
276+
}
277+
}
278+
return result;
279+
}
280+
262281
export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): boolean {
263282
const capability = COMMAND_CAPABILITY_MATRIX[command];
264283
if (!capability) return true;

src/utils/__tests__/output.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,71 @@ test('formatSnapshotText summarizes large Android TextView surfaces with preview
121121
assert.match(text, /\[truncated\]/);
122122
});
123123

124+
test('formatSnapshotText keeps web backend output as a full tree', () => {
125+
const text = withNoColor(() =>
126+
formatSnapshotText({
127+
backend: 'web',
128+
nodes: [
129+
{
130+
ref: 'e1',
131+
index: 0,
132+
depth: 0,
133+
role: 'document',
134+
rect: { x: 0, y: 0, width: 390, height: 844 },
135+
},
136+
{
137+
ref: 'e2',
138+
index: 1,
139+
depth: 1,
140+
parentIndex: 0,
141+
role: 'button',
142+
label: 'Offscreen web action',
143+
rect: { x: 0, y: 1200, width: 140, height: 44 },
144+
},
145+
],
146+
truncated: false,
147+
}),
148+
);
149+
150+
assert.match(text, /Snapshot: 2 nodes/);
151+
assert.match(text, /Offscreen web action/);
152+
assert.doesNotMatch(text, /visible nodes/);
153+
assert.doesNotMatch(text, /\[off-screen below\]/);
154+
});
155+
156+
test('formatSnapshotText keeps linux-atspi backend output as a full tree', () => {
157+
const text = withNoColor(() =>
158+
formatSnapshotText({
159+
backend: 'linux-atspi',
160+
nodes: [
161+
{
162+
ref: 'e1',
163+
index: 0,
164+
depth: 0,
165+
type: 'Window',
166+
label: 'Browser',
167+
rect: { x: 0, y: 0, width: 390, height: 844 },
168+
},
169+
{
170+
ref: 'e2',
171+
index: 1,
172+
depth: 1,
173+
parentIndex: 0,
174+
role: 'button',
175+
label: 'Offscreen desktop action',
176+
rect: { x: 0, y: 1200, width: 140, height: 44 },
177+
},
178+
],
179+
truncated: false,
180+
}),
181+
);
182+
183+
assert.match(text, /Snapshot: 2 nodes/);
184+
assert.match(text, /Offscreen desktop action/);
185+
assert.doesNotMatch(text, /visible nodes/);
186+
assert.doesNotMatch(text, /\[off-screen below\]/);
187+
});
188+
124189
test('formatSnapshotText omits unlabeled group wrappers while preserving labeled groups', () => {
125190
const text = withNoColor(() =>
126191
formatSnapshotText({
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { buildSnapshotVisibility } from '../snapshot-visibility.ts';
4+
import type { SnapshotNode } from '../snapshot.ts';
5+
6+
const FULLSCREEN_ROOT = { x: 0, y: 0, width: 390, height: 844 };
7+
const OFFSCREEN_RECT = { x: 0, y: 1200, width: 120, height: 44 };
8+
9+
function nodesWithOffscreenChild(): SnapshotNode[] {
10+
return [
11+
{
12+
ref: 'e1',
13+
index: 0,
14+
depth: 0,
15+
type: 'Window',
16+
role: 'document',
17+
rect: FULLSCREEN_ROOT,
18+
},
19+
{
20+
ref: 'e2',
21+
index: 1,
22+
depth: 1,
23+
parentIndex: 0,
24+
type: 'web.button',
25+
role: 'button',
26+
label: 'Offscreen action',
27+
rect: OFFSCREEN_RECT,
28+
},
29+
];
30+
}
31+
32+
test('buildSnapshotVisibility treats web snapshots as full-tree output', () => {
33+
const visibility = buildSnapshotVisibility({
34+
nodes: nodesWithOffscreenChild(),
35+
backend: 'web',
36+
});
37+
38+
assert.deepEqual(visibility, {
39+
partial: false,
40+
visibleNodeCount: 2,
41+
totalNodeCount: 2,
42+
reasons: [],
43+
});
44+
});
45+
46+
test('buildSnapshotVisibility keeps legacy missing backend snapshots in mobile presentation mode', () => {
47+
const visibility = buildSnapshotVisibility({ nodes: nodesWithOffscreenChild() });
48+
49+
assert.equal(visibility.partial, true);
50+
assert.equal(visibility.visibleNodeCount, 1);
51+
assert.equal(visibility.totalNodeCount, 2);
52+
assert.deepEqual(visibility.reasons, ['offscreen-nodes']);
53+
});

src/utils/output.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import {
66
import { AppError, normalizeError, type NormalizedError } from './errors.ts';
77
import { detectPossibleRepeatedNavSubtree } from './repeated-nav-subtree.ts';
88
import { buildSnapshotDisplayLines, formatSnapshotLine } from './snapshot-lines.ts';
9-
import type { Rect, SnapshotNode, SnapshotUnchanged, SnapshotVisibility } from './snapshot.ts';
9+
import {
10+
isSnapshotBackend,
11+
usesMobileSnapshotPresentation,
12+
type Rect,
13+
type SnapshotNode,
14+
type SnapshotUnchanged,
15+
type SnapshotVisibility,
16+
} from './snapshot.ts';
1017
import type { MovementRange } from './screenshot-diff-ocr.ts';
1118
import type { ScreenshotDiffResult } from './screenshot-diff.ts';
1219
import type { ScreenshotDiffRegion } from './screenshot-diff-regions.ts';
@@ -62,7 +69,8 @@ export function formatSnapshotText(
6269
): string {
6370
const rawNodes = data.nodes;
6471
const nodes = Array.isArray(rawNodes) ? (rawNodes as SnapshotNode[]) : [];
65-
const backend = typeof data.backend === 'string' ? data.backend : undefined;
72+
const backend = isSnapshotBackend(data.backend) ? data.backend : undefined;
73+
const useMobilePresentation = usesMobileSnapshotPresentation(backend);
6674
const helperPresentation = buildAndroidHelperPresentationInput(data, nodes, options);
6775
const prefix = formatSnapshotMetaPrefix(data);
6876
const notices = buildSnapshotNotices(data, nodes, options, helperPresentation);
@@ -72,13 +80,13 @@ export function formatSnapshotText(
7280
return `${prefix}${noticesBlock}${formatUnchangedSnapshotText(unchanged)}\n`;
7381
}
7482
const visiblePresentation =
75-
options.raw || backend === 'macos-helper'
83+
options.raw || !useMobilePresentation
7684
? null
7785
: buildMobileSnapshotPresentation(helperPresentation.nodes);
7886
const truncated = Boolean(data.truncated);
7987
const displayedNodes = visiblePresentation?.nodes ?? nodes;
8088
const visibility =
81-
options.raw || backend === 'macos-helper'
89+
options.raw || !useMobilePresentation
8290
? null
8391
: readSnapshotVisibility(
8492
data,

src/utils/snapshot-visibility.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { buildMobileSnapshotPresentation } from './mobile-snapshot-semantics.ts';
2-
import type { SnapshotBackend, SnapshotState, SnapshotVisibility } from './snapshot.ts';
3-
4-
function isDesktopBackend(backend: SnapshotBackend | undefined): boolean {
5-
return backend === 'macos-helper' || backend === 'linux-atspi';
6-
}
2+
import {
3+
usesMobileSnapshotPresentation,
4+
type SnapshotState,
5+
type SnapshotVisibility,
6+
} from './snapshot.ts';
77

88
export function buildSnapshotVisibility(params: {
99
nodes: SnapshotState['nodes'];
1010
backend?: SnapshotState['backend'];
1111
snapshotRaw?: boolean;
1212
}): SnapshotVisibility {
1313
const { nodes, backend, snapshotRaw } = params;
14-
if (snapshotRaw || isDesktopBackend(backend)) {
14+
if (snapshotRaw || !usesMobileSnapshotPresentation(backend)) {
1515
return {
1616
partial: false,
1717
visibleNodeCount: nodes.length,

src/utils/snapshot.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ export type SnapshotNode = RawSnapshotNode & {
6464

6565
export type SnapshotBackend = 'xctest' | 'android' | 'macos-helper' | 'linux-atspi' | 'web';
6666

67+
export function isSnapshotBackend(value: unknown): value is SnapshotBackend {
68+
return (
69+
value === 'xctest' ||
70+
value === 'android' ||
71+
value === 'macos-helper' ||
72+
value === 'linux-atspi' ||
73+
value === 'web'
74+
);
75+
}
76+
77+
export function usesMobileSnapshotPresentation(backend: SnapshotBackend | undefined): boolean {
78+
return backend === undefined || backend === 'xctest' || backend === 'android';
79+
}
80+
6781
export type SnapshotState = {
6882
nodes: SnapshotNode[];
6983
createdAt: number;

0 commit comments

Comments
 (0)