Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/contracts/src/platform-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>;

Expand Down Expand Up @@ -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'
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'] }]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<NetworkRequest, 'include' | 'maxEntries'>,
Expand Down
138 changes: 60 additions & 78 deletions packages/provider-webdriver/src/platform-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
});
Expand All @@ -32,7 +34,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),
Expand All @@ -50,20 +52,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',
Expand All @@ -80,8 +74,53 @@ 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(CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, 'android'),
ownsDevice: () => true,
capabilities: capabilities('android', undefined, CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm),
});

expect(await owner.inspectFacts({ ...device, id: 'aws-device-farm:lease-one' })).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.',
},
},
});
});

/**
* 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, 'android'],
['Android', device],
[
'iOS',
{
Expand All @@ -91,9 +130,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()),
Expand All @@ -114,7 +152,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 });
Expand All @@ -130,15 +171,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([
Expand Down Expand Up @@ -703,56 +735,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,
Expand Down
37 changes: 14 additions & 23 deletions packages/provider-webdriver/src/platform-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.',
Expand Down Expand Up @@ -405,7 +414,6 @@ function bindWebDriverPlatformRuntime(
device: DeviceInfo,
signal: AbortSignal,
): DeviceBinding<PlatformRuntimeOperations> {
const backend = device.platform === 'apple' ? 'ios-device' : 'android';
const facts = webDriverFacts(options, device);
const deploy = facts.operations.deployApp;
const operations: DeviceBinding<PlatformRuntimeOperations>['operations'] = Object.freeze({
Expand All @@ -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) =>
Expand Down Expand Up @@ -564,7 +552,6 @@ function webDriverFacts(
deployMaterializedApp: deployment,
sendPushNotification: pushUnavailable,
appState: appStateUnavailable,
networkDump: available,
...snapshotRuntimeOperationFacts({
capture: snapshotCell,
customActions: snapshotCustomActionsUnavailable,
Expand Down Expand Up @@ -677,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.',
Expand Down
1 change: 1 addition & 0 deletions website/docs/docs/debugging-profiling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down
Loading
Loading