Skip to content

Commit 92b2222

Browse files
authored
feat(cloud-webdriver): BrowserStack device-feature capabilities, and fix cloud orientation (#1544)
* feat(cloud-webdriver): support BrowserStack device-feature capabilities Adds the eight BrowserStack "device feature" session capabilities that had no representation in agent-device: deviceOrientation, geoLocation, timezone, language, locale, networkProfile, customNetwork, and resignApp. These are vendor capabilities, so they are emitted inside `bstack:options` rather than at the top level. BrowserStack's YAML config lists them unnested and its SDK relocates them; agent-device talks to the hub directly, so it nests them itself. A single spec table drives both the flag reader and the capability builder, so adding a capability is a table row rather than a branch in each. A structural test asserts every field owns exactly one row, since a field the table forgets would parse off the CLI, ride the profile, and then be silently dropped before the hub ever saw it. Rejects combinations the provider cannot act on unambiguously: an unknown orientation is caught at the flag boundary instead of being forwarded to a hub that accepts and then ignores it, --provider-no-resign-app is refused on Android, and a named network profile cannot be combined with a custom network shape. Also fixes a latent shallow-merge bug in buildBrowserStackCapabilities: a caller supplying its own `bstack:options` replaced the whole object and silently dropped the project, build, and session labels. It is now merged per key. * fix(cloud-webdriver): rotate via WebDriver orientation endpoints `setOrientation` on the cloud WebDriver path sent `mobile: rotate`, which is not a driver command at all. UiAutomator2's own error enumerates its extensions and `rotate` is absent from the list, so `agent-device orientation` was a hard failure on every hosted provider. It also forwarded agent-device's four-way rotation vocabulary verbatim ("landscape-left", "portrait-upside-down"), where the protocol accepts only uppercase PORTRAIT/LANDSCAPE. Every other platform has a translation layer; this path was the only one without one. Now two transports, ordered by backend. `POST /rotation` takes exact four-way degrees and leads on Android, since it is the only endpoint that can express upside-down and left-versus-right. `POST /orientation` is two-way and leads on XCUITest, which rejects `/rotation`. Each falls back to the other, because only BrowserStack's UiAutomator2 is verified and a provider whose driver disagrees should degrade rather than hard-fail. Verified live against BrowserStack App Automate: POST /rotation {"x":0,"y":0,"z":0} -> 200 {"value":"ROTATION_0"} The rotation-to-surface-index mapping moves to contracts/device-rotation.ts and the existing adb path now reads from it, so the local and hosted mappings cannot drift apart. Note this rotates the current display, not persistent device rotation, so an activity that does not pin its own orientation may still need rotating once it is in the foreground. The capability was declared "partial" without the transport existing, and no test covered setOrientation on the cloud path; only adb and the Apple runner were covered. Both gaps are now closed. * fix(cloud-webdriver): narrow orientation fallback and gate provider-owned flags Addresses review on #1544. The orientation fallback caught every error, so a timeout, an auth rejection, a dead session or a provider 5xx on the first transport was swallowed and retried against the second. When that one also failed the caller got "rejected both endpoints" with the real cause discarded. Fallback is now keyed on structured unsupported-endpoint signals only — HTTP 404/405, or a W3C `unknown command` / `unknown method` code — matching the repo rule of keying on typed details rather than message text. Everything else rethrows unchanged. Device-feature capabilities are BrowserStack-owned, but the flags were accepted by any cloud provider, persisted into the generated profile, and then silently dropped at session creation. `connect aws-device-farm` now rejects them with a typed error naming each offending flag, raised before the provider's own required-argument checks so the caller is told what is unsupported rather than what else is missing. Ownership is modelled on the capability spec table, so a new capability inherits the guard without a second list to maintain. Adds provider-backed orientation scenarios driven through public daemon dispatch against the fake WebDriver provider: the four-way endpoint on the happy path, the documented collapse onto the two-way endpoint when the driver does not implement `/rotation`, and a provider 5xx that must surface without consulting the second transport. The fake server's route handling became a table in the process — it had grown to ten branches in one function. * fix(cloud-webdriver): read W3C error codes before status, enforce ownership at the runtime boundary Addresses the second review pass on #1544. The fallback classifier returned on any 404/405 before consulting the W3C error code, so an HTTP 404 carrying `invalid session id` was masked as a missing route and retried against the second transport. The structured code now takes precedence whenever the driver sent one; bare status is consulted only when no code exists. Two cases pin it: a 404 `invalid session id` and a 405 `timeout` must both surface rather than fall through. Provider ownership was enforced only in the CLI profile builder, which the typed client and hand-authored remote-config profiles bypass entirely — both reach session preparation without passing through `connect`, so the capabilities were accepted and then dropped. The check now lives on the capability-ownership module and runs inside AWS Device Farm's `prepareSession`, with the CLI builder calling the same helper instead of its own copy. Covered by a scenario that drives the runtime boundary directly and asserts the rejection happens before any provider session is created.
1 parent c7af6cd commit 92b2222

31 files changed

Lines changed: 1271 additions & 41 deletions

packages/contracts/src/client-connection.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ export type AgentDeviceRequestOverrides = Pick<
6161
| 'providerProject'
6262
| 'providerBuild'
6363
| 'providerSessionName'
64+
| 'providerDeviceOrientation'
65+
| 'providerGeoLocation'
66+
| 'providerTimezone'
67+
| 'providerLanguage'
68+
| 'providerLocale'
69+
| 'providerNetworkProfile'
70+
| 'providerCustomNetwork'
71+
| 'providerNoResignApp'
6472
| 'awsProjectArn'
6573
| 'awsDeviceArn'
6674
| 'awsAppArn'

packages/contracts/src/device-rotation.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,30 @@ export const DEVICE_ROTATIONS = [
88
] as const;
99
export type DeviceRotation = (typeof DEVICE_ROTATIONS)[number];
1010

11+
/**
12+
* Android `Surface.ROTATION_*` index per rotation, which is also the value the `user_rotation`
13+
* system setting takes and, multiplied by 90, the `z` degrees the WebDriver `/rotation` endpoint
14+
* takes. One table so the adb path and the cloud WebDriver path cannot drift apart.
15+
*/
16+
export const DEVICE_ROTATION_SURFACE_INDEX = {
17+
portrait: 0,
18+
'landscape-left': 1,
19+
'portrait-upside-down': 2,
20+
'landscape-right': 3,
21+
} as const satisfies Record<DeviceRotation, 0 | 1 | 2 | 3>;
22+
23+
export function deviceRotationSurfaceDegrees(rotation: DeviceRotation): 0 | 90 | 180 | 270 {
24+
return (DEVICE_ROTATION_SURFACE_INDEX[rotation] * 90) as 0 | 90 | 180 | 270;
25+
}
26+
27+
/**
28+
* Collapses the four-way rotation onto the two values the WebDriver `/orientation` endpoint accepts.
29+
* Lossy by nature: both landscape rotations report `LANDSCAPE`, both portraits `PORTRAIT`.
30+
*/
31+
export function deviceRotationOrientation(rotation: DeviceRotation): 'PORTRAIT' | 'LANDSCAPE' {
32+
return DEVICE_ROTATION_SURFACE_INDEX[rotation] % 2 === 0 ? 'PORTRAIT' : 'LANDSCAPE';
33+
}
34+
1135
export function parseDeviceRotation(input: string | undefined): DeviceRotation {
1236
if (input === undefined) {
1337
throw new AppError(

packages/contracts/src/remote-config-fields.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,28 @@ import type {
1212

1313
import type { MetroPrepareKind } from './metro.ts';
1414

15+
/**
16+
* Screen orientation a hosted provider session starts in. Distinct from `DeviceRotation`
17+
* (`contracts/device-rotation.ts`): that is a four-way runtime rotation command, this is the
18+
* two-way session-creation capability hosted providers accept.
19+
*/
20+
export const PROVIDER_DEVICE_ORIENTATIONS = ['portrait', 'landscape'] as const;
21+
export type ProviderDeviceOrientation = (typeof PROVIDER_DEVICE_ORIENTATIONS)[number];
22+
1523
export type CloudProviderProfileFields = {
1624
providerApp?: string;
1725
providerOsVersion?: string;
1826
providerProject?: string;
1927
providerBuild?: string;
2028
providerSessionName?: string;
29+
providerDeviceOrientation?: ProviderDeviceOrientation;
30+
providerGeoLocation?: string;
31+
providerTimezone?: string;
32+
providerLanguage?: string;
33+
providerLocale?: string;
34+
providerNetworkProfile?: string;
35+
providerCustomNetwork?: string;
36+
providerNoResignApp?: boolean;
2137
awsProjectArn?: string;
2238
awsDeviceArn?: string;
2339
awsAppArn?: string;
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
4+
import { AppError } from '@agent-device/kernel/errors';
5+
import {
6+
BROWSERSTACK_DEVICE_FEATURE_SPECS,
7+
buildBrowserStackDeviceFeatureCapabilities,
8+
readBrowserStackDeviceFeatureFields,
9+
} from './browserstack-device-features.ts';
10+
11+
// Every device-feature field must own a spec row: a field the table forgets parses off the CLI,
12+
// rides the profile, and is then silently dropped before the hub ever sees it.
13+
const DEVICE_FEATURE_FIELDS = [
14+
'providerDeviceOrientation',
15+
'providerGeoLocation',
16+
'providerTimezone',
17+
'providerLanguage',
18+
'providerLocale',
19+
'providerNetworkProfile',
20+
'providerCustomNetwork',
21+
'providerNoResignApp',
22+
] as const;
23+
24+
test('every device-feature field maps to exactly one capability spec', () => {
25+
const fields = BROWSERSTACK_DEVICE_FEATURE_SPECS.map((spec) => spec.field);
26+
assert.deepEqual([...fields].sort(), [...DEVICE_FEATURE_FIELDS].sort());
27+
const capabilities = BROWSERSTACK_DEVICE_FEATURE_SPECS.map((spec) => spec.capability);
28+
assert.equal(new Set(capabilities).size, capabilities.length);
29+
});
30+
31+
test('configured device features project onto BrowserStack vendor capability keys', () => {
32+
const capabilities = buildBrowserStackDeviceFeatureCapabilities(
33+
{
34+
providerDeviceOrientation: 'portrait',
35+
providerGeoLocation: 'US',
36+
providerTimezone: 'New_York',
37+
providerLanguage: 'Fr',
38+
providerLocale: 'Fr',
39+
providerNetworkProfile: '4g-lte-advanced-good',
40+
providerNoResignApp: true,
41+
},
42+
'ios',
43+
);
44+
45+
assert.deepEqual(capabilities, {
46+
deviceOrientation: 'portrait',
47+
geoLocation: 'US',
48+
timezone: 'New_York',
49+
language: 'Fr',
50+
locale: 'Fr',
51+
networkProfile: '4g-lte-advanced-good',
52+
// The flag is the opt-out, so its presence has to invert into an explicit false.
53+
resignApp: false,
54+
});
55+
});
56+
57+
test('unset device features emit no capability keys', () => {
58+
assert.deepEqual(buildBrowserStackDeviceFeatureCapabilities({}, 'android'), {});
59+
});
60+
61+
test('app re-signing opt-out is rejected on Android with a recovery hint', () => {
62+
assert.throws(
63+
() => buildBrowserStackDeviceFeatureCapabilities({ providerNoResignApp: true }, 'android'),
64+
(error: unknown) => {
65+
assert.ok(error instanceof AppError);
66+
assert.equal(error.code, 'INVALID_ARGS');
67+
assert.match(error.message, /--provider-no-resign-app applies to ios sessions only/);
68+
assert.match(String(error.details?.hint), /--platform ios/);
69+
return true;
70+
},
71+
);
72+
});
73+
74+
test('a named network profile and a custom network shape cannot both be configured', () => {
75+
assert.throws(
76+
() =>
77+
buildBrowserStackDeviceFeatureCapabilities(
78+
{ providerNetworkProfile: '4g-lte-advanced-good', providerCustomNetwork: '1000' },
79+
'android',
80+
),
81+
(error: unknown) => {
82+
assert.ok(error instanceof AppError);
83+
assert.equal(error.code, 'INVALID_ARGS');
84+
assert.match(String(error.details?.hint), /only one of/);
85+
return true;
86+
},
87+
);
88+
});
89+
90+
test('device-feature flags are read off an untyped daemon request flag bag', () => {
91+
const fields = readBrowserStackDeviceFeatureFields({
92+
providerDeviceOrientation: 'landscape',
93+
providerTimezone: 'New_York',
94+
providerNoResignApp: true,
95+
unrelatedFlag: 'ignored',
96+
});
97+
98+
assert.deepEqual(fields, {
99+
providerDeviceOrientation: 'landscape',
100+
providerTimezone: 'New_York',
101+
providerNoResignApp: true,
102+
});
103+
});
104+
105+
test('an unrecognized orientation is rejected at the flag boundary, not forwarded to the hub', () => {
106+
assert.throws(
107+
() => readBrowserStackDeviceFeatureFields({ providerDeviceOrientation: 'landscape-left' }),
108+
(error: unknown) => {
109+
assert.ok(error instanceof AppError);
110+
assert.equal(error.code, 'INVALID_ARGS');
111+
assert.match(String(error.details?.hint), /portrait\|landscape/);
112+
return true;
113+
},
114+
);
115+
});
116+
117+
test('empty and non-string flag values are treated as unset', () => {
118+
assert.deepEqual(
119+
readBrowserStackDeviceFeatureFields({
120+
providerTimezone: '',
121+
providerGeoLocation: 42,
122+
providerNoResignApp: 'yes',
123+
}),
124+
{},
125+
);
126+
});

0 commit comments

Comments
 (0)