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
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, test } from 'vitest';
import type { ProviderProfileField } from '@agent-device/contracts/provider-profile-fields';
import {
PROVIDER_PROFILE_FIELD_FLAG_ALIASES,
PROVIDER_PROFILE_FIELD_FLAGS,
} from '@agent-device/contracts/remote';
import { getFlagDefinitionsForKey } from '../flag-registry.ts';

// Refusals and BrowserStack feature errors name flags from this map, so each must be the CLI's
// canonical spelling of that field.
test('every provider profile field flag is the canonical CLI flag for its field', () => {
for (const [field, flag] of Object.entries(PROVIDER_PROFILE_FIELD_FLAGS) as Array<
[ProviderProfileField, string]
>) {
expect(getFlagDefinitionsForKey(field)[0]?.names[0], field).toBe(flag);
}
});

// Contracts cannot depend on the command registry, so refusals carry their own copy of each field's
// accepted aliases; it must match the aliases the CLI parses.
test('every provider profile field lists the aliases the CLI accepts for it', () => {
for (const field of Object.keys(PROVIDER_PROFILE_FIELD_FLAGS) as ProviderProfileField[]) {
expect(PROVIDER_PROFILE_FIELD_FLAG_ALIASES[field] ?? [], field).toEqual(
getFlagDefinitionsForKey(field)[0]?.names.slice(1),
);
}
});
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,10 @@
"types": "./src/runtime-operation-names.ts",
"default": "./src/runtime-operation-names.ts"
},
"./provider-profile-fields": {
"types": "./src/provider-profile-fields.ts",
"default": "./src/provider-profile-fields.ts"
},
"./progress": {
"types": "./src/facades/progress.ts",
"default": "./src/facades/progress.ts"
Expand Down
6 changes: 5 additions & 1 deletion packages/contracts/src/facades/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ export type {
ProviderConnectionResource,
ProviderConnectionVerification,
} from '../provider-connection.ts';
export { PROVIDER_DEVICE_ORIENTATIONS } from '../remote-config-fields.ts';
export {
PROVIDER_DEVICE_ORIENTATIONS,
PROVIDER_PROFILE_FIELD_FLAG_ALIASES,
PROVIDER_PROFILE_FIELD_FLAGS,
} from '../remote-config-fields.ts';
export type {
CloudProviderProfileFields,
ProviderDeviceOrientation,
Expand Down
77 changes: 77 additions & 0 deletions packages/contracts/src/provider-profile-fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { AppError } from '@agent-device/kernel/errors';
import {
rejectRefusedProviderProfileFields,
type ProviderProfileFieldDeclaration,
} from './provider-profile-fields.ts';

const DECLARATION: ProviderProfileFieldDeclaration = {
provider: 'fake',
label: 'Fake Cloud',
fields: {
providerApp: 'consumed',
providerOsVersion: 'refused',
providerProject: 'consumed',
providerBuild: 'consumed',
providerSessionName: 'consumed',
providerDeviceOrientation: 'consumed',
providerGeoLocation: 'refused',
providerTimezone: 'consumed',
providerAppiumVersion: 'consumed',
providerLanguage: 'consumed',
providerLocale: 'consumed',
providerNetworkProfile: 'consumed',
providerCustomNetwork: 'consumed',
providerNoResignApp: 'refused',
awsProjectArn: 'consumed',
awsDeviceArn: 'consumed',
awsAppArn: 'consumed',
awsRegion: 'consumed',
awsInteractionMode: 'consumed',
},
};

test('consumed, unset, empty, and false fields pass', () => {
assert.doesNotThrow(() => rejectRefusedProviderProfileFields(undefined, DECLARATION));
assert.doesNotThrow(() =>
rejectRefusedProviderProfileFields(
{
providerApp: 'app',
providerOsVersion: '',
providerGeoLocation: undefined,
providerNoResignApp: false,
unrelated: 'x',
},
DECLARATION,
),
);
});

test('a refused field fails with its flag, its aliases, and the provider named', () => {
assert.throws(
() => rejectRefusedProviderProfileFields({ providerOsVersion: '18' }, DECLARATION),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === '--provider-os-version (--os-version) is not supported by Fake Cloud.' &&
error.details?.provider === 'fake' &&
JSON.stringify(error.details?.flags) === '["--provider-os-version"]',
);
});

test('every refused field is reported at once', () => {
assert.throws(
() =>
rejectRefusedProviderProfileFields(
{ providerGeoLocation: 'US', providerNoResignApp: true, providerOsVersion: '18' },
DECLARATION,
),
(error: unknown) =>
error instanceof AppError &&
error.message ===
'--provider-os-version (--os-version), --provider-geo-location (--geo-location), --provider-no-resign-app are not supported by Fake Cloud.' &&
JSON.stringify(error.details?.flags) ===
'["--provider-os-version","--provider-geo-location","--provider-no-resign-app"]',
);
});
54 changes: 54 additions & 0 deletions packages/contracts/src/provider-profile-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { AppError } from '@agent-device/kernel/errors';
import {
PROVIDER_PROFILE_FIELD_FLAG_ALIASES,
PROVIDER_PROFILE_FIELD_FLAGS,
type CloudProviderProfileFields,
} from './remote-config-fields.ts';

export type ProviderProfileField = keyof CloudProviderProfileFields;

/**
* What one lease provider does with every Cloud provider profile field. The record is total, so a
* field added to the profile fails to build until each provider says whether it consumes it; a
* field a provider neither reads nor refuses would otherwise ride the profile and be dropped.
*/
export type ProviderProfileFieldDeclaration = Readonly<{
provider: string;
label: string;
fields: Readonly<Record<ProviderProfileField, 'consumed' | 'refused'>>;
}>;

/**
* Fails when `flags` set a profile field the provider refuses. Every route to a provider — connect,
* `leases.allocate`, a hand-authored remote-config profile — runs this against the same declaration.
*/
export function rejectRefusedProviderProfileFields(
flags: Readonly<Record<string, unknown>> | undefined,
declaration: ProviderProfileFieldDeclaration,
): void {
const refused = (Object.keys(declaration.fields) as ProviderProfileField[]).filter(
(field) => declaration.fields[field] === 'refused' && isSet(flags?.[field]),
);
if (refused.length === 0) return;
const plural = refused.length !== 1;
throw new AppError(
'INVALID_ARGS',
`${refused.map(describeFlag).join(', ')} ${plural ? 'are' : 'is'} not supported by ${declaration.label}.`,
{
hint: `Drop ${plural ? 'those flags' : 'the flag'} or use a provider that supports ${plural ? 'them' : 'it'}.`,
provider: declaration.provider,
flags: refused.map((field) => PROVIDER_PROFILE_FIELD_FLAGS[field]),
},
);
}

// The flags bag keeps the field, not the spelling, so name every spelling the user could have typed.
function describeFlag(field: ProviderProfileField): string {
const aliases = PROVIDER_PROFILE_FIELD_FLAG_ALIASES[field];
const flag = PROVIDER_PROFILE_FIELD_FLAGS[field];
return aliases?.length ? `${flag} (${aliases.join(', ')})` : flag;
}

function isSet(value: unknown): boolean {
return value !== undefined && value !== null && value !== false && value !== '';
}
40 changes: 40 additions & 0 deletions packages/contracts/src/remote-config-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,46 @@ export type CloudProviderProfileFields = {
awsInteractionMode?: 'INTERACTIVE' | 'NO_VIDEO' | 'VIDEO_ONLY';
};

/** Canonical CLI flag for each profile field, so an error can name a recovery action. */
export const PROVIDER_PROFILE_FIELD_FLAGS: Readonly<
Record<keyof CloudProviderProfileFields, string>
> = {
providerApp: '--provider-app',
providerOsVersion: '--provider-os-version',
Comment thread
amankansal-lt marked this conversation as resolved.
providerProject: '--provider-project',
providerBuild: '--provider-build',
providerSessionName: '--provider-session-name',
providerDeviceOrientation: '--provider-device-orientation',
providerGeoLocation: '--provider-geo-location',
providerTimezone: '--provider-timezone',
providerAppiumVersion: '--provider-appium-version',
providerLanguage: '--provider-language',
providerLocale: '--provider-locale',
providerNetworkProfile: '--provider-network-profile',
providerCustomNetwork: '--provider-custom-network',
providerNoResignApp: '--provider-no-resign-app',
awsProjectArn: '--aws-project-arn',
awsDeviceArn: '--aws-device-arn',
awsAppArn: '--aws-app-arn',
awsRegion: '--aws-region',
awsInteractionMode: '--aws-interaction-mode',
};

/** Other spellings the CLI accepts for a profile field, so an error also names the one typed. */
export const PROVIDER_PROFILE_FIELD_FLAG_ALIASES: Readonly<
Partial<Record<keyof CloudProviderProfileFields, readonly string[]>>
> = {
providerOsVersion: ['--os-version'],
providerDeviceOrientation: ['--device-orientation'],
providerGeoLocation: ['--geo-location'],
providerTimezone: ['--timezone'],
providerAppiumVersion: ['--appium-version'],
providerLanguage: ['--language'],
providerLocale: ['--locale'],
providerNetworkProfile: ['--network-profile'],
providerCustomNetwork: ['--custom-network'],
};

export type RemoteConfigMetroOptions = {
metroProjectRoot?: string;
metroKind?: MetroPrepareKind;
Expand Down
28 changes: 28 additions & 0 deletions packages/provider-limrun/src/device.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
import { createHash } from 'node:crypto';
import type { DeviceLease } from '@agent-device/contracts/device';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { ProviderProfileFieldDeclaration } from '@agent-device/contracts/provider-profile-fields';

export type LimrunPlatform = 'ios' | 'android';

export const LIMRUN_PROVIDER = 'limrun';

/** Limrun reads only the app to preinstall; it picks the instance itself. */
export const LIMRUN_PROFILE_FIELDS: ProviderProfileFieldDeclaration = {
provider: LIMRUN_PROVIDER,
label: 'Limrun',
fields: {
providerApp: 'consumed',
providerOsVersion: 'refused',
providerProject: 'refused',
providerBuild: 'refused',
providerSessionName: 'refused',
providerDeviceOrientation: 'refused',
providerGeoLocation: 'refused',
providerTimezone: 'refused',
providerAppiumVersion: 'refused',
providerLanguage: 'refused',
providerLocale: 'refused',
providerNetworkProfile: 'refused',
providerCustomNetwork: 'refused',
providerNoResignApp: 'refused',
awsProjectArn: 'refused',
awsDeviceArn: 'refused',
awsAppArn: 'refused',
awsRegion: 'refused',
awsInteractionMode: 'refused',
},
};

const LIMRUN_DEVICE_ID_PREFIX = LIMRUN_PROVIDER;

export function platformForLimrunLeaseBackend(backend: string): LimrunPlatform | undefined {
Expand Down
2 changes: 1 addition & 1 deletion packages/provider-limrun/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { LIMRUN_PROVIDER } from './device.ts';
export { LIMRUN_PROFILE_FIELDS, LIMRUN_PROVIDER } from './device.ts';
export { createLimrunRuntime, type LimrunRuntime, type LimrunRuntimeOptions } from './runtime.ts';
export { verifyLimrunConnection } from './connection-verification.ts';
export type { LimrunInstanceAccess } from './instance-access.ts';
Expand Down
4 changes: 4 additions & 0 deletions packages/provider-limrun/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime {
if (lease.leaseProvider !== this.provider) return undefined;
const platform = platformForLimrunLeaseBackend(lease.backend);
if (!platform) return undefined;
const { rejectRefusedLimrunProfileFields } = await import('./session-allocation.ts');
// Before the reuse below, since a repeat allocation of a live lease carries flags of its own, and
// ahead of attach as well as create, since an attached instance reads none of them either.
rejectRefusedLimrunProfileFields(context);
const existing = this.sessions.get(lease.leaseId);
if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device };

Expand Down
7 changes: 6 additions & 1 deletion packages/provider-limrun/src/session-allocation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type Limrun from '@limrun/api';
import type { DeviceLease, LeaseLifecycleContext } from '@agent-device/contracts/device';
import { AppError } from '@agent-device/kernel/errors';
import { rejectRefusedProviderProfileFields } from '@agent-device/contracts/provider-profile-fields';
import { createLimrunAndroidSession, type LimrunAndroidSession } from './android.ts';
import { buildLimrunDevice } from './device.ts';
import { buildLimrunDevice, LIMRUN_PROFILE_FIELDS } from './device.ts';
import { createLimrunIosSession, type LimrunIosSession } from './ios.ts';
import {
assertLimrunUploadedAppAccess,
Expand Down Expand Up @@ -34,6 +35,10 @@ type SessionAllocationParams = Readonly<{
dependencies: LimrunRuntimeDependencies;
}>;

export function rejectRefusedLimrunProfileFields(context?: LeaseLifecycleContext): void {
rejectRefusedProviderProfileFields(context?.flags, LIMRUN_PROFILE_FIELDS);
}

export async function resolveRequestedLimrunAppAsset(
limrun: Limrun,
platform: 'android' | 'ios',
Expand Down
Loading
Loading