Repository navigation
feat(snapshot): publish the viewport a snapshot's rects are measured in #3210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
4747131
feat(snapshot): publish the viewport a snapshot's rects are measured …
thymikee 49afdf4
test(snapshot): prove each Android/viewport producer claim (#3182)
thymikee 76feede
test(platform-apple): pin the viewport an Apple capture publishes (#3…
thymikee cf110a3
test(snapshot): pin viewport publication across runtime, state, diges…
thymikee 308418e
test(core): satisfy the Android provenance pair in the viewport absen…
thymikee a92819c
test(provider): declare the viewport in the published snapshot key se…
thymikee 616c6fa
test(platform-android): move the helper display-pair proof to the cap…
thymikee d6c7485
docs(snapshot): document the viewport a snapshot publishes (#3182)
thymikee 4d4062d
feat(capture-kit): publish the viewport a provider acquisition alread…
thymikee c42c083
fix(kernel): recognise a failed viewport read without its origin (#3182)
thymikee f8b6cfe
fix(platform-apple): publish the viewport only when the fold validate…
thymikee 7026c7b
docs(snapshot): name the producers that actually answer the viewport …
thymikee 7f52420
fix(snapshot): carry a backend's own viewport through the state seam …
thymikee 8a1ee64
fix(platform-android): keep the display read across the re-capture wi…
thymikee a31e682
test(snapshot): prove the empty-screen case each producer can answer …
thymikee 4034c9c
fix(platform-android): read the real display extent around the tree d…
thymikee 79a4654
refactor(kernel): make the viewport invariant a branded type (#3182)
thymikee 2024c0b
refactor(capture-kit): return the validated viewport from the iOS fol…
thymikee 6362634
refactor(platform-android): derive the viewport once in snapshotAndro…
thymikee 8ff568a
docs(snapshot): limit the --raw viewport absence to Apple targets (#3…
thymikee 5a05a3e
fix(commands): keep clickability evidence across the snapshot state m…
thymikee File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
87 changes: 87 additions & 0 deletions
87
...snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/DisplayExtent.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| package com.callstack.agentdevice.snapshothelper; | ||
|
|
||
| import android.content.Context; | ||
| import android.graphics.Rect; | ||
| import android.os.Build; | ||
| import android.util.DisplayMetrics; | ||
| import android.view.Display; | ||
| import android.view.WindowManager; | ||
|
|
||
| /** | ||
| * The physical display the captured bounds were measured on, in physical pixels, in the current | ||
| * rotation (#3182). | ||
| * | ||
| * Node bounds reach the host through {@code AccessibilityNodeInfo.getBoundsInScreen()}, which is | ||
| * absolute screen space across every interactive window, SystemUI's navigation bar included. The | ||
| * extent therefore has to be the real display extent rather than {@code Resources.getSystem()}'s | ||
| * app display size: the latter excludes a persistent navigation bar, so a nav-bar node's rect would | ||
| * land outside the published box. On API 30+ that extent is | ||
| * {@code WindowManager.getMaximumWindowMetrics().getBounds()}; below it, {@code | ||
| * Display.getRealMetrics}, which likewise carries the full panel and not the app-usable inset. | ||
| * | ||
| * The caller reads this twice around the tree dump and publishes only what {@link #whenBothAgree} | ||
| * returns. A rotation landing inside the dump changes the extent between the reads, and the capture | ||
| * then says nothing about its box rather than pairing one rotation's bounds with the other's | ||
| * dimensions. A display this process cannot address answers with null. Either way the host publishes | ||
| * the absence it reads as unknown, and the host's own guard is what refuses a zero extent. | ||
| */ | ||
| final class DisplayExtent { | ||
| private DisplayExtent() {} | ||
|
|
||
| /** The extent one read of the display answered with, in the current rotation. */ | ||
| static final class Extent { | ||
| final int width; | ||
| final int height; | ||
|
|
||
| Extent(int width, int height) { | ||
| this.width = width; | ||
| this.height = height; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * The extent of the display {@code context} is attached to, or null when this process could not | ||
| * address it. A zero extent is passed through as read: refusing an unusable box is the host's job, | ||
| * and {@code snapshotViewportSizeFrom} already refuses one, so a second refusal here would only | ||
| * be a second place that decides what a usable display is. | ||
| */ | ||
| static Extent read(Context context) { | ||
| try { | ||
| WindowManager windowManager = | ||
| (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); | ||
| if (windowManager == null) { | ||
| return null; | ||
| } | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { | ||
| Rect bounds = windowManager.getMaximumWindowMetrics().getBounds(); | ||
| return new Extent(bounds.width(), bounds.height()); | ||
| } | ||
| return legacyExtent(windowManager); | ||
| } catch (Throwable error) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| @SuppressWarnings("deprecation") | ||
| private static Extent legacyExtent(WindowManager windowManager) { | ||
| Display display = windowManager.getDefaultDisplay(); | ||
| DisplayMetrics metrics = new DisplayMetrics(); | ||
| display.getRealMetrics(metrics); | ||
| return new Extent(metrics.widthPixels, metrics.heightPixels); | ||
| } | ||
|
|
||
| /** | ||
| * The extent both reads of one capture agreed on, or null when either read failed or the display | ||
| * changed underneath the tree dump. Omitting the viewport on disagreement is what keeps the | ||
| * published box a fact about the capture rather than about two different rotations. | ||
| */ | ||
| static Extent whenBothAgree(Extent before, Extent after) { | ||
| if (before == null || after == null) { | ||
| return null; | ||
| } | ||
| if (before.width != after.width || before.height != after.height) { | ||
| return null; | ||
| } | ||
| return before; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
66 changes: 66 additions & 0 deletions
66
...shot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/DisplayExtentTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package com.callstack.agentdevice.snapshothelper; | ||
|
|
||
| public final class DisplayExtentTest { | ||
| private DisplayExtentTest() {} | ||
|
|
||
| private static final DisplayExtent.Extent PORTRAIT = new DisplayExtent.Extent(1080, 2400); | ||
| private static final DisplayExtent.Extent LANDSCAPE = new DisplayExtent.Extent(2400, 1080); | ||
|
|
||
| static void run() { | ||
| assertTwoReadsOfTheSameDisplayPublishItsExtent(); | ||
| assertADisplayThatMovedUnderTheDumpPublishesNothing(); | ||
| assertAReadThatFailedPublishesNothing(); | ||
| } | ||
|
|
||
| private static void assertTwoReadsOfTheSameDisplayPublishItsExtent() { | ||
| // The rotation matters: a landscape capture publishes the box its bounds are actually measured | ||
| // in, which is why the extent is read per capture rather than kept across a helper session. | ||
| assertExtent( | ||
| DisplayExtent.whenBothAgree(PORTRAIT, new DisplayExtent.Extent(1080, 2400)), | ||
| 1080, | ||
| 2400, | ||
| "both reads of one portrait display"); | ||
| assertExtent( | ||
| DisplayExtent.whenBothAgree(LANDSCAPE, LANDSCAPE), | ||
| 2400, | ||
| 1080, | ||
| "both reads of one landscape display"); | ||
| } | ||
|
|
||
| private static void assertADisplayThatMovedUnderTheDumpPublishesNothing() { | ||
| // A rotation landing inside the tree dump would pair that rotation's node bounds with the other | ||
| // rotation's dimensions. The capture then names no box at all rather than a wrong one (#3182). | ||
| assertNull( | ||
| DisplayExtent.whenBothAgree(PORTRAIT, LANDSCAPE), "display rotated mid-dump"); | ||
| assertNull( | ||
| DisplayExtent.whenBothAgree( | ||
| PORTRAIT, new DisplayExtent.Extent(1076, 2400)), | ||
| "display resized mid-dump"); | ||
| } | ||
|
|
||
| private static void assertAReadThatFailedPublishesNothing() { | ||
| // A read that failed on either side of the dump leaves the box unknown: the surviving read says | ||
| // nothing about what the tree between the two was measured against. | ||
| assertNull(DisplayExtent.whenBothAgree(null, PORTRAIT), "read before the dump failed"); | ||
| assertNull(DisplayExtent.whenBothAgree(PORTRAIT, null), "read after the dump failed"); | ||
| assertNull(DisplayExtent.whenBothAgree(null, null), "both reads failed"); | ||
| } | ||
|
|
||
| private static void assertExtent( | ||
| DisplayExtent.Extent extent, int width, int height, String label) { | ||
| if (extent == null) { | ||
| throw new AssertionError("expected an extent for " + label); | ||
| } | ||
| if (extent.width != width || extent.height != height) { | ||
| throw new AssertionError( | ||
| "wrong extent for " + label + ": got " + extent.width + "x" + extent.height); | ||
| } | ||
| } | ||
|
|
||
| private static void assertNull(DisplayExtent.Extent extent, String label) { | ||
| if (extent != null) { | ||
| throw new AssertionError( | ||
| "expected no extent for " + label + " but got " + extent.width + "x" + extent.height); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
84 changes: 84 additions & 0 deletions
84
packages/capture-kit/src/snapshot/__tests__/ios-snapshot-runtime-viewport.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { test } from 'vitest'; | ||
| import { createIosSnapshotAcquisition } from '@agent-device/capture-kit/ios-snapshot-acquisition'; | ||
| import { presentIosSnapshotAcquisition } from '../ios-snapshot-runtime.ts'; | ||
| import type { IosViewportEvidence } from '@agent-device/contracts/ios-snapshot'; | ||
| import { AppError } from '@agent-device/kernel/errors'; | ||
| import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; | ||
|
|
||
| // #3182: the provider acquisitions — the simulator AX bridge, an Appium page source, a Limrun tree — | ||
| // already resolve the box their nodes are measured in, and this presenter is the one seam that turns | ||
| // an acquisition into a snapshot result. Publishing nothing here left the default local-iOS producer | ||
| // without a viewport while it held the evidence, which is the issue's first `Done when` line. | ||
|
|
||
| const SCREEN = { x: 0, y: 0, width: 390, height: 844 }; | ||
|
|
||
| const NODES: readonly RawSnapshotNode[] = [ | ||
| { index: 0, type: 'Application', rect: SCREEN }, | ||
| { index: 1, parentIndex: 0, type: 'Button', label: 'Open', rect: { ...SCREEN, width: 80 } }, | ||
| ]; | ||
|
|
||
| function acquire(viewport: IosViewportEvidence) { | ||
| return createIosSnapshotAcquisition({ | ||
| producer: 'appium-source', | ||
| nodes: NODES, | ||
| viewport, | ||
| lineage: { targetId: 'sim-1:com.example.app' }, | ||
| }); | ||
| } | ||
|
|
||
| test('an acquisition publishes the viewport box the regular fold measured against (#3182)', () => { | ||
| const presented = presentIosSnapshotAcquisition(acquire({ kind: 'reported', rect: SCREEN })); | ||
|
|
||
| assert.deepEqual(presented.viewport, { width: 390, height: 844 }); | ||
| }); | ||
|
|
||
| // The regular fold refuses a capture with no viewport, so absence is only reachable on a raw | ||
| // projection — which is also the projection that validates no box at all. | ||
| test('a raw acquisition publishes no viewport, since nothing checked it (#3182)', () => { | ||
| const presented = presentIosSnapshotAcquisition(acquire({ kind: 'reported', rect: SCREEN }), { | ||
| raw: true, | ||
| }); | ||
|
|
||
| assert.equal('viewport' in presented, false); | ||
| }); | ||
|
|
||
| test('a raw acquisition carrying the failed-read box publishes no viewport (#3182)', () => { | ||
| const presented = presentIosSnapshotAcquisition( | ||
| acquire({ | ||
| kind: 'reported', | ||
| rect: { | ||
| x: -Number.MAX_VALUE / 2, | ||
| y: -Number.MAX_VALUE / 2, | ||
| width: Number.MAX_VALUE, | ||
| height: Number.MAX_VALUE, | ||
| }, | ||
| }), | ||
| { raw: true }, | ||
| ); | ||
|
|
||
| assert.equal('viewport' in presented, false); | ||
| }); | ||
|
|
||
| test('a regular acquisition with no viewport evidence still refuses rather than minting one', () => { | ||
| assert.throws( | ||
| () => presentIosSnapshotAcquisition(acquire({ kind: 'missing', reason: 'not-provided' })), | ||
| (error: unknown) => error instanceof AppError && error.code === 'COMMAND_FAILED', | ||
| ); | ||
| }); | ||
|
|
||
| // The issue's second Done-when line: an empty screen still reports the box. The box is a property of | ||
| // the surface the producer read, so a tree that names nothing on it still answers the question. | ||
| test('an empty iOS acquisition still publishes the viewport it read (#3182)', () => { | ||
| const empty = createIosSnapshotAcquisition({ | ||
| producer: 'appium-source', | ||
| nodes: [], | ||
| viewport: { kind: 'reported', rect: SCREEN }, | ||
| lineage: { targetId: 'sim-1:com.example.app' }, | ||
| }); | ||
|
|
||
| const presented = presentIosSnapshotAcquisition(empty); | ||
|
|
||
| assert.deepEqual(presented.nodes, []); | ||
| assert.deepEqual(presented.viewport, { width: 390, height: 844 }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.