Skip to content

Commit 2e74b78

Browse files
authored
feat: verify device cloud connections (#1564)
* feat: verify device cloud connections * refactor: unify connect provider adapters * refactor: separate connect verification facts * fix: tighten connect provider verification * fix: use neutral cloud connection wording * perf: deduplicate local affected checks * refactor: simplify affected check runner * refactor: derive connect workflow from verification
1 parent 6ef7cc0 commit 2e74b78

38 files changed

Lines changed: 2118 additions & 383 deletions

‎docs/agents/testing.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ docs-only short-circuit its path would otherwise take. If the matrix moves
141141
again, move that entry with it.
142142
The plan documents the rule and changed path behind every selected check.
143143

144+
Local coverage reuses the affected Vitest run as its LCOV producer and applies the changed-line
145+
coverage gate to that report. It does not run the full instrumented suite; global coverage
146+
thresholds and full unit/provider matrices remain authoritative in GitHub CI. When coverage is
147+
selected, `vitest-related` is folded into this one affected coverage run, and full unit/provider
148+
aggregates are not repeated locally.
149+
144150
Model and catalog live under `scripts/check-affected/`; the derivation is guarded
145151
by `pnpm check:affected:test` (the `Affected-check Selector` CI job).
146152

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from '../companion-tunnel-scope.ts';
22
export * from '../metro.ts';
33
export * from '../remote-config-fields.ts';
4+
export * from '../provider-connection.ts';
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export type ProviderConnectionResource = {
2+
status: 'verified' | 'configured' | 'deferred' | 'missing';
3+
name?: string;
4+
reference?: string;
5+
platform?: 'android' | 'ios';
6+
osVersion?: string;
7+
version?: string;
8+
availability?: string;
9+
message?: string;
10+
};
11+
12+
export type ProviderConnectionVerification = {
13+
provider: string;
14+
service: string;
15+
verificationMessage: string;
16+
project?: { name?: string; reference: string };
17+
device: ProviderConnectionResource;
18+
app: ProviderConnectionResource;
19+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export const LIMRUN_CLIENT_HEADER = 'agent-device-cli';
2+
3+
export function buildLimrunClientOptions(options: { apiKey: string; clientVersion: string }): {
4+
apiKey: string;
5+
defaultHeaders: Record<string, string>;
6+
} {
7+
return {
8+
apiKey: options.apiKey,
9+
defaultHeaders: {
10+
'x-agent-device-client': LIMRUN_CLIENT_HEADER,
11+
'x-agent-device-version': options.clientVersion,
12+
},
13+
};
14+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import assert from 'node:assert/strict';
2+
import { afterEach, test, vi } from 'vitest';
3+
import { verifyLimrunConnection } from './connection-verification.ts';
4+
5+
const mockState = vi.hoisted(() => ({
6+
constructorOptions: [] as Array<Record<string, unknown>>,
7+
androidList: vi.fn(async () => ({ getPaginatedItems: () => [] })),
8+
iosList: vi.fn(async () => ({ getPaginatedItems: () => [] })),
9+
}));
10+
11+
vi.mock('@limrun/api', () => ({
12+
default: class MockLimrun {
13+
readonly androidInstances = { list: mockState.androidList };
14+
readonly iosInstances = { list: mockState.iosList };
15+
16+
constructor(options: Record<string, unknown>) {
17+
mockState.constructorOptions.push(options);
18+
}
19+
},
20+
}));
21+
22+
afterEach(() => {
23+
mockState.constructorOptions.length = 0;
24+
vi.clearAllMocks();
25+
});
26+
27+
test('Limrun verification reads the selected instance service without creating an instance', async () => {
28+
const result = await verifyLimrunConnection({
29+
apiKey: 'lim_test_key',
30+
clientVersion: '1.2.3',
31+
platform: 'android',
32+
});
33+
34+
assert.deepEqual(result, {
35+
provider: 'limrun',
36+
service: 'Limrun',
37+
verificationMessage: 'Credentials and Android instance access verified.',
38+
device: {
39+
status: 'deferred',
40+
name: 'Provider-selected Android emulator',
41+
platform: 'android',
42+
},
43+
app: {
44+
status: 'missing',
45+
message: 'A new Limrun instance does not have your app yet.',
46+
},
47+
});
48+
assert.deepEqual(mockState.androidList.mock.calls, [[{ limit: 1 }]]);
49+
assert.equal(mockState.iosList.mock.calls.length, 0);
50+
assert.equal(mockState.constructorOptions[0]?.apiKey, 'lim_test_key');
51+
assert.deepEqual(mockState.constructorOptions[0]?.defaultHeaders, {
52+
'x-agent-device-client': 'agent-device-cli',
53+
'x-agent-device-version': '1.2.3',
54+
});
55+
});
56+
57+
test('Limrun verification classifies authentication failures', async () => {
58+
mockState.iosList.mockRejectedValueOnce(Object.assign(new Error('invalid'), { status: 401 }));
59+
60+
await assert.rejects(
61+
verifyLimrunConnection({
62+
apiKey: 'lim_bad_key',
63+
clientVersion: '1.2.3',
64+
platform: 'ios',
65+
}),
66+
(error: unknown) => {
67+
assert.equal((error as { code?: string }).code, 'UNAUTHORIZED');
68+
assert.doesNotMatch(JSON.stringify(error), /lim_bad_key/);
69+
return true;
70+
},
71+
);
72+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import Limrun from '@limrun/api';
2+
import { AppError } from '@agent-device/kernel/errors';
3+
import { buildLimrunClientOptions } from './client-options.ts';
4+
import type { ProviderConnectionVerification } from '@agent-device/contracts/remote';
5+
6+
export type LimrunConnectionVerification = ProviderConnectionVerification & {
7+
provider: 'limrun';
8+
service: 'Limrun';
9+
device: {
10+
status: 'deferred';
11+
name: string;
12+
platform: 'android' | 'ios';
13+
};
14+
app: {
15+
status: 'missing';
16+
message: string;
17+
};
18+
};
19+
20+
export type LimrunConnectionVerificationOptions = {
21+
apiKey: string;
22+
clientVersion: string;
23+
platform: 'android' | 'ios';
24+
region?: string;
25+
};
26+
27+
export async function verifyLimrunConnection(
28+
options: LimrunConnectionVerificationOptions,
29+
): Promise<LimrunConnectionVerification> {
30+
const client = new Limrun({
31+
...buildLimrunClientOptions(options),
32+
timeout: 15_000,
33+
maxRetries: 0,
34+
});
35+
const query = { limit: 1, ...(options.region ? { region: options.region } : {}) };
36+
try {
37+
if (options.platform === 'android') {
38+
await client.androidInstances.list(query);
39+
} else {
40+
await client.iosInstances.list(query);
41+
}
42+
} catch (error) {
43+
const status = readStatus(error);
44+
if (status === 401 || status === 403) {
45+
throw new AppError('UNAUTHORIZED', 'Limrun rejected connection verification.', {
46+
status,
47+
hint: 'Check LIMRUN_API_KEY and its organization access.',
48+
});
49+
}
50+
throw new AppError(
51+
'COMMAND_FAILED',
52+
'Limrun connection verification failed.',
53+
{ hint: 'Check Limrun service access, LIMRUN_REGION, and network connectivity, then retry.' },
54+
error,
55+
);
56+
}
57+
const platformName = options.platform === 'android' ? 'Android' : 'iOS';
58+
const deviceKind = options.platform === 'android' ? 'emulator' : 'simulator';
59+
return {
60+
provider: 'limrun',
61+
service: 'Limrun',
62+
verificationMessage: `Credentials and ${platformName} instance access verified.`,
63+
device: {
64+
status: 'deferred',
65+
name: `Provider-selected ${platformName} ${deviceKind}`,
66+
platform: options.platform,
67+
},
68+
app: {
69+
status: 'missing',
70+
message: 'A new Limrun instance does not have your app yet.',
71+
},
72+
};
73+
}
74+
75+
function readStatus(error: unknown): number | undefined {
76+
if (!error || typeof error !== 'object') return undefined;
77+
const status = (error as { status?: unknown }).status;
78+
return typeof status === 'number' ? status : undefined;
79+
}

‎packages/provider-limrun/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
export { LIMRUN_PROVIDER } from './device.ts';
22
export { createLimrunRuntime, type LimrunRuntime, type LimrunRuntimeOptions } from './runtime.ts';
3+
export {
4+
verifyLimrunConnection,
5+
type LimrunConnectionVerification,
6+
type LimrunConnectionVerificationOptions,
7+
} from './connection-verification.ts';
38

49
export type { LimrunRuntimeDependencies } from './runtime-dependencies.ts';
510

‎packages/provider-limrun/src/runtime.ts‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
} from './ios.ts';
3535
import { createLimrunDeviceSession, type LimrunDeviceSession } from './device-session.ts';
3636
import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts';
37+
import { buildLimrunClientOptions, LIMRUN_CLIENT_HEADER } from './client-options.ts';
3738

3839
type LimrunInstance = {
3940
metadata: { id: string };
@@ -51,8 +52,6 @@ export type LimrunRuntimeOptions = {
5152
region?: string;
5253
};
5354

54-
const LIMRUN_CLIENT_HEADER = 'agent-device-cli';
55-
5655
export type LimrunRuntime = ProviderDeviceRuntime & {
5756
recoverExpiredLease: ProviderExpiredLeaseRecovery;
5857
getDeviceSession(device: DeviceInfo): LimrunDeviceSession | undefined;
@@ -99,13 +98,12 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime {
9998
constructor(options: LimrunRuntimeOptions, dependencies: LimrunRuntimeDependencies) {
10099
this.options = options;
101100
this.dependencies = dependencies;
102-
this.limrun = new Limrun({
103-
apiKey: options.apiKey,
104-
defaultHeaders: {
105-
'x-agent-device-client': LIMRUN_CLIENT_HEADER,
106-
'x-agent-device-version': dependencies.clientVersion,
107-
},
108-
});
101+
this.limrun = new Limrun(
102+
buildLimrunClientOptions({
103+
apiKey: options.apiKey,
104+
clientVersion: dependencies.clientVersion,
105+
}),
106+
);
109107
}
110108

111109
ownsDevice(device: DeviceInfo): boolean {
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { AppError } from '@agent-device/kernel/errors';
2+
import { createAwsDeviceFarmCommandRunner } from './aws-device-farm.ts';
3+
import type { RunHostCommand } from './dependencies.ts';
4+
import type {
5+
CloudWebDriverConnectionVerification,
6+
CloudWebDriverConnectionVerificationOptions,
7+
} from './connection-verification.ts';
8+
import type { ProviderConnectionResource } from '@agent-device/contracts/remote';
9+
10+
type AwsOptions = Extract<
11+
CloudWebDriverConnectionVerificationOptions,
12+
{ provider: 'aws-device-farm' }
13+
>;
14+
15+
export function readAwsDeviceFarmRegionFromArn(arn: string): string | undefined {
16+
return /^arn:[^:]+:devicefarm:([^:]+):/.exec(arn)?.[1];
17+
}
18+
19+
export async function verifyAwsDeviceFarmConnection(
20+
options: AwsOptions,
21+
runHostCommand: RunHostCommand,
22+
): Promise<CloudWebDriverConnectionVerification> {
23+
const runAwsJson = createAwsDeviceFarmCommandRunner({
24+
runHostCommand,
25+
region: options.region,
26+
});
27+
const [projectResponse, deviceResponse, uploadResponse] = await Promise.all([
28+
runAwsJson('get-project', ['--arn', options.projectArn]),
29+
runAwsJson('get-device', ['--arn', options.deviceArn]),
30+
options.appArn ? runAwsJson('get-upload', ['--arn', options.appArn]) : undefined,
31+
]);
32+
const project = readAwsResource(projectResponse, 'project');
33+
const device = readAwsResource(deviceResponse, 'device');
34+
const devicePlatform = readAwsPlatform(device.platform);
35+
if (devicePlatform !== options.platform) {
36+
throw new AppError(
37+
'INVALID_ARGS',
38+
`AWS Device Farm device "${readString(device.name) ?? options.deviceArn}" is ${devicePlatform}, not ${options.platform}.`,
39+
);
40+
}
41+
42+
const app = options.appArn
43+
? verifyAwsUpload(readAwsResource(uploadResponse, 'upload'), options)
44+
: {
45+
status: 'missing' as const,
46+
message:
47+
'No app upload is attached; AWS Device Farm does not support install after allocation. Reconnect with --aws-app-arn <arn>.',
48+
};
49+
return {
50+
provider: 'aws-device-farm',
51+
service: 'AWS Device Farm',
52+
verificationMessage: options.appArn
53+
? 'Credentials, project, device, and app upload verified.'
54+
: 'Credentials, project, and device verified.',
55+
project: { name: readString(project.name), reference: options.projectArn },
56+
device: {
57+
status: 'verified',
58+
name: readString(device.name) ?? options.deviceArn,
59+
reference: options.deviceArn,
60+
platform: devicePlatform,
61+
osVersion: readString(device.os),
62+
availability: readString(device.availability),
63+
},
64+
app,
65+
};
66+
}
67+
68+
function verifyAwsUpload(
69+
upload: Record<string, unknown>,
70+
options: AwsOptions,
71+
): ProviderConnectionResource {
72+
const status = readString(upload.status);
73+
if (status !== 'SUCCEEDED') {
74+
throw new AppError(
75+
'COMMAND_FAILED',
76+
`AWS Device Farm app upload is not ready (${status ?? 'unknown status'}).`,
77+
{
78+
status,
79+
hint: 'Wait for the upload to succeed or reconnect with a different --aws-app-arn.',
80+
},
81+
);
82+
}
83+
const expectedType = options.platform === 'android' ? 'ANDROID_APP' : 'IOS_APP';
84+
const type = readString(upload.type);
85+
if (type !== expectedType) {
86+
throw new AppError(
87+
'INVALID_ARGS',
88+
`AWS Device Farm app upload type is ${type ?? 'unknown'}, expected ${expectedType}.`,
89+
);
90+
}
91+
return {
92+
status: 'verified',
93+
name: readString(upload.name),
94+
reference: options.appArn,
95+
};
96+
}
97+
98+
function readAwsResource(value: unknown, key: string): Record<string, unknown> {
99+
if (!value || typeof value !== 'object') {
100+
throw new AppError('COMMAND_FAILED', `AWS Device Farm ${key} response was not an object.`);
101+
}
102+
const resource = (value as Record<string, unknown>)[key];
103+
if (!resource || typeof resource !== 'object' || Array.isArray(resource)) {
104+
throw new AppError('COMMAND_FAILED', `AWS Device Farm response missed ${key}.`);
105+
}
106+
return resource as Record<string, unknown>;
107+
}
108+
109+
function readAwsPlatform(value: unknown): 'android' | 'ios' {
110+
if (value === 'ANDROID') return 'android';
111+
if (value === 'IOS') return 'ios';
112+
throw new AppError(
113+
'UNSUPPORTED_PLATFORM',
114+
`AWS Device Farm device platform is ${String(value)}.`,
115+
);
116+
}
117+
118+
function readString(value: unknown): string | undefined {
119+
return typeof value === 'string' && value.length > 0 ? value : undefined;
120+
}

‎packages/provider-webdriver/src/aws-device-farm.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,19 @@ async function runAwsJson(
307307
args: string[],
308308
): Promise<unknown> {
309309
const result = await runHostCommand(command, args);
310-
return JSON.parse(result.stdout) as unknown;
310+
try {
311+
return JSON.parse(result.stdout) as unknown;
312+
} catch (error) {
313+
throw new AppError(
314+
'COMMAND_FAILED',
315+
'AWS Device Farm returned invalid JSON.',
316+
undefined,
317+
error,
318+
);
319+
}
311320
}
312321

313-
function createAwsDeviceFarmCommandRunner(
322+
export function createAwsDeviceFarmCommandRunner(
314323
options: AwsCliDeviceFarmClientOptions,
315324
): (subcommand: string, args: string[]) => Promise<unknown> {
316325
const regionArgs = options.region ? ['--region', options.region] : [];

0 commit comments

Comments
 (0)