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
10 changes: 10 additions & 0 deletions docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ one minute, while a cloud WebDriver connection profile asks for ten. A single co
longer than its own lease is therefore ordinary on the default and only reachable through a profile
on the longer one.

## Provider allocation

Allocation is admitted work too. The registry records a lease before a hosted provider creates the
session behind it, and creating that session can take longer than the lease's inactivity TTL, so a
lease timed from its record was already expired, or nearly so, when its client first received it, and
the paid session it pointed at was orphaned. The allocation therefore holds a work pass while the
provider allocates: the lease cannot expire underneath it, and a successful allocation still wanted by
its requester starts the inactivity TTL from the moment allocation completed. A requester that hung up
preserves nothing, and its allocation is released as before.

## Client-side work that precedes admission

Protecting admitted work covers nothing that happens before a request is admitted. Installing an
Expand Down
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
5 changes: 5 additions & 0 deletions packages/contracts/src/app-deployment-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export type AppDeploymentSource =
export type MaterializedAppSource = Readonly<{
archivePath?: string;
installablePath: string;
/**
* The single file a hosted provider uploads for this build, named by the materializer that knows
* its format. Absent when the installable is already that file or no such file exists.
*/
uploadPath?: string;
bundleId?: string;
packageName?: string;
appName?: string;
Expand Down
73 changes: 73 additions & 0 deletions packages/contracts/src/provider-profile-fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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: 'consumed',
providerDeviceType: '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',
providerDeviceType: '',
providerGeoLocation: undefined,
providerNoResignApp: false,
unrelated: 'x',
},
DECLARATION,
),
);
});

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

test('every refused field is reported at once', () => {
assert.throws(
() =>
rejectRefusedProviderProfileFields(
{ providerGeoLocation: 'US', providerNoResignApp: true, providerDeviceType: 'virtual' },
DECLARATION,
),
/--provider-device-type, --provider-geo-location, --provider-no-resign-app are not supported by Fake Cloud\./,
);
});
66 changes: 66 additions & 0 deletions packages/contracts/src/provider-profile-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { AppError } from '@agent-device/kernel/errors';
import 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'>>;
}>;

const PROVIDER_PROFILE_FIELD_FLAGS: Readonly<Record<ProviderProfileField, string>> = {
providerApp: '--provider-app',
providerOsVersion: '--provider-os-version',
providerDeviceType: '--provider-device-type',
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',
};

/**
* 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]))
.map((field) => PROVIDER_PROFILE_FIELD_FLAGS[field]);
if (refused.length === 0) return;
const plural = refused.length !== 1;
throw new AppError(
'INVALID_ARGS',
`${refused.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,
},
);
}

function isSet(value: unknown): boolean {
return value !== undefined && value !== null && value !== false && value !== '';
}
27 changes: 26 additions & 1 deletion packages/platform-apple/src/core/install-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import path from 'node:path';
import type { LocalInstallSource } from '@agent-device/kernel/contracts';
import { readIosBundleInfo } from './bundle-info.ts';
import { AppError } from '@agent-device/kernel/errors';
import { extractArchiveSafely, ArchiveBudget } from '@agent-device/host-kit/archive';
import {
archiveTypeFromPath,
extractArchiveSafely,
ArchiveBudget,
} from '@agent-device/host-kit/archive';

import {
installArtifactArchiveBudget,
Expand Down Expand Up @@ -35,6 +39,7 @@ type IosPayloadAppBundle = {
export type PreparedIosInstallArtifact = {
archivePath?: string;
installablePath: string;
uploadPath?: string;
bundleId?: string;
appName?: string;
cleanup: () => Promise<void>;
Expand Down Expand Up @@ -80,9 +85,11 @@ async function prepareIosInstallArtifactInScope(
? materialized.installablePath
: undefined);

const uploadPath = iosUploadPath(materialized);
return {
archivePath,
installablePath: resolvedInstallable.installPath,
...(uploadPath ? { uploadPath } : {}),
bundleId: bundleInfo.bundleId,
appName: bundleInfo.appName,
cleanup: async () => {
Expand All @@ -102,6 +109,24 @@ async function prepareIosInstallArtifactInScope(

export { readIosBundleInfo } from './bundle-info.ts';

/**
* The installable is an extracted `.app` directory, which no hosted upload API accepts. The file
* that carries it is the `.ipa` it was unpacked from, or the zip the `.app` was extracted from
* directly; an outer archive that merely wrapped either is never it.
*/
function iosUploadPath(materialized: {
containingArchivePath?: string;
installablePath: string;
}): string | undefined {
if (materialized.installablePath.toLowerCase().endsWith('.ipa')) {
return materialized.installablePath;
}
const { containingArchivePath } = materialized;
return containingArchivePath && archiveTypeFromPath(containingArchivePath) === 'zip'
? containingArchivePath
: undefined;
}

async function resolveIosInstallablePath(
appPath: string,
options?: InstallIosArtifactOptions,
Expand Down
29 changes: 29 additions & 0 deletions packages/provider-limrun/src/device.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
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',
providerDeviceType: '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';

Expand Down
8 changes: 5 additions & 3 deletions packages/provider-limrun/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,15 +231,17 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime {
if (lease.leaseProvider !== this.provider) return undefined;
const platform = platformForLimrunLeaseBackend(lease.backend);
if (!platform) return undefined;
const existing = this.sessions.get(lease.leaseId);
if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device };

const {
allocateLimrunAndroidSession,
allocateLimrunIosSession,
rejectRefusedLimrunProfileFields,
resolvePreinstalledAppId,
resolveRequestedLimrunAppAsset,
} = await import('./session-allocation.ts');
// Before the reuse below: a repeat allocation of a live lease carries flags of its own.
rejectRefusedLimrunProfileFields(context);
const existing = this.sessions.get(lease.leaseId);
if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device };
const requestedAsset = await resolveRequestedLimrunAppAsset(this.limrun, platform, context);
const session =
platform === 'ios'
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
4 changes: 0 additions & 4 deletions packages/provider-webdriver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@
"./providers": {
"types": "./src/providers.ts",
"default": "./src/providers.ts"
},
"./testmu-device-features": {
"types": "./src/testmu-device-features.ts",
"default": "./src/testmu-device-features.ts"
}
}
}
5 changes: 3 additions & 2 deletions packages/provider-webdriver/src/artifact-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export function urlArtifactFromDetails(
kind: CloudArtifact['kind'],
name: string,
): CloudArtifact | undefined {
const url = details[field];
if (typeof url !== 'string' || url.length === 0) return undefined;
const value = details[field];
const url = typeof value === 'string' ? value.trim() : '';
if (url.length === 0) return undefined;
return { provider, providerSessionId, kind, name, url, availability: 'ready' };
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ async function fetchBrowserStackJson(
hints: {
service: 'BrowserStack',
unauthorizedHint: 'Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.',
serviceHint: 'Retry connect or check the BrowserStack service status.',
networkHint: 'Check network access to api-cloud.browserstack.com and retry connect.',
},
});
Expand Down
Loading