From bc18594f5e43c4aaef1aa4cad6a18c34b8e5fec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 24 Oct 2026 20:18:58 +0200 Subject: [PATCH 1/4] fix(providers): refuse a live network dump the provider runtime cannot serve The WebDriver provider runtime answered `network dump` by scanning the session app log. A hosted provider owns the network path for its session and the session writes no request lines the caller's app does not choose to, so the scan answered an empty dump that read as "no traffic". Report the operation unavailable instead. The new typed reason `network-capture-post-session` says where traffic exists: on the provider's side, finalized once its own session ends. The hint points at the provider dashboard, which is where that report appears. Co-Authored-By: Claude --- packages/contracts/src/platform-runtime.ts | 11 +- .../src/platform-runtime.test.ts | 108 +++++------------- .../src/platform-runtime.ts | 35 ++---- website/docs/docs/debugging-profiling.md | 1 + website/docs/docs/device-clouds.md | 1 + 5 files changed, 53 insertions(+), 103 deletions(-) diff --git a/packages/contracts/src/platform-runtime.ts b/packages/contracts/src/platform-runtime.ts index c46fd84b92..15c0ef20f7 100644 --- a/packages/contracts/src/platform-runtime.ts +++ b/packages/contracts/src/platform-runtime.ts @@ -108,7 +108,13 @@ export type RuntimeOperationUnavailability = Readonly<{ | 'unsupported-device-backend' | 'unsupported-device-scope' | 'unsupported-provider-mode' - | 'owner-capability-missing'; + | 'owner-capability-missing' + /** + * A hosted provider owns the network path for its session, so a live `network dump` has no + * source to read. Where the provider records traffic at all, it publishes that report only + * after its own session ends, on the provider's side. + */ + | 'network-capture-post-session'; hint?: string; }>; @@ -346,7 +352,8 @@ function isRuntimeOperationUnavailabilityReason( value === 'unsupported-device-backend' || value === 'unsupported-device-scope' || value === 'unsupported-provider-mode' || - value === 'owner-capability-missing' + value === 'owner-capability-missing' || + value === 'network-capture-post-session' ); } diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index b2cb2b86d9..75876dd8bb 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -32,7 +32,7 @@ const device: DeviceInfo = { booted: true, }; -test('direct WebDriver network uses only the canonical session log and preserves empty success', async () => { +test('direct WebDriver refuses a live network dump instead of answering from another source', async () => { const run = vi.fn(async () => ({ stdout: 'must not execute', stderr: '', exitCode: 0 })); const owner = createWebDriverPlatformRuntimeOwner({ host: host(run), @@ -50,20 +50,12 @@ test('direct WebDriver network uses only the canonical session log and preserves }, }); - await expect( - binding.operations.networkDump?.({ - sessionId: 'one', - maxEntries: 25, - include: 'summary', - maxPayloadChars: 2048, - maxScanLines: 4000, - }), - ).resolves.toMatchObject({ - source: 'app-log', - backend: 'android', - dump: { exists: false, entries: [] }, + expect(binding.operations.networkDump).toBeUndefined(); + expect(binding.facts.operations.networkDump).toMatchObject({ + available: false, + reason: 'network-capture-post-session', + hint: 'The provider publishes any network capture only after its session ends, on the provider dashboard.', }); - expect(binding.facts.operations.networkDump).toEqual({ available: true }); expect(binding.facts.operations.appLogInspect).toMatchObject({ available: false }); for (const operation of [ 'deployApp', @@ -80,8 +72,27 @@ test('direct WebDriver network uses only the canonical session log and preserves expect(run).not.toHaveBeenCalled(); }); +test('a provider with no capture of its own states the same refusal for a live dump', async () => { + const owner = createWebDriverPlatformRuntimeOwner({ + host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + owner: providerRuntimeOwner('aws-device-farm', 'android'), + ownsDevice: () => true, + capabilities: capabilities(), + }); + + expect(await owner.inspectFacts(device)).toMatchObject({ + operations: { + networkDump: { + available: false, + reason: 'network-capture-post-session', + hint: 'The provider publishes any network capture only after its session ends, on the provider dashboard.', + }, + }, + }); +}); + test.each([ - ['Android', device, 'android'], + ['Android', device], [ 'iOS', { @@ -91,9 +102,8 @@ test.each([ id: 'browserstack:ios:lease-one', name: 'Remote iPhone', }, - 'ios-device', ], -])('classifies the direct WebDriver %s network cell', async (_name, runtimeDevice, backend) => { +])('classifies the direct WebDriver %s device binding', async (_name, runtimeDevice) => { const owner = createWebDriverPlatformRuntimeOwner({ host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), owner: providerRuntimeOwner('browserstack', String(_name).toLowerCase()), @@ -114,7 +124,10 @@ test.each([ available: false, reason: 'unsupported-provider-mode', }); - expect(binding.facts.operations.networkDump).toEqual({ available: true }); + expect(binding.facts.operations.networkDump).toMatchObject({ + available: false, + reason: 'network-capture-post-session', + }); expect(binding.facts.operations.ensureReady).toEqual({ available: true }); expect(binding.facts.operations.bootTarget).toEqual({ available: true }); expect(binding.facts.operations.bootTargetHeadless).toMatchObject({ available: false }); @@ -130,15 +143,6 @@ test.each([ id: runtimeDevice.id, booted: true, }); - await expect( - binding.operations.networkDump?.({ - sessionId: 'one', - maxEntries: 25, - include: 'summary', - maxPayloadChars: 2048, - maxScanLines: 4000, - }), - ).resolves.toMatchObject({ source: 'app-log', backend }); }); test.each([ @@ -703,56 +707,6 @@ test('Android TV hardware buttons are refused before a WebDriver interactor is a expect(getInteractor).not.toHaveBeenCalled(); }); -test('an Apple WebDriver dump bounded to one entry reports unnamed traffic without its identities', async () => { - // Five keep-alive requests CFNetwork logged no URL for, and no connection - // line to resolve them against: many more unnamed tasks than maxEntries. - const summaries = Array.from( - { length: 5 }, - (_, index) => - `2026-09-09 18:22:28.167 Df app[1:2] [com.apple.CFNetwork:Summary] Task <2FAEF670>.<${index + 10}> summary for task success {transaction_duration_ms=1, response_status=200, connection=9, reused=1}`, - ); - const base = host(vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 }))); - const owner = createWebDriverPlatformRuntimeOwner({ - host: { - ...base, - appLogs: { - ...base.appLogs, - readRecent: async () => ({ - path: '/sessions/one/app.log', - exists: true, - text: `${summaries.join('\n')}\n`, - skippedLines: 0, - }), - }, - }, - owner: providerRuntimeOwner('browserstack', 'apple'), - ownsDevice: () => true, - capabilities: capabilities(), - }); - const binding = await owner.bind({ - device: { ...device, platform: 'apple', appleOs: 'ios', id: 'browserstack:lease-ios' }, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - }); - - const result = await binding.operations.networkDump?.({ - sessionId: 'one', - maxEntries: 1, - include: 'summary', - maxPayloadChars: 2048, - maxScanLines: 4000, - }); - - if (result?.source !== 'app-log') throw new Error('expected app-log result'); - expect(result.backend).toBe('ios-device'); - expect(result.dump.unnamedRequests).toBe(5); - expect(result.dump).not.toHaveProperty('unnamedRequestIds'); -}); - test('an iOS WebDriver device serves keyboard dismiss, and neither the unproven enter nor the Android IME probe', async () => { const iosDevice: DeviceInfo = { ...device, diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 2c3df40124..f58c742670 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -67,7 +67,6 @@ import { } from '@agent-device/contracts/touch-runtime'; import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; -import { readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { type WebDriverDeploymentRuntime, webDriverInactiveSession } from './runtime-deployment.ts'; @@ -86,6 +85,16 @@ type WebDriverPlatformDeploymentRuntime = Pick< const available = Object.freeze({ available: true } as const); const appLogUnavailable = unavailableFact('unsupported-provider-mode'); +/** + * A live `network dump` needs a source the session can read. A hosted provider keeps traffic on its + * own side: whatever the provider captures at all, it publishes only once its session ends, through + * the provider's own dashboard or session records. Nothing is readable live, and the runtime must + * not answer with an empty dump that looks like "no traffic". + */ +const networkCaptureUnavailable = unavailableFact( + 'network-capture-post-session', + 'The provider publishes any network capture only after its session ends, on the provider dashboard.', +); const recordingUnavailable = unavailableFact( 'unsupported-provider-mode', 'WebDriver provider runtimes do not expose screen recording.', @@ -405,7 +414,6 @@ function bindWebDriverPlatformRuntime( device: DeviceInfo, signal: AbortSignal, ): DeviceBinding { - const backend = device.platform === 'apple' ? 'ios-device' : 'android'; const facts = webDriverFacts(options, device); const deploy = facts.operations.deployApp; const operations: DeviceBinding['operations'] = Object.freeze({ @@ -420,26 +428,6 @@ function bindWebDriverPlatformRuntime( facts.operations, ), ...webDriverInteractionOperations(options, device, signal, facts), - networkDump: async (input) => { - const recent = await options.host.appLogs.readRecent(input.sessionId, input.maxScanLines); - const { dump } = readRecentNetworkTrafficFromText(recent.text, { - ...input, - path: recent.path, - exists: recent.exists, - lineNumberOffset: recent.skippedLines, - backend, - }); - return Object.freeze({ - source: 'app-log' as const, - backend, - dump, - notes: Object.freeze( - dump.entries.length === 0 - ? ['No HTTP(s) entries were found in recent session app logs.'] - : [], - ), - }); - }, ...(deploy.available && options.deployment ? { deployApp: async (input: AppDeploymentInput) => @@ -508,7 +496,7 @@ function webDriverFacts( const unavailable = createUnavailablePlatformRuntimeFacts(device, options.owner, { appLog: appLogUnavailable, appDeployment: deploymentUnavailable, - network: appLogUnavailable, + network: networkCaptureUnavailable, screenRecording: recordingUnavailable, screenshot: screenshotUnavailable, viewport: viewportUnavailable, @@ -564,7 +552,6 @@ function webDriverFacts( deployMaterializedApp: deployment, sendPushNotification: pushUnavailable, appState: appStateUnavailable, - networkDump: available, ...snapshotRuntimeOperationFacts({ capture: snapshotCell, customActions: snapshotCustomActionsUnavailable, diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index ee1131cf1b..6c58455052 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -223,6 +223,7 @@ agent-device network dump 25 --include all - On iOS, `network dump` can return zero entries for real traffic when the app does not log request metadata to Unified Logging. The response notes tell an empty repro window apart from a log window with no network lines. - On iOS, the system logs a request URL only when it opens a connection. Requests that reuse a keep-alive connection are reported against that connection's origin with `pathUnavailable: true`, their status, and timing. Requests whose connection opened before the scanned window are only counted in `unnamedRequests`. Treat an endpoint missing from an iOS dump as unproven, not as never called. - On web, request and response bodies are not available; use `agent-browser` HAR capture directly when you need them. +- On a hosted device cloud, `network dump` refuses with `UNSUPPORTED_OPERATION` and `details.reason: "network-capture-post-session"`. The cloud owns the network path, so the session has no live source to read. Where the provider records traffic at all, it publishes the report on its own dashboard after its session ends. See [Device clouds](/docs/device-clouds). - For the alias and scan limits, see [Media and logs](/docs/commands#media-and-logs). ### Audio probes diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md index 42f16761ff..a795940ac6 100644 --- a/website/docs/docs/device-clouds.md +++ b/website/docs/docs/device-clouds.md @@ -23,6 +23,7 @@ Use a device cloud or farm when an agent needs to drive a hosted mobile device w | App source | `--provider-app`: `bs://` reference, HTTP(S) URL, or local path | `--aws-app-arn` upload ARN; no install after allocation | `--provider-app`: `lt://` reference, HTTP(S) URL, or local path | `install `, or `open ` for an uploaded asset | | Artifacts after `close` | Video, Appium and device logs, dashboard and public links | Video and log files, once AWS finalizes them | Video, Appium, device, network, and command logs, screenshots, dashboard link | None | | Port reverse | No; use BrowserStack Local | No | No | Android over ADB; not on iOS | +| Live `network dump` | No; the provider owns the network path | No; the provider owns the network path | No; the provider owns the network path | Yes | ## Shared workflow From 671179c0f38215762e38e74d5a73d5f4a9af043e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 25 Oct 2026 10:39:51 +0100 Subject: [PATCH 2/4] fix(daemon): carry the network refusal's typed reason onto the wire handleNetworkCommand built its own UNSUPPORTED_OPERATION for an unavailable networkDump fact, so the fact's reason never reached the caller. The refusal kept the message and the hint and dropped the only discriminator, which left hint text as the way to tell one provider's refusal from another's. Report it through unsupportedOperationResponse, the one shared wire shape every runtime-admission refusal already uses, and delete the local helper. The parity test now pins details.reason, and a new case takes a network-capture-post-session fact through handleSessionCommands and asserts the reason the provider docs promise. Co-Authored-By: Claude --- .../session-network-runtime-parity.test.ts | 38 +++++++++++++++++++ .../internal/session-network.ts | 14 +------ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts index f49cb291d5..a7fcbc9ac7 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts @@ -144,6 +144,44 @@ test('network admission remains fail-closed before parsing invalid input', async code: 'UNSUPPORTED_OPERATION', message: 'network is not supported on this device', hint: 'Network capture is unavailable for this platform leaf.', + details: { reason: 'unsupported-platform-leaf' }, + }, + }); + expect(runtime.uses).toEqual([{ required: [], preferred: ['networkDump'] }]); + expect(runtime.networkDump).not.toHaveBeenCalled(); +}); + +test('a provider whose capture is post-session reports that typed reason on the wire', async () => { + const sessionStore = makeSessionStore(); + const session = makeSession('provider-post-session', { + platform: 'android', + id: 'browserstack-device', + name: 'BrowserStack device', + kind: 'device', + }); + sessionStore.publish(session.name, session); + const runtime = createNetworkRuntime( + session.device, + // The answer the removed code produced: an empty dump that reads as "no traffic". The refusal + // must win before this is reached, so an ok response here fails the assertions below. + async () => emptyAppLogResult('android'), + { + available: false, + reason: 'network-capture-post-session', + hint: 'The provider publishes any network capture only after its session ends, on the provider dashboard.', + }, + providerRuntimeOwner('browserstack', 'default'), + ); + + const response = await runNetwork(session.name, sessionStore, runtime.bindDevice); + + expect(response).toEqual({ + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'network is not supported on this device', + hint: 'The provider publishes any network capture only after its session ends, on the provider dashboard.', + details: { reason: 'network-capture-post-session' }, }, }); expect(runtime.uses).toEqual([{ required: [], preferred: ['networkDump'] }]); diff --git a/packages/daemon/src/sessions/session-observability/internal/session-network.ts b/packages/daemon/src/sessions/session-observability/internal/session-network.ts index 95e1f73328..b7bc332fb5 100644 --- a/packages/daemon/src/sessions/session-observability/internal/session-network.ts +++ b/packages/daemon/src/sessions/session-observability/internal/session-network.ts @@ -12,6 +12,7 @@ import { type DaemonResponse, } from '@agent-device/kernel/contracts'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { unsupportedOperationResponse } from '../../../core/runtime-admission.ts'; import type { BindDeviceRuntime } from '../../../core/request-runtime-binding.ts'; import type { SessionStore } from '../../../core/session-store.ts'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; @@ -46,7 +47,7 @@ export async function handleNetworkCommand(params: NetworkHandlerParams): Promis const bindDevice = requireNetworkBindDevice(params.bindDevice); const admission = await bindDevice(session.device, networkAdmissionUse); const networkFact = admission.facts.networkDump; - if (!networkFact.available) return unsupportedNetworkResponse(networkFact.hint); + if (!networkFact.available) return unsupportedOperationResponse('network', networkFact); const request = resolveNetworkRequest(params.req); if (!request.ok) return request; @@ -133,17 +134,6 @@ function resolveNetworkIncludeMode( return { ok: true, include: requestedInclude as NetworkIncludeMode }; } -function unsupportedNetworkResponse(hint: string | undefined): DaemonFailureResponse { - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'network is not supported on this device', - hint, - }, - }; -} - function networkDumpResponse( result: NetworkDumpResult, request: Pick, From 0d088d0d608fc1984d157556f5b773136d708135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 25 Oct 2026 10:50:37 +0100 Subject: [PATCH 3/4] test(providers): model AWS in the AWS refusal case and scope the docs claim The AWS refusal case built its capability map with the browserstack provider id, so it modelled an AWS runtime wearing BrowserStack's declared map. The helper now takes the provider id and the case passes AWS's own id and device. The debugging guide said hosted device clouds refuse a live network dump. Limrun is hosted and answers live, so the sentence names the three providers that refuse and says Limrun keeps the operation. Co-Authored-By: Claude --- .../provider-webdriver/src/platform-runtime.test.ts | 10 ++++++---- website/docs/docs/debugging-profiling.md | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 75876dd8bb..3f059ce857 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -10,14 +10,16 @@ import { type CloudWebDriverCapabilityOverrides, } from './capabilities.ts'; import type { CloudWebDriverPlatform } from './runtime.ts'; +import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; /** The declared map fact generation now reads. Defaults to a provider with no overrides. */ function capabilities( platform: CloudWebDriverPlatform = 'android', overrides?: CloudWebDriverCapabilityOverrides, + provider: string = CLOUD_WEBDRIVER_PROVIDERS.browserStack, ) { return createCloudWebDriverCapabilities({ - provider: 'browserstack', + provider, platform, ...(overrides ? { overrides } : {}), }); @@ -75,12 +77,12 @@ test('direct WebDriver refuses a live network dump instead of answering from ano test('a provider with no capture of its own states the same refusal for a live dump', async () => { const owner = createWebDriverPlatformRuntimeOwner({ host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), - owner: providerRuntimeOwner('aws-device-farm', 'android'), + owner: providerRuntimeOwner(CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, 'android'), ownsDevice: () => true, - capabilities: capabilities(), + capabilities: capabilities('android', undefined, CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm), }); - expect(await owner.inspectFacts(device)).toMatchObject({ + expect(await owner.inspectFacts({ ...device, id: 'aws-device-farm:lease-one' })).toMatchObject({ operations: { networkDump: { available: false, diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index 6c58455052..b8769b25c4 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -223,7 +223,7 @@ agent-device network dump 25 --include all - On iOS, `network dump` can return zero entries for real traffic when the app does not log request metadata to Unified Logging. The response notes tell an empty repro window apart from a log window with no network lines. - On iOS, the system logs a request URL only when it opens a connection. Requests that reuse a keep-alive connection are reported against that connection's origin with `pathUnavailable: true`, their status, and timing. Requests whose connection opened before the scanned window are only counted in `unnamedRequests`. Treat an endpoint missing from an iOS dump as unproven, not as never called. - On web, request and response bodies are not available; use `agent-browser` HAR capture directly when you need them. -- On a hosted device cloud, `network dump` refuses with `UNSUPPORTED_OPERATION` and `details.reason: "network-capture-post-session"`. The cloud owns the network path, so the session has no live source to read. Where the provider records traffic at all, it publishes the report on its own dashboard after its session ends. See [Device clouds](/docs/device-clouds). +- On BrowserStack, AWS Device Farm, and TestMu AI sessions, `network dump` refuses with `UNSUPPORTED_OPERATION` and `details.reason: "network-capture-post-session"`. Those providers own the network path, so the session has no live source to read. Where the provider records traffic at all, it publishes the report on its own dashboard after its session ends. Limrun keeps live `network dump`. See [Device clouds](/docs/device-clouds). - For the alias and scan limits, see [Media and logs](/docs/commands#media-and-logs). ### Audio probes From e205a9674531039c0539e914ef89177b2626cdaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 25 Oct 2026 13:36:11 +0100 Subject: [PATCH 4/4] fix(providers): keep the post-session reason on the network cell alone The network cell is not only networkDump's fact. It is the fallback the shared hub (createUnavailablePlatformRuntimeFacts) hands to every optional cell the owner leaves unclassified, and this owner leaves snapshot unclassified. findText and querySelector start from that snapshot cell, so stating network: networkCaptureUnavailable reported network-capture-post-session for a text read on BrowserStack, AWS Device Farm and TestMu. Return the network cell to the app-log gap the rest of the family uses and state networkDump in the final operations object, where every other owner-declared cell already sits. One case now asserts the pairing: networkDump keeps network-capture-post-session and the two text reads keep unsupported-provider-mode. Co-Authored-By: Claude --- .../src/platform-runtime.test.ts | 26 +++++++++++++++++++ .../src/platform-runtime.ts | 6 ++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 3f059ce857..0bf08f14a4 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -93,6 +93,32 @@ test('a provider with no capture of its own states the same refusal for a live d }); }); +/** + * The `network` cell is also the fallback the shared hub gives every optional cell this owner leaves + * unclassified, `snapshot` among them, and `findText`/`querySelector` start from the snapshot cell. + * So the post-session reason must be stated on `networkDump` alone, or an unrelated read reports it. + */ +test('the post-session refusal stays on networkDump and does not leak into the text reads', async () => { + const owner = createWebDriverPlatformRuntimeOwner({ + host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + owner: providerRuntimeOwner('browserstack', 'android'), + ownsDevice: () => true, + capabilities: capabilities(), + }); + + const facts = await owner.inspectFacts(device); + + expect(facts.operations.networkDump).toMatchObject({ + available: false, + reason: 'network-capture-post-session', + }); + for (const operation of ['findText', 'querySelector'] as const) { + expect(facts.operations[operation].available).toBe(false); + if (facts.operations[operation].available) throw new Error(`${operation} admitted a live read`); + expect(facts.operations[operation].reason).toBe('unsupported-provider-mode'); + } +}); + test.each([ ['Android', device], [ diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index f58c742670..b4ebe45a58 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -496,7 +496,7 @@ function webDriverFacts( const unavailable = createUnavailablePlatformRuntimeFacts(device, options.owner, { appLog: appLogUnavailable, appDeployment: deploymentUnavailable, - network: networkCaptureUnavailable, + network: appLogUnavailable, screenRecording: recordingUnavailable, screenshot: screenshotUnavailable, viewport: viewportUnavailable, @@ -664,6 +664,10 @@ function webDriverFacts( bootTarget: available, bootTargetHeadless: headlessUnavailable, listApps: appsUnavailable, + // Stated here rather than through the `network` cell: `network` is the fallback the shared + // hub gives every optional cell this owner leaves unclassified, so putting the network-capture + // reason there would report it for unrelated reads. Only this cell refuses post-session. + networkDump: networkCaptureUnavailable, shutdownTarget: unavailableFact( 'unsupported-provider-mode', 'WebDriver owns the target lifecycle for provider-owned devices.',