diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index fb1bcf7bae..90790422cc 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -346,3 +346,30 @@ test('client throws AppError for daemon failures', async () => { }, ); }); + +test('client capture.snapshot preserves visibility metadata from daemon responses', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: { + nodes: [], + truncated: false, + appBundleId: 'com.expensify.chat.dev', + visibility: { + partial: true, + visibleNodeCount: 64, + totalNodeCount: 67, + reasons: ['offscreen-nodes'], + }, + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.capture.snapshot(); + + assert.deepEqual(result.visibility, { + partial: true, + visibleNodeCount: 64, + totalNodeCount: 67, + reasons: ['offscreen-nodes'], + }); +}); diff --git a/src/client-shared.ts b/src/client-shared.ts index 7ddefda980..48a687e889 100644 --- a/src/client-shared.ts +++ b/src/client-shared.ts @@ -185,6 +185,7 @@ export function serializeSnapshotResult(result: CaptureSnapshotResult): Record 0 ? { warnings: result.warnings } : {}), }; } diff --git a/src/client-types.ts b/src/client-types.ts index f3e30a98ac..dadeeca8fe 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -6,7 +6,7 @@ import type { SessionRuntimeHints, } from './daemon/types.ts'; import type { DeviceKind, DeviceTarget, Platform, PlatformSelector } from './utils/device.ts'; -import type { ScreenshotOverlayRef, SnapshotNode } from './utils/snapshot.ts'; +import type { ScreenshotOverlayRef, SnapshotNode, SnapshotVisibility } from './utils/snapshot.ts'; import type { MetroPrepareKind, PrepareMetroRuntimeResult } from './client-metro.ts'; type DaemonTransportMode = 'auto' | 'socket' | 'http'; @@ -254,6 +254,7 @@ export type CaptureSnapshotResult = { truncated: boolean; appName?: string; appBundleId?: string; + visibility?: SnapshotVisibility; warnings?: string[]; identifiers: AgentDeviceIdentifiers; }; diff --git a/src/client.ts b/src/client.ts index f0aa787443..3c9385cffb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -29,6 +29,7 @@ import type { AppOpenOptions, CaptureScreenshotOptions, CaptureSnapshotOptions, + CaptureSnapshotResult, EnsureSimulatorOptions, InternalRequestOptions, MaterializationReleaseOptions, @@ -215,11 +216,16 @@ export function createAgentDeviceClient( const session = resolveSessionName(config.session, options.session); const data = await execute('snapshot', [], options); const appBundleId = readOptionalString(data, 'appBundleId'); + const visibility = + typeof data.visibility === 'object' && data.visibility !== null + ? (data.visibility as CaptureSnapshotResult['visibility']) + : undefined; return { nodes: readSnapshotNodes(data.nodes), truncated: data.truncated === true, appName: readOptionalString(data, 'appName'), appBundleId, + ...(visibility ? { visibility } : {}), warnings: Array.isArray(data.warnings) ? data.warnings.filter((entry): entry is string => typeof entry === 'string') : undefined, diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 72236383a1..bdcd45c00f 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -332,6 +332,60 @@ test('snapshot warns when Android freshness retries still return the previous ro expect(mockDispatch).toHaveBeenCalledTimes(3); }); +test('snapshot response includes normalized visibility metadata', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-visibility'; + sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); + + mockDispatch.mockResolvedValue({ + nodes: [ + { + index: 0, + depth: 0, + type: 'android.widget.ScrollView', + label: 'Messages', + rect: { x: 0, y: 100, width: 390, height: 500 }, + hiddenContentBelow: true, + }, + { + index: 1, + depth: 1, + parentIndex: 0, + type: 'android.widget.Button', + label: 'Visible message', + rect: { x: 0, y: 140, width: 390, height: 48 }, + hittable: true, + }, + ], + truncated: false, + backend: 'android', + analysis: { rawNodeCount: 2, maxDepth: 1 }, + }); + + const response = await handleSnapshotCommands({ + req: { + token: 't', + session: sessionName, + command: 'snapshot', + positionals: [], + flags: { snapshotInteractiveOnly: true }, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + if (response?.ok) { + expect(response.data?.visibility).toEqual({ + partial: true, + visibleNodeCount: 2, + totalNodeCount: 2, + reasons: ['scroll-hidden-below'], + }); + } +}); + test('diff snapshot carries stale-tree warnings for recent Android presses', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-diff-stale-after-press'; diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index 2104b64a4e..99a7b9bfda 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -7,8 +7,10 @@ import { normalizeRef, type RawSnapshotNode, type SnapshotState, + type SnapshotVisibility, } from '../../utils/snapshot.ts'; import { normalizeSnapshotTree } from '../../utils/snapshot-tree.ts'; +import { buildMobileSnapshotPresentation } from '../../utils/mobile-snapshot-semantics.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { ANDROID_FRESHNESS_RETRY_DELAYS_MS, @@ -202,6 +204,41 @@ export function buildSnapshotState( }; } +export function buildSnapshotVisibility(params: { + nodes: SnapshotState['nodes']; + backend?: SnapshotState['backend']; + snapshotRaw?: boolean; +}): SnapshotVisibility { + const { nodes, backend, snapshotRaw } = params; + if (snapshotRaw || backend === 'macos-helper') { + return { + partial: false, + visibleNodeCount: nodes.length, + totalNodeCount: nodes.length, + reasons: [], + }; + } + + const presentation = buildMobileSnapshotPresentation(nodes); + const reasons = new Set(); + if (presentation.hiddenCount > 0) { + reasons.add('offscreen-nodes'); + } + if (presentation.nodes.some((node) => node.hiddenContentAbove)) { + reasons.add('scroll-hidden-above'); + } + if (presentation.nodes.some((node) => node.hiddenContentBelow)) { + reasons.add('scroll-hidden-below'); + } + + return { + partial: reasons.size > 0, + visibleNodeCount: presentation.nodes.length, + totalNodeCount: nodes.length, + reasons: [...reasons], + }; +} + function shapeMacOsSurfaceSnapshot( data: SnapshotData, options: { diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index b0df84ae5d..8ee35dace4 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -2,7 +2,11 @@ import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { buildSnapshotDiff, countSnapshotComparableLines } from '../snapshot-diff.ts'; -import { captureSnapshot, resolveSnapshotScope } from './snapshot-capture.ts'; +import { + buildSnapshotVisibility, + captureSnapshot, + resolveSnapshotScope, +} from './snapshot-capture.ts'; import { buildSnapshotSession, recordIfSession, @@ -60,6 +64,11 @@ export async function handleSnapshotCommands(params: { flags: req.flags, session, }); + const visibility = buildSnapshotVisibility({ + nodes: capture.snapshot.nodes, + backend: capture.snapshot.backend, + snapshotRaw: req.flags?.snapshotRaw, + }); const nextSession = buildSnapshotSession({ session, sessionName, @@ -77,6 +86,7 @@ export async function handleSnapshotCommands(params: { data: { nodes: capture.snapshot.nodes, truncated: capture.snapshot.truncated ?? false, + visibility, ...(warnings.length > 0 ? { warnings } : {}), appName: nextSession.appBundleId ? (nextSession.appName ?? nextSession.appBundleId) diff --git a/src/platforms/android/__tests__/scroll-hints.test.ts b/src/platforms/android/__tests__/scroll-hints.test.ts index 437d22268c..2adc22f7ca 100644 --- a/src/platforms/android/__tests__/scroll-hints.test.ts +++ b/src/platforms/android/__tests__/scroll-hints.test.ts @@ -108,3 +108,150 @@ test('annotateAndroidScrollableContentHints marks bottomed-out scroll areas with assert.equal(nodes[0].hiddenContentAbove, true); assert.equal(nodes[0].hiddenContentBelow, undefined); }); + +test('annotateAndroidScrollableContentHints infers bottomed-out scroll areas from a single aligned block', () => { + const nodes: RawSnapshotNode[] = [ + { + index: 0, + type: 'android.widget.ScrollView', + label: 'Messages', + rect: { x: 0, y: 100, width: 390, height: 500 }, + depth: 0, + }, + { + index: 1, + type: 'android.view.ViewGroup', + rect: { x: 0, y: 100, width: 390, height: 500 }, + depth: 1, + parentIndex: 0, + }, + { + index: 2, + type: 'android.view.ViewGroup', + rect: { x: 0, y: 432, width: 390, height: 168 }, + depth: 2, + parentIndex: 1, + }, + ]; + + const dump = [ + ' com.facebook.react.views.scroll.ReactScrollView{d32a800 VFED.V... ........ 0,0-390,500 #4b2}', + ' com.facebook.react.views.view.ReactViewGroup{77d31ae V.E...... ........ 0,0-390,804 #4b0}', + ' com.facebook.react.views.view.ReactViewGroup{c V.E...... ........ 0,636-390,804 #3}', + ].join('\n'); + + annotateAndroidScrollableContentHints(nodes, dump); + + assert.equal(nodes[0].hiddenContentAbove, true); + assert.equal(nodes[0].hiddenContentBelow, undefined); +}); + +test('annotateAndroidScrollableContentHints infers virtualized scroll coverage without a unique block offset', () => { + const nodes: RawSnapshotNode[] = [ + { + index: 0, + type: 'android.widget.ScrollView', + label: 'Messages', + rect: { x: 0, y: 100, width: 390, height: 500 }, + depth: 0, + }, + { + index: 1, + type: 'android.view.ViewGroup', + rect: { x: 0, y: 100, width: 390, height: 500 }, + depth: 1, + parentIndex: 0, + }, + { + index: 2, + type: 'android.view.ViewGroup', + rect: { x: 0, y: 100, width: 390, height: 143 }, + depth: 2, + parentIndex: 1, + }, + ...Array.from({ length: 11 }, (_value, index) => ({ + index: index + 3, + type: 'android.view.ViewGroup', + rect: { x: 0, y: 243 + index * 192, width: 390, height: 192 }, + depth: 2, + parentIndex: 1, + })), + ]; + + const dump = [ + ' com.facebook.react.views.scroll.ReactScrollView{d32a800 VFED.V... ........ 0,0-390,500 #4b2}', + ' com.facebook.react.views.view.ReactViewGroup{77d31ae V.E...... ........ 0,0-390,853 #4b0}', + ' com.facebook.react.views.view.ReactViewGroup{a V.E...... ........ 0,285-390,477 #1}', + ' com.facebook.react.views.view.ReactViewGroup{b V.E...... ........ 0,477-390,669 #2}', + ' com.facebook.react.views.view.ReactViewGroup{c V.E...... ........ 0,669-390,861 #3}', + ' com.facebook.react.views.view.ReactViewGroup{d V.E...... ........ 0,861-390,1053 #4}', + ' com.facebook.react.views.view.ReactViewGroup{e V.E...... ........ 0,1053-390,1245 #5}', + ' com.facebook.react.views.view.ReactViewGroup{f V.E...... ........ 0,1245-390,1437 #6}', + ].join('\n'); + + annotateAndroidScrollableContentHints(nodes, dump); + + assert.equal(nodes[0].hiddenContentAbove, true); + assert.equal(nodes[0].hiddenContentBelow, true); +}); + +test('annotateAndroidScrollableContentHints keeps shallow offset matching for fully mounted content', () => { + const nodes: RawSnapshotNode[] = [ + { + index: 0, + type: 'android.widget.ScrollView', + label: 'Messages', + rect: { x: 0, y: 100, width: 390, height: 500 }, + depth: 0, + }, + { + index: 1, + type: 'android.view.ViewGroup', + rect: { x: 0, y: 100, width: 390, height: 500 }, + depth: 1, + parentIndex: 0, + }, + { + index: 2, + type: 'android.view.ViewGroup', + rect: { x: 0, y: 100, width: 390, height: 100 }, + depth: 2, + parentIndex: 1, + }, + { + index: 3, + type: 'android.view.ViewGroup', + rect: { x: 0, y: 200, width: 390, height: 180 }, + depth: 2, + parentIndex: 1, + }, + { + index: 4, + type: 'android.view.ViewGroup', + rect: { x: 0, y: 380, width: 390, height: 120 }, + depth: 2, + parentIndex: 1, + }, + { + index: 5, + type: 'android.view.ViewGroup', + rect: { x: 0, y: 500, width: 390, height: 100 }, + depth: 2, + parentIndex: 1, + }, + ]; + + const dump = [ + ' com.facebook.react.views.scroll.ReactScrollView{d32a800 VFED.V... ........ 0,0-390,500 #4b2}', + ' com.facebook.react.views.view.ReactViewGroup{77d31ae V.E...... ........ 0,0-390,520 #4b0}', + ' com.facebook.react.views.view.ReactViewGroup{a V.E...... ........ 0,20-390,120 #1}', + ' com.facebook.react.views.view.ReactViewGroup{b V.E...... ........ 0,120-390,300 #2}', + ' com.facebook.react.views.view.ReactViewGroup{c V.E...... ........ 0,300-390,420 #3}', + ' com.facebook.react.views.view.ReactViewGroup{d V.E...... ........ 0,420-390,520 #4}', + ].join('\n'); + + annotateAndroidScrollableContentHints(nodes, dump); + + assert.equal(nodes[0].hiddenContentAbove, true); + assert.equal(nodes[0].hiddenContentBelow, undefined); +}); diff --git a/src/platforms/android/__tests__/snapshot.test.ts b/src/platforms/android/__tests__/snapshot.test.ts index f1091cd6ba..7bb6d0007c 100644 --- a/src/platforms/android/__tests__/snapshot.test.ts +++ b/src/platforms/android/__tests__/snapshot.test.ts @@ -302,7 +302,7 @@ test('snapshotAndroid preserves hidden scroll content hints in interactive snaps }); const result = await snapshotAndroid(device, { interactiveOnly: true }); - const scrollArea = result.nodes.find((node) => node.label === 'Messages'); + const scrollArea = result.nodes.find((node) => node.type === 'android.widget.ScrollView'); assert.ok(scrollArea); assert.equal(scrollArea?.hiddenContentAbove, true); @@ -390,9 +390,46 @@ test('snapshotAndroid derives hidden content hints for interactive snapshots fro }); const result = await snapshotAndroid(device, { interactiveOnly: true }); - const scrollArea = result.nodes.find((node) => node.label === 'Messages'); + const scrollArea = result.nodes.find((node) => node.type === 'android.widget.ScrollView'); assert.ok(scrollArea); assert.equal(scrollArea?.hiddenContentAbove, undefined); assert.equal(scrollArea?.hiddenContentBelow, true); }); + +test('snapshotAndroid preserves bottomed-out hidden-above hints in interactive snapshots from a single aligned block', async () => { + const xml = ` + + + + + + + + +`; + const dump = [ + ' com.facebook.react.views.scroll.ReactScrollView{d32a800 VFED.V... ........ 0,0-390,500 #4b2}', + ' com.facebook.react.views.view.ReactViewGroup{77d31ae V.E...... ........ 0,0-390,804 #4b0}', + ' com.facebook.react.views.view.ReactViewGroup{c V.E...... ........ 0,636-390,804 #3}', + ].join('\n'); + + mockRunCmd.mockImplementation(async (_cmd, args) => { + if (args.includes('exec-out')) { + return { exitCode: 0, stdout: xml, stderr: '' }; + } + if (args.includes('dumpsys') && args.includes('activity') && args.includes('top')) { + return { exitCode: 0, stdout: dump, stderr: '' }; + } + throw new Error(`unexpected args: ${args.join(' ')}`); + }); + + const result = await snapshotAndroid(device, { interactiveOnly: true }); + const scrollArea = result.nodes.find( + (node) => node.hiddenContentAbove === true || node.hiddenContentBelow === true, + ); + + assert.ok(scrollArea); + assert.equal(scrollArea?.hiddenContentAbove, true); + assert.equal(scrollArea?.hiddenContentBelow, undefined); +}); diff --git a/src/platforms/android/scroll-hints.ts b/src/platforms/android/scroll-hints.ts index d871d76b05..9b456dccc0 100644 --- a/src/platforms/android/scroll-hints.ts +++ b/src/platforms/android/scroll-hints.ts @@ -68,18 +68,56 @@ function inferHiddenScrollableContent(params: { if (visibleBlocks.length === 0 || nativeScrollView.contentBlocks.length === 0) { return null; } - const offset = estimateScrollOffset(nativeScrollView.contentBlocks, visibleBlocks); + // Virtualized Android lists often mount only the currently visible rows, so coverage gaps + // in the native content tree are the strongest signal. Offset matching remains useful for + // shallow scroll positions where content is still fully mounted and the first block is only + // slightly displaced, which coverage thresholds intentionally treat as inconclusive. + const mountedCoverageHiddenContent = inferMountedCoverageHiddenContent(nativeScrollView); + const offset = + estimateScrollOffset(nativeScrollView.contentBlocks, visibleBlocks) ?? + estimateEdgeAlignedScrollOffset({ + nativeBlocks: nativeScrollView.contentBlocks, + visibleBlocks, + viewportExtent: viewportRect.height, + contentExtent: nativeScrollView.contentExtent, + }); if (offset === null) { - return null; + return mountedCoverageHiddenContent; } const viewportExtent = viewportRect.height; - const hiddenBefore = offset > 16; - const hiddenAfter = offset + viewportExtent < nativeScrollView.contentExtent - 16; + const hiddenBefore = (mountedCoverageHiddenContent?.above ?? false) || offset > 16; + const hiddenAfter = + (mountedCoverageHiddenContent?.below ?? false) || + offset + viewportExtent < nativeScrollView.contentExtent - 16; return { above: hiddenBefore, below: hiddenAfter }; } +function inferMountedCoverageHiddenContent( + nativeScrollView: NativeScrollView, +): { above?: boolean; below?: boolean } | null { + if (nativeScrollView.contentBlocks.length === 0) { + return null; + } + const firstBlock = nativeScrollView.contentBlocks[0]; + const lastBlock = nativeScrollView.contentBlocks[nativeScrollView.contentBlocks.length - 1]; + if (!firstBlock || !lastBlock) { + return null; + } + + const medianBlockSize = + median(nativeScrollView.contentBlocks.map((block) => block.size)) ?? + nativeScrollView.rect.height; + const hiddenAboveThreshold = Math.max(48, Math.round(medianBlockSize * 0.5)); + const hiddenBelowThreshold = Math.max(24, Math.round(medianBlockSize * 0.25)); + const hiddenBefore = firstBlock.start >= hiddenAboveThreshold; + const hiddenAfter = + nativeScrollView.contentExtent - (lastBlock.start + lastBlock.size) >= hiddenBelowThreshold; + + return hiddenBefore || hiddenAfter ? { above: hiddenBefore, below: hiddenAfter } : null; +} + function estimateScrollOffset( nativeBlocks: FlowBlock[], visibleBlocks: FlowBlock[], @@ -111,6 +149,48 @@ function estimateScrollOffset( return sorted[Math.floor(sorted.length / 2)] ?? null; } +function estimateEdgeAlignedScrollOffset(params: { + nativeBlocks: FlowBlock[]; + visibleBlocks: FlowBlock[]; + viewportExtent: number; + contentExtent: number; +}): number | null { + const { nativeBlocks, visibleBlocks, viewportExtent, contentExtent } = params; + const topAlignedOffsets: number[] = []; + const bottomAlignedOffsets: number[] = []; + + for (const nativeBlock of nativeBlocks) { + for (const visibleBlock of visibleBlocks) { + if (!areFlowBlocksComparable(nativeBlock, visibleBlock)) { + continue; + } + const offset = nativeBlock.start - visibleBlock.start; + if (Math.abs(offset) <= 16) { + topAlignedOffsets.push(offset); + } + if (Math.abs(offset + viewportExtent - contentExtent) <= 16) { + bottomAlignedOffsets.push(offset); + } + } + } + + if (bottomAlignedOffsets.length > 0) { + return median(bottomAlignedOffsets); + } + if (topAlignedOffsets.length > 0) { + return median(topAlignedOffsets); + } + return null; +} + +function median(values: number[]): number | null { + if (values.length === 0) { + return null; + } + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.floor(sorted.length / 2)] ?? null; +} + function areFlowBlocksComparable(nativeBlock: FlowBlock, visibleBlock: FlowBlock): boolean { const sizeTolerance = Math.max( 24, diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 80f8c88b10..56fcce8b3f 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -123,7 +123,7 @@ async function dumpActivityTop(device: DeviceInfo): Promise { try { const result = await runCmd('adb', adbArgs(device, ['shell', 'dumpsys', 'activity', 'top']), { allowFailure: true, - timeoutMs: 2_000, + timeoutMs: 8_000, }); const text = `${result.stdout}\n${result.stderr}`.trim(); return text.length > 0 ? text : null; diff --git a/src/utils/__tests__/output.test.ts b/src/utils/__tests__/output.test.ts index 61b1a7eaf8..d9ea8fec62 100644 --- a/src/utils/__tests__/output.test.ts +++ b/src/utils/__tests__/output.test.ts @@ -332,11 +332,47 @@ test('formatSnapshotText renders explicit hidden scroll-area content hints', () }), ); + assert.match(text, /Snapshot: 3 visible nodes/); assert.match(text, /^ @e2 \[scroll-area\] "Messages" \[scrollable\]$/m); assert.match(text, /^ \[content above scroll-area hidden\]$/m); assert.match(text, /^ \[content below scroll-area hidden\]$/m); }); +test('formatSnapshotText prefers payload visibility metadata for partial snapshot headers', () => { + const text = withNoColor(() => + formatSnapshotText({ + nodes: [ + { + ref: 'e1', + index: 0, + depth: 0, + type: 'Window', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + ref: 'e2', + index: 1, + depth: 1, + parentIndex: 0, + type: 'android.widget.Button', + label: 'Visible', + rect: { x: 20, y: 140, width: 160, height: 44 }, + hittable: true, + }, + ], + visibility: { + partial: true, + visibleNodeCount: 2, + totalNodeCount: 5, + reasons: ['offscreen-nodes'], + }, + truncated: false, + }), + ); + + assert.match(text, /Snapshot: 2 visible nodes \(5 total\)/); +}); + test('formatSnapshotText renders hidden scroll-area content hints in flattened output', () => { const text = withNoColor(() => formatSnapshotText( diff --git a/src/utils/output.ts b/src/utils/output.ts index 11948d606a..1ecfd98c66 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { AppError, normalizeError, type NormalizedError } from './errors.ts'; import { buildSnapshotDisplayLines, formatSnapshotLine } from './snapshot-lines.ts'; -import type { SnapshotNode } from './snapshot.ts'; +import type { SnapshotNode, SnapshotVisibility } from './snapshot.ts'; import type { ScreenshotDiffResult } from './screenshot-diff.ts'; import { styleText } from 'node:util'; import { buildMobileSnapshotPresentation } from './mobile-snapshot-semantics.ts'; @@ -65,11 +65,15 @@ export function formatSnapshotText( if (appName) meta.push(`Page: ${appName}`); if (appBundleId) meta.push(`App: ${appBundleId}`); const displayedNodes = visiblePresentation?.nodes ?? nodes; - const hiddenCount = visiblePresentation?.hiddenCount ?? 0; - const header = - hiddenCount > 0 - ? `Snapshot: ${displayedNodes.length} visible nodes (${nodes.length} total)${truncated ? ' (truncated)' : ''}` - : `Snapshot: ${nodes.length} nodes${truncated ? ' (truncated)' : ''}`; + const visibility = + options.raw || backend === 'macos-helper' + ? null + : readSnapshotVisibility(data, visiblePresentation, displayedNodes.length, nodes.length); + const header = visibility?.partial + ? visibility.totalNodeCount > visibility.visibleNodeCount + ? `Snapshot: ${visibility.visibleNodeCount} visible nodes (${visibility.totalNodeCount} total)${truncated ? ' (truncated)' : ''}` + : `Snapshot: ${visibility.visibleNodeCount} visible nodes${truncated ? ' (truncated)' : ''}` + : `Snapshot: ${nodes.length} nodes${truncated ? ' (truncated)' : ''}`; const prefix = meta.length > 0 ? `${meta.join('\n')}\n` : ''; const notices = buildSnapshotNotices(data, nodes, options); const noticesBlock = notices.length > 0 ? `${notices.join('\n')}\n` : ''; @@ -98,6 +102,55 @@ export function formatSnapshotText( return `${prefix}${header}\n${noticesBlock}${lines.join('\n')}${summaryBlock}\n`; } +function readSnapshotVisibility( + data: Record, + visiblePresentation: ReturnType | null, + displayedNodeCount: number, + totalNodeCount: number, +): SnapshotVisibility | null { + const candidate = data.visibility; + if (candidate && typeof candidate === 'object') { + const visibility = candidate as Partial; + if ( + typeof visibility.partial === 'boolean' && + typeof visibility.visibleNodeCount === 'number' && + typeof visibility.totalNodeCount === 'number' && + Array.isArray(visibility.reasons) + ) { + return { + partial: visibility.partial, + visibleNodeCount: visibility.visibleNodeCount, + totalNodeCount: visibility.totalNodeCount, + reasons: visibility.reasons.filter( + (reason): reason is SnapshotVisibility['reasons'][number] => typeof reason === 'string', + ), + }; + } + } + + const hiddenCount = visiblePresentation?.hiddenCount ?? 0; + const hasExplicitHiddenContentHints = visiblePresentation + ? visiblePresentation.nodes.some((node) => node.hiddenContentAbove || node.hiddenContentBelow) + : false; + if (hiddenCount > 0) { + return { + partial: true, + visibleNodeCount: displayedNodeCount, + totalNodeCount, + reasons: ['offscreen-nodes'], + }; + } + if (hasExplicitHiddenContentHints) { + return { + partial: true, + visibleNodeCount: displayedNodeCount, + totalNodeCount: displayedNodeCount, + reasons: [], + }; + } + return null; +} + export function formatSnapshotDiffText(data: Record): string { const baselineInitialized = data.baselineInitialized === true; const summaryRaw = (data.summary ?? {}) as Record; diff --git a/src/utils/snapshot.ts b/src/utils/snapshot.ts index 9ba0035989..6f786be0c9 100644 --- a/src/utils/snapshot.ts +++ b/src/utils/snapshot.ts @@ -53,6 +53,18 @@ export type SnapshotState = { comparisonSafe?: boolean; }; +export type SnapshotVisibilityReason = + | 'offscreen-nodes' + | 'scroll-hidden-above' + | 'scroll-hidden-below'; + +export type SnapshotVisibility = { + partial: boolean; + visibleNodeCount: number; + totalNodeCount: number; + reasons: SnapshotVisibilityReason[]; +}; + export type ScreenshotOverlayRef = { ref: string; label?: string;