Skip to content

Commit 857f409

Browse files
committed
fix: keep iOS snapshots fast after relaunch
1 parent a9a34d9 commit 857f409

5 files changed

Lines changed: 291 additions & 2 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,15 @@ extension RunnerTests {
8989
return blocking
9090
}
9191

92-
guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
92+
let context: SnapshotTraversalContext?
93+
do {
94+
context = try makeSnapshotTraversalContext(app: app, options: options)
95+
} catch let failure as SnapshotCaptureFailure where options.interactiveOnly {
96+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_FALLBACK=%@", failure.message)
97+
return snapshotFlatInteractive(app: app, options: options)
98+
}
99+
100+
guard let context else {
93101
return DataPayload(nodes: [], truncated: false)
94102
}
95103

@@ -248,6 +256,82 @@ extension RunnerTests {
248256
return DataPayload(nodes: nodes, truncated: truncated)
249257
}
250258

259+
private func snapshotFlatInteractive(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
260+
let viewport = safeSnapshotViewport(app: app)
261+
var nodes: [SnapshotNode] = [
262+
SnapshotNode(
263+
index: 0,
264+
type: "Application",
265+
label: nonEmptyElementText { app.label },
266+
identifier: nonEmptyElementText { app.identifier },
267+
value: nil,
268+
rect: snapshotRect(from: safely("SNAPSHOT_FLAT_APP_FRAME", CGRect.zero) { app.frame }),
269+
enabled: true,
270+
focused: nil,
271+
selected: nil,
272+
hittable: false,
273+
depth: 0,
274+
parentIndex: nil,
275+
hiddenContentAbove: nil,
276+
hiddenContentBelow: nil
277+
)
278+
]
279+
if options.depth == 0 {
280+
return DataPayload(nodes: nodes, truncated: false)
281+
}
282+
283+
var seen = Set<String>()
284+
let candidates = flatFallbackElements(app: app)
285+
.compactMap { element in
286+
flatSnapshotNode(
287+
element: element,
288+
index: 0,
289+
parentIndex: 0,
290+
viewport: viewport,
291+
options: options
292+
)
293+
}
294+
.filter { node in
295+
let key = "\(node.type)-\(node.label ?? "")-\(node.identifier ?? "")-\(node.value ?? "")-\(node.rect.x)-\(node.rect.y)-\(node.rect.width)-\(node.rect.height)"
296+
if seen.contains(key) { return false }
297+
seen.insert(key)
298+
return true
299+
}
300+
.sorted { left, right in
301+
if left.rect.y != right.rect.y {
302+
return left.rect.y < right.rect.y
303+
}
304+
if left.rect.x != right.rect.x {
305+
return left.rect.x < right.rect.x
306+
}
307+
return left.type < right.type
308+
}
309+
310+
let remaining = max(0, fastSnapshotLimit - nodes.count)
311+
let truncated = candidates.count > remaining
312+
for candidate in candidates.prefix(remaining) {
313+
nodes.append(
314+
SnapshotNode(
315+
index: nodes.count,
316+
type: candidate.type,
317+
label: candidate.label,
318+
identifier: candidate.identifier,
319+
value: candidate.value,
320+
rect: candidate.rect,
321+
enabled: candidate.enabled,
322+
focused: candidate.focused,
323+
selected: candidate.selected,
324+
hittable: candidate.hittable,
325+
depth: 1,
326+
parentIndex: 0,
327+
hiddenContentAbove: nil,
328+
hiddenContentBelow: nil
329+
)
330+
)
331+
}
332+
return DataPayload(nodes: nodes, truncated: truncated)
333+
}
334+
251335
func snapshotRect(from frame: CGRect) -> SnapshotRect {
252336
return SnapshotRect(
253337
x: Double(frame.origin.x),
@@ -714,6 +798,95 @@ extension RunnerTests {
714798
safely("SNAPSHOT_QUERY", [], fetch)
715799
}
716800

801+
private func flatFallbackElements(app: XCUIApplication) -> [XCUIElement] {
802+
let queries: [XCUIElementQuery] = [
803+
app.buttons,
804+
app.cells,
805+
app.collectionViews,
806+
app.images,
807+
app.links,
808+
app.scrollViews,
809+
app.searchFields,
810+
app.secureTextFields,
811+
app.segmentedControls,
812+
app.sliders,
813+
app.staticTexts,
814+
app.switches,
815+
app.tables,
816+
app.textFields,
817+
app.textViews
818+
]
819+
return queries.flatMap { query in
820+
safeSnapshotElementsQuery {
821+
query.allElementsBoundByIndex
822+
}
823+
}
824+
}
825+
826+
private func flatSnapshotNode(
827+
element: XCUIElement,
828+
index: Int,
829+
parentIndex: Int?,
830+
viewport: CGRect,
831+
options: SnapshotOptions
832+
) -> SnapshotNode? {
833+
var node: SnapshotNode?
834+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
835+
if !element.exists { return }
836+
let frame = element.frame
837+
if frame.isNull || frame.isEmpty { return }
838+
let visible = isVisibleInViewport(frame, viewport)
839+
#if os(macOS)
840+
if !visible { return }
841+
#endif
842+
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
843+
let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
844+
let valueText = snapshotValueText(element)
845+
let hasContent = !label.isEmpty || !identifier.isEmpty || valueText != nil
846+
let elementType = element.elementType
847+
let enabled = element.isEnabled
848+
let hittable = visible && enabled && element.isHittable
849+
if options.compact && !hasContent && !hittable && !interactiveTypes.contains(elementType) {
850+
return
851+
}
852+
if let scope = options.scope?.trimmingCharacters(in: .whitespacesAndNewlines), !scope.isEmpty {
853+
let haystack = [label, identifier, valueText ?? ""].joined(separator: "\n")
854+
if !haystack.localizedCaseInsensitiveContains(scope) {
855+
return
856+
}
857+
}
858+
859+
node = SnapshotNode(
860+
index: index,
861+
type: elementTypeName(elementType),
862+
label: label.isEmpty ? nil : label,
863+
identifier: identifier.isEmpty ? nil : identifier,
864+
value: valueText,
865+
rect: snapshotRect(from: frame),
866+
enabled: enabled,
867+
focused: elementHasFocus(element) ? true : nil,
868+
selected: element.isSelected ? true : nil,
869+
hittable: hittable,
870+
depth: 1,
871+
parentIndex: parentIndex,
872+
hiddenContentAbove: nil,
873+
hiddenContentBelow: nil
874+
)
875+
})
876+
if let exceptionMessage {
877+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_IGNORED_EXCEPTION=%@", exceptionMessage)
878+
return nil
879+
}
880+
return node
881+
}
882+
883+
private func nonEmptyElementText(_ read: () -> String) -> String? {
884+
let value = safely("SNAPSHOT_FLAT_TEXT", "") {
885+
read()
886+
}.trimmingCharacters(in: .whitespacesAndNewlines)
887+
return value.isEmpty ? nil : value
888+
}
889+
717890
private func isScrollableContainer(_ snapshot: XCUIElementSnapshot, visible: Bool) -> Bool {
718891
if !visible { return false }
719892
if !Self.scrollContainerTypes.contains(snapshot.elementType) { return false }

src/commands/capture-snapshot.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,10 @@ function formatRecentSnapshotDropWarning(params: {
278278
runtimeNow: number;
279279
}): string | undefined {
280280
const previousSnapshot = params.session?.snapshot;
281+
const samePresentation =
282+
previousSnapshot?.presentationKey === undefined ||
283+
params.snapshot.presentationKey === undefined ||
284+
previousSnapshot.presentationKey === params.snapshot.presentationKey;
281285
const isRecentSnapshot = previousSnapshot
282286
? [params.capturedAt, params.runtimeNow].some((timestamp) => {
283287
const elapsed = timestamp - previousSnapshot.createdAt;
@@ -287,6 +291,7 @@ function formatRecentSnapshotDropWarning(params: {
287291
if (
288292
!params.result.freshness &&
289293
previousSnapshot &&
294+
samePresentation &&
290295
isRecentSnapshot &&
291296
isLikelyStaleSnapshotDrop(previousSnapshot.nodes.length, params.snapshot.nodes.length)
292297
) {

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2761,6 +2761,13 @@ test('open --relaunch on iOS stops runner before close/open', async () => {
27612761
});
27622762

27632763
const calls: string[] = [];
2764+
mockResolveTargetDevice.mockResolvedValue({
2765+
platform: 'ios',
2766+
id: 'ios-device-1',
2767+
name: 'My iPhone',
2768+
kind: 'device',
2769+
booted: true,
2770+
});
27642771
mockStopIosRunner.mockImplementation(async () => {
27652772
calls.push('stop-runner');
27662773
});
@@ -2788,6 +2795,55 @@ test('open --relaunch on iOS stops runner before close/open', async () => {
27882795
expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']);
27892796
});
27902797

2798+
test('open --relaunch on iOS simulator keeps runner while closing app', async () => {
2799+
const sessionStore = makeSessionStore();
2800+
const sessionName = 'ios-simulator-session';
2801+
sessionStore.set(sessionName, {
2802+
...makeSession(sessionName, {
2803+
platform: 'ios',
2804+
id: 'sim-1',
2805+
name: 'iPhone 17 Pro',
2806+
kind: 'simulator',
2807+
booted: true,
2808+
}),
2809+
appName: 'com.example.app',
2810+
});
2811+
2812+
const calls: string[] = [];
2813+
mockResolveTargetDevice.mockResolvedValue({
2814+
platform: 'ios',
2815+
id: 'sim-1',
2816+
name: 'iPhone 17 Pro',
2817+
kind: 'simulator',
2818+
booted: true,
2819+
});
2820+
mockStopIosRunner.mockImplementation(async () => {
2821+
calls.push('stop-runner');
2822+
});
2823+
mockDispatch.mockImplementation(async (_device, command, positionals) => {
2824+
calls.push(`${command}:${positionals.join(' ')}`);
2825+
return {};
2826+
});
2827+
2828+
const response = await handleSessionCommands({
2829+
req: {
2830+
token: 't',
2831+
session: sessionName,
2832+
command: 'open',
2833+
positionals: [],
2834+
flags: { relaunch: true },
2835+
},
2836+
sessionName,
2837+
logPath: path.join(os.tmpdir(), 'daemon.log'),
2838+
sessionStore,
2839+
invoke: noopInvoke,
2840+
});
2841+
2842+
expect(response).toBeTruthy();
2843+
expect(response?.ok).toBe(true);
2844+
expect(calls).toEqual(['close:com.example.app', 'open:com.example.app']);
2845+
});
2846+
27912847
test('open --relaunch includes timing and waits for iOS runner prewarm', async () => {
27922848
vi.useFakeTimers({ now: 1_000 });
27932849
const sessionStore = makeSessionStore();

src/daemon/handlers/__tests__/snapshot-handler.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,60 @@ test('snapshot warns when recent snapshot node count collapses sharply', async (
502502
}
503503
});
504504

505+
test('snapshot does not warn on expected node drop across presentation modes', async () => {
506+
const sessionStore = makeSessionStore();
507+
const sessionName = 'ios-presentation-drop';
508+
const session = makeSession(sessionName, iosSimulatorDevice);
509+
session.appBundleId = 'com.example.app';
510+
session.snapshot = {
511+
nodes: Array.from({ length: 50 }, (_, index) => ({
512+
ref: `e${index + 1}`,
513+
index,
514+
depth: 0,
515+
type: 'StaticText',
516+
label: `Row ${index + 1}`,
517+
})),
518+
createdAt: Date.now(),
519+
backend: 'xctest',
520+
presentationKey: buildSnapshotPresentationKey({ interactiveOnly: false, compact: false }),
521+
};
522+
sessionStore.set(sessionName, session);
523+
524+
mockDispatch.mockResolvedValue({
525+
nodes: Array.from({ length: 8 }, (_, index) => ({
526+
index,
527+
depth: 0,
528+
type: 'Button',
529+
label: `Action ${index + 1}`,
530+
hittable: true,
531+
})),
532+
truncated: false,
533+
backend: 'xctest',
534+
});
535+
536+
const response = await handleSnapshotCommands({
537+
req: {
538+
token: 't',
539+
session: sessionName,
540+
command: 'snapshot',
541+
positionals: [],
542+
flags: { snapshotInteractiveOnly: true, snapshotCompact: true },
543+
},
544+
sessionName,
545+
logPath: '/tmp/daemon.log',
546+
sessionStore,
547+
});
548+
549+
expect(response?.ok).toBe(true);
550+
if (response?.ok) {
551+
expect(response.data?.warnings ?? []).toEqual(
552+
expect.not.arrayContaining([
553+
expect.stringContaining('Recent snapshots dropped sharply in node count'),
554+
]),
555+
);
556+
}
557+
});
558+
505559
test('snapshot automatically retries stale Android trees after recent navigation', async () => {
506560
const sessionStore = makeSessionStore();
507561
const sessionName = 'android-stale-retries-to-fresh';

src/daemon/handlers/session-open.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { SessionStore } from '../session-store.ts';
1414
import {
1515
IOS_SIMULATOR_POST_CLOSE_SETTLE_MS,
1616
IOS_SIMULATOR_POST_OPEN_SETTLE_MS,
17+
isIosSimulator,
1718
refreshSessionDeviceIfNeeded,
1819
settleIosSimulator,
1920
} from './session-device-utils.ts';
@@ -61,7 +62,7 @@ async function relaunchCloseApp(params: {
6162
context: Parameters<typeof dispatchCommand>[4];
6263
}): Promise<void> {
6364
const { device, closeTarget, outFlag, context } = params;
64-
if (device.platform !== 'android') {
65+
if (device.platform !== 'android' && !isIosSimulator(device)) {
6566
await stopIosRunnerSession(device.id);
6667
}
6768
await dispatchCommand(device, 'close', [closeTarget], outFlag, context);

0 commit comments

Comments
 (0)