diff --git a/CONTEXT.md b/CONTEXT.md index b9cfbec45..670e13535 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -520,7 +520,9 @@ the observable freshness and failure semantics below before any runtime refactor stabilization is active. - Sparse snapshot quality verdicts are observable failures. Sparse captures must not replace `session.snapshot`, and selector routes should report the sparse verdict instead of treating a - root-only or sparse tree as an empty UI. + root-only or sparse tree as an empty UI. The user-facing `snapshot` dispatch publishes a fallback + screenshot through the response artifact channel (`fallbackScreenshotPath`); internal observations + never do, so a polling wait cannot turn an unreadable screen into one screenshot per poll. - iOS sparse and AX failures are not proof of empty UI. Regular visible snapshots can recover through the capture plan; raw and strict paths preserve failure. `runnerFatal` invalidates the cached target and must never refresh healthy mutation recency. diff --git a/packages/contracts/src/client-capture.ts b/packages/contracts/src/client-capture.ts index c33f62359..7d1ea71fd 100644 --- a/packages/contracts/src/client-capture.ts +++ b/packages/contracts/src/client-capture.ts @@ -43,6 +43,11 @@ export type CaptureSnapshotResult = { visibility?: SnapshotVisibility; unchanged?: SnapshotUnchanged; snapshotDiagnostics?: SnapshotDiagnosticsSummary; + /** + * Screenshot captured automatically when the semantic snapshot was sparse. + * Remote clients receive a materialized local path through the daemon artifact channel. + */ + fallbackScreenshotPath?: string; identifiers: AgentDeviceIdentifiers; /** * ADR 0014: the response-level ref-frame epoch the plain node refs were minted diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index ebcd2800f..f79d35617 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -547,6 +547,7 @@ function optionalSnapshotResponseFields( | 'warnings' | 'snapshotQuality' | 'snapshotDiagnostics' + | 'fallbackScreenshotPath' | 'refsGeneration' > > { @@ -558,6 +559,9 @@ function optionalSnapshotResponseFields( ...readSerializedSnapshotCaptureAnnotations(data), ...(unchanged ? { unchanged: unchanged as CaptureSnapshotResult['unchanged'] } : {}), ...(snapshotDiagnostics ? { snapshotDiagnostics } : {}), + ...(typeof data.fallbackScreenshotPath === 'string' + ? { fallbackScreenshotPath: data.fallbackScreenshotPath } + : {}), // ADR 0014: keep the response-level ref-frame generation on Node.js results // so callers can pin refs (`@e12~s`) before a mutation. ...(typeof data.refsGeneration === 'number' ? { refsGeneration: data.refsGeneration } : {}), diff --git a/src/commands/capture/output.test.ts b/src/commands/capture/output.test.ts index 76ecc9e6d..d66cf815b 100644 --- a/src/commands/capture/output.test.ts +++ b/src/commands/capture/output.test.ts @@ -76,3 +76,21 @@ test('distinct labels across the chain are all preserved', () => { assert.equal(jsonNodes[0]!.label, 'Map'); assert.equal(jsonNodes[1]!.label, 'Anthropic HQ'); }); + +test('snapshot output presents the materialized fallback screenshot path', () => { + const result = { + ...buildResult([]), + fallbackScreenshotPath: '/client/artifacts/snapshot-fallback.png', + }; + + const output = snapshotCliOutput({ result }); + + assert.equal( + (output.jsonData as Record).fallbackScreenshotPath, + '/client/artifacts/snapshot-fallback.png', + ); + assert.match( + output.text ?? '', + /Captured a screenshot of this screen automatically as visual truth: \/client\/artifacts\/snapshot-fallback\.png/, + ); +}); diff --git a/src/daemon/__tests__/request-finalization.test.ts b/src/daemon/__tests__/request-finalization.test.ts index 61d5af796..2a144ecea 100644 --- a/src/daemon/__tests__/request-finalization.test.ts +++ b/src/daemon/__tests__/request-finalization.test.ts @@ -115,6 +115,76 @@ test('finalizeDaemonResponse registers downloadable artifact type', () => { ]); }); +test('finalizeDaemonResponse registers an unexpected output artifact without a client path', () => { + const req: DaemonRequest = { + token: 'token', + session: 'default', + command: 'snapshot', + positionals: [], + meta: { tenantId: 'tenant-a' }, + }; + const response: DaemonResponse = { + ok: true, + data: { + fallbackScreenshotPath: '/tmp/snapshot-fallback.png', + artifacts: [ + { + field: 'fallbackScreenshotPath', + artifactType: 'screenshot', + path: '/tmp/snapshot-fallback.png', + fileName: 'snapshot-fallback.png', + }, + ], + }, + }; + + const finalized = finalizeDaemonResponse(req, response, () => 'artifact-id'); + + expect(finalized).toEqual({ + ok: true, + data: { + fallbackScreenshotPath: '/tmp/snapshot-fallback.png', + artifacts: [ + { + field: 'fallbackScreenshotPath', + artifactType: 'screenshot', + artifactId: 'artifact-id', + fileName: 'snapshot-fallback.png', + localPath: undefined, + }, + ], + }, + }); +}); + +test('finalizeDaemonResponse leaves unrelated local-path artifacts unregistered', () => { + const req: DaemonRequest = { + token: 'token', + session: 'default', + command: 'record', + positionals: ['stop'], + }; + const response: DaemonResponse = { + ok: true, + data: { + artifacts: [ + { + field: 'recordingPath', + artifactType: 'screen-recording', + path: '/tmp/recording.mp4', + fileName: 'recording.mp4', + }, + ], + }, + }; + + const finalized = finalizeDaemonResponse(req, response, () => { + throw new Error('local-only artifact must not be registered'); + }); + + expect(finalized).toEqual(response); +}); + test('finalizeDaemonResponse keeps screenshot path fallback as screenshot artifact type', () => { const req: DaemonRequest = { token: 'token', diff --git a/src/daemon/__tests__/response-views.test.ts b/src/daemon/__tests__/response-views.test.ts index 8ab080fdb..99781c059 100644 --- a/src/daemon/__tests__/response-views.test.ts +++ b/src/daemon/__tests__/response-views.test.ts @@ -51,6 +51,34 @@ test('digest tolerates missing/empty node trees', () => { expect(digest).toMatchObject({ nodeCount: 0, refs: [], truncated: true }); }); +test('snapshot digest keeps sparse fallback screenshot retrieval data', () => { + const artifacts = [ + { + field: 'fallbackScreenshotPath', + artifactType: 'screenshot', + path: '/tmp/snapshot-fallback.png', + fileName: 'snapshot-fallback.png', + }, + ]; + const digest = snapshotView!( + { + nodes: [], + truncated: false, + snapshotQuality: { state: 'sparse', backend: 'private-ax' }, + warnings: ['Use screenshot as visual truth.'], + fallbackScreenshotPath: '/tmp/snapshot-fallback.png', + artifacts, + }, + 'digest', + ); + + expect(digest).toMatchObject({ + fallbackScreenshotPath: '/tmp/snapshot-fallback.png', + warnings: ['Use screenshot as visual truth.'], + artifacts, + }); +}); + const overlayRef = (ref: string, label: string | undefined) => ({ ref, ...(label !== undefined ? { label } : {}), diff --git a/src/daemon/__tests__/sparse-fallback-screenshot.test.ts b/src/daemon/__tests__/sparse-fallback-screenshot.test.ts new file mode 100644 index 000000000..12e871b10 --- /dev/null +++ b/src/daemon/__tests__/sparse-fallback-screenshot.test.ts @@ -0,0 +1,131 @@ +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import type { SnapshotQualityVerdict } from '@agent-device/kernel/snapshot'; +import { makeIosSession, mkdtempForTest } from '../../__tests__/test-utils/index.ts'; +import { SessionStore } from '../session-store.ts'; +import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; + +const dispatchCommandMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../core/dispatch.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchCommand: dispatchCommandMock, + }; +}); + +const SPARSE: SnapshotQualityVerdict = { + state: 'sparse', + backend: 'private-ax', + reason: 'snapshot returned no semantic controls or content', + reasonCode: 'sparse-tree', +}; + +async function scenario() { + const root = await mkdtempForTest('agent-device-sparse-fallback-'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + return { sessionStore, sessionName, logPath: path.join(root, 'daemon.log') }; +} + +/** Snapshot captures answer with the seeded verdict; screenshot captures answer per `screenshot`. */ +function seed( + verdict: SnapshotQualityVerdict, + screenshot: () => Promise> = async () => ({ width: 390, height: 844 }), +) { + dispatchCommandMock.mockReset(); + dispatchCommandMock.mockImplementation(async (_device: unknown, command: string) => + command === 'screenshot' + ? await screenshot() + : { + backend: 'xctest', + truncated: false, + quality: verdict, + nodes: [{ index: 0, depth: 0, type: 'Application', label: 'Demo' }], + }, + ); +} + +async function dispatch(input: Awaited>, internalObservation = false) { + const response = await dispatchSnapshotViaRuntime({ + req: { + command: 'snapshot', + positionals: [], + token: 't', + session: input.sessionName, + ...(internalObservation ? { internal: { observationOnly: true } } : {}), + }, + sessionName: input.sessionName, + logPath: input.logPath, + sessionStore: input.sessionStore, + }); + if (!response.ok) throw new Error('expected ok response'); + return response.data ?? {}; +} + +function screenshotCalls() { + return dispatchCommandMock.mock.calls.filter((call) => call[1] === 'screenshot'); +} + +test('a sparse snapshot captures the screenshot its own remedy asks for and links it', async () => { + const input = await scenario(); + seed(SPARSE); + + const data = await dispatch(input); + const warnings = (data.warnings ?? []) as string[]; + + expect(screenshotCalls()).toHaveLength(1); + expect(data.fallbackScreenshotPath).toMatch(/\.png$/); + expect(data.artifacts).toEqual([ + { + field: 'fallbackScreenshotPath', + artifactType: 'screenshot', + path: data.fallbackScreenshotPath, + fileName: 'snapshot-fallback.png', + }, + ]); + // The verdict's own two lines still stand: the refs are invalid, and a screen that + // publishes nothing is an app defect worth reporting. + expect(warnings.some((line) => line.startsWith('No snapshot backend could read'))).toBe(true); + expect(warnings.some((line) => line.includes('app accessibility bug'))).toBe(true); +}); + +test('a readable snapshot never pays for a screenshot', async () => { + const input = await scenario(); + seed({ state: 'healthy', backend: 'tree' }); + + const data = await dispatch(input); + + expect(screenshotCalls()).toHaveLength(0); + expect(data.fallbackScreenshotPath).toBeUndefined(); + expect(data.artifacts).toBeUndefined(); +}); + +test('internal observations stay silent so a polling wait cannot shoot once per poll', async () => { + const input = await scenario(); + seed(SPARSE); + + const data = await dispatch(input, true); + + expect(screenshotCalls()).toHaveLength(0); + expect(data.fallbackScreenshotPath).toBeUndefined(); + expect(data.artifacts).toBeUndefined(); +}); + +test('a failed fallback screenshot does not fail the snapshot that was asked for', async () => { + const input = await scenario(); + seed(SPARSE, async () => { + throw new Error('screenshot dispatch exploded'); + }); + + const data = await dispatch(input); + const warnings = (data.warnings ?? []) as string[]; + + expect(screenshotCalls()).toHaveLength(1); + expect(data.fallbackScreenshotPath).toBeUndefined(); + expect(data.artifacts).toBeUndefined(); + // The manual remedy is still on the response, so the caller is not left without one. + expect(warnings.some((line) => line.includes('Use screenshot as visual truth'))).toBe(true); +}); diff --git a/src/daemon/request-finalization.ts b/src/daemon/request-finalization.ts index 1fb761e9c..6ca62bcb2 100644 --- a/src/daemon/request-finalization.ts +++ b/src/daemon/request-finalization.ts @@ -128,8 +128,8 @@ function collectPendingArtifacts(req: DaemonRequest, data: DaemonResponseData): artifact && typeof artifact.field === 'string' && typeof artifact.path === 'string' && - typeof artifact.localPath === 'string' && - artifact.localPath.length > 0, + ((typeof artifact.localPath === 'string' && artifact.localPath.length > 0) || + (artifact.field === 'fallbackScreenshotPath' && artifact.artifactType === 'screenshot')), ), ); } diff --git a/src/daemon/response-views.ts b/src/daemon/response-views.ts index 7b1537b0a..608cc2198 100644 --- a/src/daemon/response-views.ts +++ b/src/daemon/response-views.ts @@ -16,7 +16,8 @@ const DIGEST_REF_LIMIT = 12; /** * Token-cheap snapshot digest: the node count plus the first N actionable refs * (hittable and not occluded) with a label, and the cheap top-level signals - * (`truncated`, `visibility`, `snapshotQuality`). The full node tree — the + * (`truncated`, `visibility`, `snapshotQuality`) plus any fallback screenshot + * retrieval handle. The full node tree — the * dominant token sink — is dropped. `full` returns today's shape unchanged * (nothing richer is computed yet). */ @@ -33,6 +34,11 @@ function snapshotView(data: DaemonResponseData, level: ResponseLevel): DaemonRes truncated: data.truncated, ...(data.visibility !== undefined ? { visibility: data.visibility } : {}), ...(data.snapshotQuality !== undefined ? { snapshotQuality: data.snapshotQuality } : {}), + ...(data.warnings !== undefined ? { warnings: data.warnings } : {}), + ...(data.fallbackScreenshotPath !== undefined + ? { fallbackScreenshotPath: data.fallbackScreenshotPath } + : {}), + ...(data.artifacts !== undefined ? { artifacts: data.artifacts } : {}), // #1076 versioned refs: the one-number generation is the pinning signal for // the refs above — cheap, and dropping it would strand auto-pinning clients. ...(data.refsGeneration !== undefined ? { refsGeneration: data.refsGeneration } : {}), diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index 871ae2022..d2fb093b0 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -26,6 +26,7 @@ import { type CapturedSnapshotQuality, } from './snapshot-quality-latch.ts'; import { createDaemonRuntimePolicy } from './runtime-policy.ts'; +import { captureSparseFallbackScreenshot } from './sparse-fallback-screenshot.ts'; import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; import { getRequestSignal } from '../request/cancel.ts'; import { isInteractiveObservation } from './session-action-recorder.ts'; @@ -53,19 +54,29 @@ export async function dispatchSnapshotViaRuntime(params: { customActions: req.flags?.snapshotCustomActions, forceFull: req.flags?.snapshotForceFull, }); - // #1076 versioned refs: the snapshot response is a ref-issuing response, - // so it carries the stored tree's generation ONCE (`refsGeneration`) — - // the node tree itself stays plain `e12` refs (token economy). The - // capture above already stored the next session via setRecord, so the - // store holds the generation these refs were minted from. - const refsGeneration = publishedSnapshotGeneration(req, params.sessionStore.get(sessionName)); + const session = params.sessionStore.get(sessionName); + const refsGeneration = publishedSnapshotGeneration(req, session); // ADR 0014: retain provenance in the immutable operational/ref-frame tree; // project only the published copy so settle and replay keep the full evidence. const publicNodes = stripAndroidSystemChromeProvenance(result.nodes); const publicResult = publicNodes === result.nodes ? result : { ...result, nodes: publicNodes }; + const fallbackScreenshot = await captureSparseFallbackScreenshot({ + req, + session, + sessionName, + logPath: params.logPath, + verdict: result.snapshotQuality, + }); + const published = fallbackScreenshot + ? { + ...publicResult, + fallbackScreenshotPath: fallbackScreenshot.path, + artifacts: [fallbackScreenshot.artifact], + } + : publicResult; return { - data: refsGeneration === undefined ? publicResult : { ...publicResult, refsGeneration }, + data: refsGeneration === undefined ? published : { ...published, refsGeneration }, record: { kind: 'snapshot', nodes: result.nodes.length, diff --git a/src/daemon/sparse-fallback-screenshot.ts b/src/daemon/sparse-fallback-screenshot.ts new file mode 100644 index 000000000..97ddef13d --- /dev/null +++ b/src/daemon/sparse-fallback-screenshot.ts @@ -0,0 +1,87 @@ +import type { SnapshotQualityVerdict } from '@agent-device/kernel/snapshot'; +import { isSparseSnapshotQualityVerdict } from '../snapshot-quality/verdict.ts'; +import { contextFromFlags } from './context.ts'; +import { dispatchScreenshotViaRuntime } from './screenshot-runtime.ts'; +import type { DaemonRequest, SessionState } from './types.ts'; + +export type SparseFallbackScreenshot = { + path: string; + artifact: { + field: 'fallbackScreenshotPath'; + artifactType: 'screenshot'; + path: string; + fileName: string; + }; +}; + +/** + * A sparse verdict means no backend could read the tree while the screen itself still + * renders, so the remedy the verdict already prints — use a screenshot as visual truth — + * is the caller's guaranteed next command. Taking the shot here spends it once, on the + * one path where it is never speculative, instead of charging the caller a second round + * trip to obey advice we authored. + * + * Deliberately hung off the user-facing `snapshot` dispatch, and skipped for internal + * observations: selector resolution, settle, and wait polling capture through + * `captureSnapshot` directly rather than through this runtime command, so a wait polling + * an unreadable screen cannot turn into a screenshot per poll. + */ +export async function captureSparseFallbackScreenshot(params: { + req: DaemonRequest; + session: SessionState | undefined; + sessionName: string; + logPath: string; + verdict: SnapshotQualityVerdict | undefined; +}): Promise { + const session = params.session; + if (!session) return undefined; + if (!isSparseSnapshotQualityVerdict(params.verdict)) return undefined; + if (params.req.internal?.observationOnly === true) return undefined; + + const path = await captureFallbackScreenshotPath({ ...params, session }); + if (path === undefined) return undefined; + return { + path, + artifact: { + field: 'fallbackScreenshotPath', + artifactType: 'screenshot', + path, + fileName: 'snapshot-fallback.png', + }, + }; +} + +async function captureFallbackScreenshotPath(params: { + req: DaemonRequest; + session: SessionState; + sessionName: string; + logPath: string; +}): Promise { + const { req, session } = params; + try { + const data = await dispatchScreenshotViaRuntime({ + session, + sessionName: params.sessionName, + // No caller-supplied destination: the screenshot artifact adapter mints a temp + // path, so the fallback never writes where an explicit `--out` would have. + outputPlacement: 'default', + dispatchContext: contextFromFlags( + params.logPath, + // The request's own flags carry the toolchain selection (xctestrun file, + // derived data, verbosity) this dispatch needs; snapshot-shaped flags are + // inert for a screenshot. + req.flags, + session.appBundleId, + session.trace?.outPath, + req.meta?.requestId, + req.meta, + ), + }); + return typeof data.path === 'string' ? data.path : undefined; + } catch { + // A convenience on an already-degraded path. The sparse verdict's own warning still + // carries the manual remedy, so a failed fallback must not fail the snapshot the + // caller actually asked for. + return undefined; + } +} diff --git a/src/snapshot-quality/__tests__/warnings.test.ts b/src/snapshot-quality/__tests__/warnings.test.ts index 9c431a246..50f825405 100644 --- a/src/snapshot-quality/__tests__/warnings.test.ts +++ b/src/snapshot-quality/__tests__/warnings.test.ts @@ -68,9 +68,27 @@ test('renderSnapshotQualityWarnings rejects semantic targets from a sparse tree' assert.deepEqual(warnings, [ 'No snapshot backend could read this screen (snapshot returned no semantic controls or content). Its refs and selectors are invalid. Use screenshot as visual truth and coordinate taps; retry snapshot after navigating.', + 'This screen publishes no accessibility content at all; assistive technologies see the same empty tree, so it is worth flagging as an app accessibility bug rather than only an automation limitation.', ]); }); +test('sparse warnings blame the app only when the backends reached the screen', () => { + for (const reasonCode of ['ax-rejected', 'budget', 'no-nodes', 'capture-failed'] as const) { + const warnings = renderSnapshotQualityWarnings( + { state: 'sparse', backend: 'tree', reason: 'capture gave up', reasonCode }, + [], + ); + + assert.deepEqual( + warnings, + [ + 'No snapshot backend could read this screen (capture gave up). Its refs and selectors are invalid. Use screenshot as visual truth and coordinate taps; retry snapshot after navigating.', + ], + `${reasonCode} is a limit of this tool, not an app accessibility defect`, + ); + } +}); + const pinned = { state: 'recovered', backend: 'private-ax', diff --git a/src/snapshot-quality/warnings.ts b/src/snapshot-quality/warnings.ts index b7a4b416a..d9dcdb3ab 100644 --- a/src/snapshot-quality/warnings.ts +++ b/src/snapshot-quality/warnings.ts @@ -74,11 +74,24 @@ function stateWarning(verdict: SnapshotQualityVerdict): string[] { 'No snapshot backend could read this screen' + (verdict.reason ? ` (${verdict.reason})` : '') + '. Its refs and selectors are invalid. Use screenshot as visual truth and coordinate taps; retry snapshot after navigating.', + ...appAccessibilityDefectWarning(verdict), ]; } return []; } +/** + * Only `sparse-tree` is evidence about the app: every backend reached the screen and it + * published no semantic content. Other sparse reasons describe capture limits and must + * not be presented as an application accessibility defect. + */ +function appAccessibilityDefectWarning(verdict: SnapshotQualityVerdict): string[] { + if (verdict.reasonCode !== 'sparse-tree') return []; + return [ + 'This screen publishes no accessibility content at all; assistive technologies see the same empty tree, so it is worth flagging as an app accessibility bug rather than only an automation limitation.', + ]; +} + function depthWarning(verdict: SnapshotQualityVerdict): string[] { if (verdict.effectiveDepth === undefined) return []; return [ diff --git a/src/utils/output.ts b/src/utils/output.ts index 40a3c33da..c8bd0b169 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -493,7 +493,7 @@ function buildSnapshotNotices( options: SnapshotTextOptions, helperPresentation: AndroidHelperPresentationInput = { nodes, filteredCount: 0 }, ): string[] { - const notices = readSnapshotWarnings(data); + const notices = [...readSnapshotWarnings(data), ...fallbackScreenshotNotices(data)]; // The structured snapshot quality verdict already carries a sharper version of this hint. if (shouldRenderLegacySparseSnapshotHint(data)) { const sparseSnapshotHint = formatSparseSnapshotHint(nodes, options); @@ -511,6 +511,13 @@ function buildSnapshotNotices( return notices; } +function fallbackScreenshotNotices(data: Record): string[] { + const fallbackPath = data.fallbackScreenshotPath; + return typeof fallbackPath === 'string' && fallbackPath.length > 0 + ? [`Captured a screenshot of this screen automatically as visual truth: ${fallbackPath}`] + : []; +} + function shouldRenderLegacySparseSnapshotHint(data: Record): boolean { return !data.snapshotQuality && !isWebSnapshotData(data); } diff --git a/src/utils/result-serialization.ts b/src/utils/result-serialization.ts index d72e690ad..6a5d71d4e 100644 --- a/src/utils/result-serialization.ts +++ b/src/utils/result-serialization.ts @@ -192,6 +192,9 @@ export function serializeSnapshotResult(result: CaptureSnapshotResult): Record` before a mutation. ...(result.refsGeneration !== undefined ? { refsGeneration: result.refsGeneration } : {}), diff --git a/website/docs/docs/snapshots.md b/website/docs/docs/snapshots.md index 61b743ce4..79973f152 100644 --- a/website/docs/docs/snapshots.md +++ b/website/docs/docs/snapshots.md @@ -100,6 +100,12 @@ the strategy owns which tiers it may use. **sparse** capture reports that no backend could read the screen and points you at `screenshot` as visual truth plus coordinate taps. Use `--json` and read `snapshotQuality` when you need the state, backend, and reason behind **degraded** output. +- A **sparse** `snapshot` takes that screenshot for you and returns its path as + `fallbackScreenshotPath`, so the visual fallback costs no extra command. Remote clients download + the image through the normal artifact channel before exposing that path. When the screen was + reachable but published no accessibility content at all, the warning also names it as a likely app + accessibility bug — assistive technologies get the same empty tree. Reasons that describe a limit + of this tool instead (a refused or budget-exhausted capture) are not attributed to the app. - `--raw` uses the **raw diagnostic strategy**: it stays tree-first and preserves strict capture failures, so a real XCTest accessibility serialization error surfaces as an error rather than as an empty tree.