Skip to content

Commit 3a4bcbe

Browse files
authored
feat(snapshot): publish the viewport a snapshot's rects are measured in (#3210)
* feat(snapshot): publish the viewport a snapshot's rects are measured in (#3182) Carry a SnapshotViewportSize from each producer through the state, response, serialization, digest view, and client reader, published once at response level and only through the kernel guard that makes a zero-size viewport unrepresentable. Android: the snapshot helper reports the DisplayMetrics extent beside the density it already declares; the host converts the pair to a viewport at the helper-result boundary and keeps only the response fact. Apple: the host publishes the viewport evidence the presentation fold already resolves, with no new runner wire key. * test(snapshot): prove each Android/viewport producer claim (#3182) Kernel: the construction guard and the wire re-read refuse zero, non-finite, and the infinite sentinel as absence. Android: both helper transports read the display pair, snapshotAndroid publishes the display beside an empty tree, the publication adapter carries it, and the backend metadata keeps no second copy. * test(platform-apple): pin the viewport an Apple capture publishes (#3182) The capture publishes the fold's app-window evidence, follows the quality payload the fold prefers, refuses zero/infinite boxes as absence, and the desktop runner publishes none because window-space rects answer to no single box. * test(snapshot): pin viewport publication across runtime, state, digest, client (#3182) Runtime command publishes what its producer measured and omits what it didn't; the session state carries the box so find-path consumers read the same one; the digest survives the tree collapse; serialization emits it once; the client re-reads it through the kernel guard so a broken payload stays absent. * test(core): satisfy the Android provenance pair in the viewport absence case * test(provider): declare the viewport in the published snapshot key set (#3182) The proxied iOS capture now publishes the app-window box the presentation fold resolves; the parity table names it rather than the guard loosening to a size. * test(platform-android): move the helper display-pair proof to the capture test (#3182) parseAndroidSnapshotHelperOutput lives in snapshot-helper-capture.ts, so its viewport claim follows the source topology instead of the legacy aggregation; the capture fixture gains result lines rather than keeping a second output builder. * docs(snapshot): document the viewport a snapshot publishes (#3182) `--json` consumers now read the box the rects are measured in off the response instead of inferring one from the largest rect on screen. commands.md names each producer's answer, the absence rule, and the separation from content-safe gesture bounds. * feat(capture-kit): publish the viewport a provider acquisition already holds (#3182) presentIosSnapshotAcquisition is the one seam that turns an acquisition into a snapshot result for the simulator AX bridge, an Appium source, and a Limrun tree, and it had the viewport evidence in hand while returning no viewport. That left the default local-iOS producer without the field the issue's first Done-when line asks for. Like the runner fold, it publishes only the box the regular projection measured against: raw validates no box at all. * fix(kernel): recognise a failed viewport read without its origin (#3182) The published shape carries no origin, so a wire payload with the CGRectInfinite extents and no coordinates used to mint (0, 0) around them and pass the guard, handing the caller the largest number on the wire as the screen every rect is measured in (#2891). Both viewport gates now read the extents themselves. kernel/record keeps its inlined rect rule and points at the other half of that seam. * fix(platform-apple): publish the viewport only when the fold validated it (#3182) The runner fold published the largest root even on a raw projection, where the engine validates no box at all — reinstating the largest-rect guess #3182 exists to retire under the name of a measured fact, on a payload the regular projection refuses outright. Publication now follows the validated projection, and the absence cases are pinned off the runnerFatal early return. * docs(snapshot): name the producers that actually answer the viewport question (#3182) The contract and user docs claimed a web producer that does not exist: platform-web hands over a tree and never reads a screen, and neither does Linux. They now name the two surfaces that answer and the absences that are real, including raw, which validates no box. The Node client page gains the field, following how readiness was documented. * fix(snapshot): carry a backend's own viewport through the state seam (#3182) normalizeBackendSnapshot returned a backend-built state verbatim, so a producer that named its own keyboard band or viewport box beside that state lost it on the one path where the backend was the authority. The two Android rejection helpers advertise a capture they can never yield and now say never, and the helper's one-read claim is scoped to what the code guarantees: density and extent from one DisplayMetrics, not a metrics sample taken with the dump. Proves the display read answers the geometry-free-tree case the issue came from. * fix(platform-android): keep the display read across the re-capture window (#3182) The re-capture loop #3160 split out of helper capture declared the plain xml+metadata pair, so the display read a usable attempt made was invisible to the caller while it travelled inside the object. Declaring the capture type makes the viewport the loop already carries part of its contract. * test(snapshot): prove the empty-screen case each producer can answer (#3182) The issue's second Done-when line is a capture of an empty screen that still reports the box. On the provider acquisition seam that is now a test: the fold runs with no nodes and the reported viewport still reaches the result. * fix(platform-android): read the real display extent around the tree dump (#3182) Resources.getSystem() reports the app display, which excludes a persistent navigation bar, while the tree's getBoundsInScreen rects span the full panel. Read the real extent instead (WindowManager.getMaximumWindowMetrics on API 30+, Display.getRealMetrics below) before and after each dump on both the one-shot and the session path, and publish it only when the two reads agree so a rotation mid-dump omits the viewport instead of pairing one rotation's bounds with the other's dimensions. * refactor(kernel): make the viewport invariant a branded type (#3182) A plain structural { width, height } accepted the zero the type claimed was unrepresentable, and readSnapshotViewportSize stood as a second constructor beside snapshotViewportSizeFrom. Brand the type with a token only kernel/rect imports, so a literal with a zero in it stops being assignable, and let the reader delegate to snapshotViewportSizeFrom so one guard decides what a usable box is. Test fixtures now mint their viewport through the guard, which is the mutation that proves the brand bites. * refactor(capture-kit): return the validated viewport from the iOS fold (#3182) The engine already resolves the box the regular projection validates against; hand it over on the presentation result and publication instead of making snapshot-presentation.ts and ios-snapshot-runtime.ts each re-derive the rule that regular validates a box and raw does not. Callers now only pass the returned rect through the shared construction guard, so the rule lives with its owner. * refactor(platform-android): derive the viewport once in snapshotAndroid (#3182) The capture carrier held a pre-derived viewport beside its metadata, so every re-capture hop had to thread the sibling through; a second copy of the display pair also meant two lifetimes for one fact. Carry the raw helper metadata on AndroidUiHierarchyCapture instead and answer the viewport question only in snapshotAndroid, through the shared guard, so both the healthy and the presentation-failed return publish the same box the tree was measured against. * docs(snapshot): limit the --raw viewport absence to Apple targets (#3182) Android publishes its viewport under --raw too: the display the helper reads is the same screen the raw rects are measured on. Only the Apple projection validates no box, so only there does --raw omit viewport. * fix(commands): keep clickability evidence across the snapshot state merge (#3182) The state seam added the backend's keyboard and viewport facts to the state without carrying the private clickability evidence over, and that evidence is retained by object identity. Android Maestro taps then read `exact-evidence-not-retained`, skip clickable-first ordering, and tap the document-order duplicate of a requested id — the Android smoke lane's no-index `tapOn` failure, which is this PR's, not a flake. The mutation that makes the new test fail: remove the copy and the evidence read on the published result is undefined.
1 parent 97aa811 commit 3a4bcbe

45 files changed

Lines changed: 1219 additions & 32 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.callstack.agentdevice.snapshothelper;
2+
3+
import android.content.Context;
4+
import android.graphics.Rect;
5+
import android.os.Build;
6+
import android.util.DisplayMetrics;
7+
import android.view.Display;
8+
import android.view.WindowManager;
9+
10+
/**
11+
* The physical display the captured bounds were measured on, in physical pixels, in the current
12+
* rotation (#3182).
13+
*
14+
* Node bounds reach the host through {@code AccessibilityNodeInfo.getBoundsInScreen()}, which is
15+
* absolute screen space across every interactive window, SystemUI's navigation bar included. The
16+
* extent therefore has to be the real display extent rather than {@code Resources.getSystem()}'s
17+
* app display size: the latter excludes a persistent navigation bar, so a nav-bar node's rect would
18+
* land outside the published box. On API 30+ that extent is
19+
* {@code WindowManager.getMaximumWindowMetrics().getBounds()}; below it, {@code
20+
* Display.getRealMetrics}, which likewise carries the full panel and not the app-usable inset.
21+
*
22+
* The caller reads this twice around the tree dump and publishes only what {@link #whenBothAgree}
23+
* returns. A rotation landing inside the dump changes the extent between the reads, and the capture
24+
* then says nothing about its box rather than pairing one rotation's bounds with the other's
25+
* dimensions. A display this process cannot address answers with null. Either way the host publishes
26+
* the absence it reads as unknown, and the host's own guard is what refuses a zero extent.
27+
*/
28+
final class DisplayExtent {
29+
private DisplayExtent() {}
30+
31+
/** The extent one read of the display answered with, in the current rotation. */
32+
static final class Extent {
33+
final int width;
34+
final int height;
35+
36+
Extent(int width, int height) {
37+
this.width = width;
38+
this.height = height;
39+
}
40+
}
41+
42+
/**
43+
* The extent of the display {@code context} is attached to, or null when this process could not
44+
* address it. A zero extent is passed through as read: refusing an unusable box is the host's job,
45+
* and {@code snapshotViewportSizeFrom} already refuses one, so a second refusal here would only
46+
* be a second place that decides what a usable display is.
47+
*/
48+
static Extent read(Context context) {
49+
try {
50+
WindowManager windowManager =
51+
(WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
52+
if (windowManager == null) {
53+
return null;
54+
}
55+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
56+
Rect bounds = windowManager.getMaximumWindowMetrics().getBounds();
57+
return new Extent(bounds.width(), bounds.height());
58+
}
59+
return legacyExtent(windowManager);
60+
} catch (Throwable error) {
61+
return null;
62+
}
63+
}
64+
65+
@SuppressWarnings("deprecation")
66+
private static Extent legacyExtent(WindowManager windowManager) {
67+
Display display = windowManager.getDefaultDisplay();
68+
DisplayMetrics metrics = new DisplayMetrics();
69+
display.getRealMetrics(metrics);
70+
return new Extent(metrics.widthPixels, metrics.heightPixels);
71+
}
72+
73+
/**
74+
* The extent both reads of one capture agreed on, or null when either read failed or the display
75+
* changed underneath the tree dump. Omitting the viewport on disagreement is what keeps the
76+
* published box a fact about the capture rather than about two different rotations.
77+
*/
78+
static Extent whenBothAgree(Extent before, Extent after) {
79+
if (before == null || after == null) {
80+
return null;
81+
}
82+
if (before.width != after.width || before.height != after.height) {
83+
return null;
84+
}
85+
return before;
86+
}
87+
}

‎android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import android.content.res.Resources;
66
import android.os.Bundle;
77
import android.util.Base64;
8+
import android.util.DisplayMetrics;
89
import java.io.BufferedReader;
910
import java.io.File;
1011
import java.io.FileOutputStream;
@@ -77,14 +78,17 @@ public void onStart() {
7778
return;
7879
}
7980
long startedAtMs = System.currentTimeMillis();
81+
DisplayExtent.Extent beforeDump = DisplayExtent.read(getContext());
8082
AccessibilityTreeCapture.Result capture =
8183
captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes);
84+
DisplayExtent.Extent display =
85+
DisplayExtent.whenBothAgree(beforeDump, DisplayExtent.read(getContext()));
8286
writeOutputFile(outputPath, capture.xml);
8387
if (emitChunks) {
8488
emitChunks(capture.xml);
8589
}
8690
result.putString("ok", "true");
87-
putCaptureMetadata(result, capture, System.currentTimeMillis() - startedAtMs);
91+
putCaptureMetadata(result, capture, display, System.currentTimeMillis() - startedAtMs);
8892
finishSafely(0, result);
8993
} catch (Throwable error) {
9094
result.putString("ok", "false");
@@ -114,7 +118,10 @@ private static void putBaseMetadata(
114118
}
115119

116120
private static void putCaptureMetadata(
117-
Bundle result, AccessibilityTreeCapture.Result capture, long elapsedMs) {
121+
Bundle result,
122+
AccessibilityTreeCapture.Result capture,
123+
DisplayExtent.Extent display,
124+
long elapsedMs) {
118125
result.putString("rootPresent", Boolean.toString(capture.rootPresent));
119126
result.putString("captureMode", capture.captureMode);
120127
result.putString("windowCount", Integer.toString(capture.windowCount));
@@ -124,8 +131,17 @@ private static void putCaptureMetadata(
124131
// Physical pixels per dp of the display the bounds above were measured on, from the same
125132
// configuration the framework lays out with (a `wm density` override included), so a host that
126133
// works in dp has the factor beside the pixels instead of a second adb round trip.
127-
result.putString(
128-
"pixelDensity", Float.toString(Resources.getSystem().getDisplayMetrics().density));
134+
DisplayMetrics metrics = Resources.getSystem().getDisplayMetrics();
135+
result.putString("pixelDensity", Float.toString(metrics.density));
136+
// The real extent of the display those bounds are measured in, read around the tree dump and
137+
// published only when both reads agreed, so the host can publish the box the bounds live in
138+
// without reading the tree for it. Published by absence: a display this helper could not
139+
// address, or one that rotated mid-dump, adds no keys, which the host reads as unknown rather
140+
// than as a screen of no size or of another rotation's size (#3182).
141+
if (display != null) {
142+
result.putString("displayWidth", Integer.toString(display.width));
143+
result.putString("displayHeight", Integer.toString(display.height));
144+
}
129145
}
130146

131147
private void runOneShotViewport(Bundle result) {
@@ -232,10 +248,13 @@ private void writeSessionSnapshot(
232248
result.putString("requestId", requestId);
233249
try {
234250
long startedAtMs = System.currentTimeMillis();
251+
DisplayExtent.Extent beforeDump = DisplayExtent.read(getContext());
235252
AccessibilityTreeCapture.Result capture =
236253
captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes);
254+
DisplayExtent.Extent display =
255+
DisplayExtent.whenBothAgree(beforeDump, DisplayExtent.read(getContext()));
237256
result.putString("ok", "true");
238-
putCaptureMetadata(result, capture, System.currentTimeMillis() - startedAtMs);
257+
putCaptureMetadata(result, capture, display, System.currentTimeMillis() - startedAtMs);
239258
result.putString("byteLength", Integer.toString(capture.xml.getBytes(StandardCharsets.UTF_8).length));
240259
SessionResponseWriter.writeSessionResponse(output, result, capture.xml);
241260
} catch (Throwable error) {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.callstack.agentdevice.snapshothelper;
2+
3+
public final class DisplayExtentTest {
4+
private DisplayExtentTest() {}
5+
6+
private static final DisplayExtent.Extent PORTRAIT = new DisplayExtent.Extent(1080, 2400);
7+
private static final DisplayExtent.Extent LANDSCAPE = new DisplayExtent.Extent(2400, 1080);
8+
9+
static void run() {
10+
assertTwoReadsOfTheSameDisplayPublishItsExtent();
11+
assertADisplayThatMovedUnderTheDumpPublishesNothing();
12+
assertAReadThatFailedPublishesNothing();
13+
}
14+
15+
private static void assertTwoReadsOfTheSameDisplayPublishItsExtent() {
16+
// The rotation matters: a landscape capture publishes the box its bounds are actually measured
17+
// in, which is why the extent is read per capture rather than kept across a helper session.
18+
assertExtent(
19+
DisplayExtent.whenBothAgree(PORTRAIT, new DisplayExtent.Extent(1080, 2400)),
20+
1080,
21+
2400,
22+
"both reads of one portrait display");
23+
assertExtent(
24+
DisplayExtent.whenBothAgree(LANDSCAPE, LANDSCAPE),
25+
2400,
26+
1080,
27+
"both reads of one landscape display");
28+
}
29+
30+
private static void assertADisplayThatMovedUnderTheDumpPublishesNothing() {
31+
// A rotation landing inside the tree dump would pair that rotation's node bounds with the other
32+
// rotation's dimensions. The capture then names no box at all rather than a wrong one (#3182).
33+
assertNull(
34+
DisplayExtent.whenBothAgree(PORTRAIT, LANDSCAPE), "display rotated mid-dump");
35+
assertNull(
36+
DisplayExtent.whenBothAgree(
37+
PORTRAIT, new DisplayExtent.Extent(1076, 2400)),
38+
"display resized mid-dump");
39+
}
40+
41+
private static void assertAReadThatFailedPublishesNothing() {
42+
// A read that failed on either side of the dump leaves the box unknown: the surviving read says
43+
// nothing about what the tree between the two was measured against.
44+
assertNull(DisplayExtent.whenBothAgree(null, PORTRAIT), "read before the dump failed");
45+
assertNull(DisplayExtent.whenBothAgree(PORTRAIT, null), "read after the dump failed");
46+
assertNull(DisplayExtent.whenBothAgree(null, null), "both reads failed");
47+
}
48+
49+
private static void assertExtent(
50+
DisplayExtent.Extent extent, int width, int height, String label) {
51+
if (extent == null) {
52+
throw new AssertionError("expected an extent for " + label);
53+
}
54+
if (extent.width != width || extent.height != height) {
55+
throw new AssertionError(
56+
"wrong extent for " + label + ": got " + extent.width + "x" + extent.height);
57+
}
58+
}
59+
60+
private static void assertNull(DisplayExtent.Extent extent, String label) {
61+
if (extent != null) {
62+
throw new AssertionError(
63+
"expected no extent for " + label + " but got " + extent.width + "x" + extent.height);
64+
}
65+
}
66+
}

‎android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/SnapshotHelperTestSuite.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ public static void main(String[] args) throws Exception {
88
AccessibilityCaptureStabilizerTest.run();
99
BoundedUiAutomationConnectionTest.run();
1010
GestureViewportReaderTest.run();
11+
DisplayExtentTest.run();
1112
}
1213
}

‎packages/capture-kit/src/ios-snapshot-engine/engine.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ export function publishIosSnapshot(
4646
comparisonIdentity: buildIosSnapshotComparisonIdentity(input, request),
4747
residue:
4848
input.stage === 'acquired' ? [...input.acquisition.residue] : [...input.validation.residue],
49+
...(presentation.validatedViewport
50+
? { validatedViewport: presentation.validatedViewport }
51+
: {}),
4952
};
5053
}
5154

@@ -143,6 +146,7 @@ function presentAcquiredSnapshot(
143146
}).nodes;
144147
return {
145148
nodes: compacted.nodes,
149+
validatedViewport: viewport,
146150
...(qualityNodes ? { qualityNodes } : {}),
147151
presentedIndexesBySourceIndex: remapPresentedIndexes(
148152
acquisition.nodes,

‎packages/capture-kit/src/ios-snapshot-engine/runner-presentation.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function presentIosRunnerSnapshot(
3434
);
3535
return {
3636
nodes: compacted.nodes,
37+
...(viewport ? { validatedViewport: viewport } : {}),
3738
...(input.presentation.qualityPayload
3839
? { qualityNodes: [...input.presentation.qualityPayload.nodes] }
3940
: {}),

‎packages/capture-kit/src/ios-snapshot-engine/types.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ export type IosSnapshotPresentationStats = Readonly<{
2626

2727
export type IosSnapshotEnginePresentation = Readonly<{
2828
nodes: RawSnapshotNode[];
29+
/**
30+
* The box the regular projection validated this tree against (#3182). The fold already resolves it
31+
* (`resolveIosViewport` / `resolveViewportEvidence`), so the engine hands it over instead of making
32+
* every caller re-derive "regular validates a box, raw does not". A raw projection validates no box
33+
* and carries none.
34+
*/
35+
validatedViewport?: Rect;
2936
qualityNodes?: RawSnapshotNode[];
3037
presentedIndexesBySourceIndex: ReadonlyMap<number, readonly number[]>;
3138
stats: IosSnapshotPresentationStats;

‎packages/capture-kit/src/snapshot-state.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type SnapshotKeyboardBandFact,
1616
snapshotStateProvenance,
1717
type SnapshotState,
18+
type SnapshotViewportSize,
1819
} from '@agent-device/kernel/snapshot';
1920
import {
2021
annotateCoveredSnapshotNodes,
@@ -51,6 +52,8 @@ export function buildSnapshotState(
5152
targetActivation?: IosTargetActivation;
5253
/** The keyboard band the producer measured, carried to the state the tap guards read (#2660). */
5354
keyboard?: SnapshotKeyboardBandFact;
55+
/** The box the producer measured the rects in (#3182), carried to the response that reads this state. */
56+
viewport?: SnapshotViewportSize;
5457
} & SnapshotCaptureProvenance,
5558
flags:
5659
| (Pick<CommandFlags, 'snapshotDepth' | 'snapshotInteractiveOnly' | 'snapshotRaw'> &
@@ -89,6 +92,7 @@ export function buildSnapshotState(
8992
...(data.systemSurface ? { iosSystemSurfaceBundleId: data.systemSurface.bundleId } : {}),
9093
...(data.targetActivation ? { targetActivation: data.targetActivation } : {}),
9194
...(data.keyboard ? { keyboard: data.keyboard } : {}),
95+
...(data.viewport ? { viewport: data.viewport } : {}),
9296
presentationKey: buildSnapshotPresentationKey(snapshotPresentationOptionsFromFlags(flags)),
9397
// Only broad Android snapshots become freshness baselines. If the user asked for a scoped
9498
// or filtered view, preserve that output contract but avoid pretending it is safe for
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { createIosSnapshotAcquisition } from '@agent-device/capture-kit/ios-snapshot-acquisition';
4+
import { presentIosSnapshotAcquisition } from '../ios-snapshot-runtime.ts';
5+
import type { IosViewportEvidence } from '@agent-device/contracts/ios-snapshot';
6+
import { AppError } from '@agent-device/kernel/errors';
7+
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
8+
9+
// #3182: the provider acquisitions — the simulator AX bridge, an Appium page source, a Limrun tree —
10+
// already resolve the box their nodes are measured in, and this presenter is the one seam that turns
11+
// an acquisition into a snapshot result. Publishing nothing here left the default local-iOS producer
12+
// without a viewport while it held the evidence, which is the issue's first `Done when` line.
13+
14+
const SCREEN = { x: 0, y: 0, width: 390, height: 844 };
15+
16+
const NODES: readonly RawSnapshotNode[] = [
17+
{ index: 0, type: 'Application', rect: SCREEN },
18+
{ index: 1, parentIndex: 0, type: 'Button', label: 'Open', rect: { ...SCREEN, width: 80 } },
19+
];
20+
21+
function acquire(viewport: IosViewportEvidence) {
22+
return createIosSnapshotAcquisition({
23+
producer: 'appium-source',
24+
nodes: NODES,
25+
viewport,
26+
lineage: { targetId: 'sim-1:com.example.app' },
27+
});
28+
}
29+
30+
test('an acquisition publishes the viewport box the regular fold measured against (#3182)', () => {
31+
const presented = presentIosSnapshotAcquisition(acquire({ kind: 'reported', rect: SCREEN }));
32+
33+
assert.deepEqual(presented.viewport, { width: 390, height: 844 });
34+
});
35+
36+
// The regular fold refuses a capture with no viewport, so absence is only reachable on a raw
37+
// projection — which is also the projection that validates no box at all.
38+
test('a raw acquisition publishes no viewport, since nothing checked it (#3182)', () => {
39+
const presented = presentIosSnapshotAcquisition(acquire({ kind: 'reported', rect: SCREEN }), {
40+
raw: true,
41+
});
42+
43+
assert.equal('viewport' in presented, false);
44+
});
45+
46+
test('a raw acquisition carrying the failed-read box publishes no viewport (#3182)', () => {
47+
const presented = presentIosSnapshotAcquisition(
48+
acquire({
49+
kind: 'reported',
50+
rect: {
51+
x: -Number.MAX_VALUE / 2,
52+
y: -Number.MAX_VALUE / 2,
53+
width: Number.MAX_VALUE,
54+
height: Number.MAX_VALUE,
55+
},
56+
}),
57+
{ raw: true },
58+
);
59+
60+
assert.equal('viewport' in presented, false);
61+
});
62+
63+
test('a regular acquisition with no viewport evidence still refuses rather than minting one', () => {
64+
assert.throws(
65+
() => presentIosSnapshotAcquisition(acquire({ kind: 'missing', reason: 'not-provided' })),
66+
(error: unknown) => error instanceof AppError && error.code === 'COMMAND_FAILED',
67+
);
68+
});
69+
70+
// The issue's second Done-when line: an empty screen still reports the box. The box is a property of
71+
// the surface the producer read, so a tree that names nothing on it still answers the question.
72+
test('an empty iOS acquisition still publishes the viewport it read (#3182)', () => {
73+
const empty = createIosSnapshotAcquisition({
74+
producer: 'appium-source',
75+
nodes: [],
76+
viewport: { kind: 'reported', rect: SCREEN },
77+
lineage: { targetId: 'sim-1:com.example.app' },
78+
});
79+
80+
const presented = presentIosSnapshotAcquisition(empty);
81+
82+
assert.deepEqual(presented.nodes, []);
83+
assert.deepEqual(presented.viewport, { width: 390, height: 844 });
84+
});

0 commit comments

Comments
 (0)