Skip to content
Open
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
4 changes: 4 additions & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@
{
"file": "src/platforms/ios/index.ts",
"exports": ["listSimulatorApps", "uninstallIosApp"]
},
{
"file": "src/cloud-webdriver.ts",
"exports": ["CLOUD_WEBDRIVER_PROVIDERS"]
}
],
"usedClassMembers": ["name", "listActiveLeases", "delete", "values", "elapsedMs", "isExpired"],
Expand Down
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

- Provider-backed integration scenario: device-free integration test that runs the real daemon request path and replaces only external device or host tool execution.
- Provider: request-scoped adapter interface for external device, runner, or host tool execution.
- Cloud WebDriver runtime: package-shaped `ProviderDeviceRuntime` implementation that maps a
cloud-owned Appium/WebDriver session into agent-device lease, inventory, install, interactor, and
release hooks without adding provider-specific branches to daemon routing. Cloud WebDriver
adapters must expose explicit command capabilities because snapshots come from Appium page source
rather than agent-device native iOS runner or Android helper backends.
- CloudArtifact: provider-hosted session output such as video, Appium logs, device logs, automation
logs, or provider dashboard links. Cloud artifacts stay under the `cloudArtifacts` response field
so they do not collide with daemon-managed local/downloadable `artifacts`.
- Provider transcript: exact record of provider calls used when a test must verify platform command translation.
- Scenario transcript: command-level integration flow that describes user-visible behavior through daemon commands.
- In-process provider scenario harness: integration runner that invokes the daemon request handler directly without opening an HTTP listener.
Expand Down
19 changes: 19 additions & 0 deletions scripts/integration-progress-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,25 @@ function summarizeProviderScenarioFlagExclusions() {
owner: 'connection/runtime/request policy tests',
keys: ['force', 'noLogin', 'sessionLock', 'sessionLocked', 'sessionLockConflicts'],
},
{
name: 'cloud artifact provider lookup',
owner:
'cloud provider profile, artifact provider, CLI output, and cloud WebDriver provider scenario tests',
keys: [
'provider',
'providerSessionId',
'providerApp',
'providerOsVersion',
'providerProject',
'providerBuild',
'providerSessionName',
'awsProjectArn',
'awsDeviceArn',
'awsAppArn',
'awsRegion',
'awsInteractionMode',
],
},
{
name: 'Metro and React Native runtime preparation',
owner: 'Metro companion integration and parser tests',
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,7 @@ function createStubClient(params: {
list: async () => [],
stateDir: async () => '/tmp/agent-device-state',
close: async () => ({ session: 'default', identifiers: { session: 'default' } }),
artifacts: unexpectedCommandCall,
},
apps: {
install: async () => ({
Expand Down
112 changes: 112 additions & 0 deletions src/__tests__/cloud-connect-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,77 @@ test('connect without remote config reports unsupported cloud profile keys', asy
}
});

test('connect browserstack generates local provider profile without credentials', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-browserstack-'));
const stateDir = path.join(tempRoot, '.state');
vi.stubEnv('BROWSERSTACK_USERNAME', 'browser-user');
vi.stubEnv('BROWSERSTACK_ACCESS_KEY', 'browser-key');

try {
await connectWithGeneratedProviderProfile({
stateDir,
positionals: ['browserstack'],
flags: {
platform: 'android',
device: 'Google Pixel 8',
providerOsVersion: '14.0',
providerApp: 'bs://app-id',
providerProject: 'agent-device',
providerBuild: 'build-a',
},
});

const state = readRequiredActiveState(stateDir);
assert.equal(state.tenant, 'browserstack');
assert.equal(state.leaseProvider, 'browserstack');
assert.equal(state.daemon?.baseUrl, undefined);
assert.match(state.remoteConfigPath, /generated\/browserstack-[a-f0-9]{16}\.json$/);
const generated = readGeneratedConfig(state.remoteConfigPath);
assert.equal(generated.providerApp, 'bs://app-id');
assert.equal(generated.providerOsVersion, '14.0');
assert.equal(generated.providerProject, 'agent-device');
assert.equal(generated.providerBuild, 'build-a');
assert.equal(JSON.stringify(generated).includes('browser-key'), false);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});

test('connect aws-device-farm generates local provider profile from flags', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-aws-'));
const stateDir = path.join(tempRoot, '.state');

try {
await connectWithGeneratedProviderProfile({
stateDir,
positionals: ['aws-device-farm'],
flags: {
platform: 'ios',
device: 'Apple iPhone 15',
awsProjectArn: 'arn:aws:devicefarm:us-west-2:123:project:project-a',
awsDeviceArn: 'arn:aws:devicefarm:us-west-2::device:device-a',
awsAppArn: 'arn:aws:devicefarm:us-west-2:123:upload:app-a',
awsRegion: 'us-west-2',
awsInteractionMode: 'INTERACTIVE',
},
});

const state = readRequiredActiveState(stateDir);
assert.equal(state.tenant, 'aws-device-farm');
assert.equal(state.leaseProvider, 'aws-device-farm');
assert.equal(state.daemon?.baseUrl, undefined);
assert.match(state.remoteConfigPath, /generated\/aws-device-farm-[a-f0-9]{16}\.json$/);
const generated = readGeneratedConfig(state.remoteConfigPath);
assert.equal(generated.awsProjectArn, 'arn:aws:devicefarm:us-west-2:123:project:project-a');
assert.equal(generated.awsDeviceArn, 'arn:aws:devicefarm:us-west-2::device:device-a');
assert.equal(generated.awsAppArn, 'arn:aws:devicefarm:us-west-2:123:upload:app-a');
assert.equal(generated.awsRegion, 'us-west-2');
assert.equal(generated.awsInteractionMode, 'INTERACTIVE');
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});

function mockCloudConnectionProfile(connection: Record<string, unknown>): ReturnType<typeof vi.fn> {
mockedResolveCloudAccessForConnect.mockResolvedValue({
accessToken: 'adc_agent_cloud',
Expand Down Expand Up @@ -197,15 +268,56 @@ async function connectWithGeneratedCloudProfile(stateDir: string): Promise<void>
}
}

async function connectWithGeneratedProviderProfile(options: {
stateDir: string;
positionals: string[];
flags: Partial<Parameters<typeof connectCommand>[0]['flags']>;
}): Promise<void> {
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
try {
await connectCommand({
positionals: options.positionals,
flags: {
json: true,
help: false,
version: false,
stateDir: options.stateDir,
...options.flags,
},
client: {} as AgentDeviceClient,
});
} finally {
stdoutWrite.mockRestore();
}
}

function readGeneratedConfig(configPath: string): {
tenant?: string;
leaseProvider?: string;
clientId?: string;
providerApp?: string;
providerOsVersion?: string;
providerProject?: string;
providerBuild?: string;
awsProjectArn?: string;
awsDeviceArn?: string;
awsAppArn?: string;
awsRegion?: string;
awsInteractionMode?: string;
} {
return JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
tenant?: string;
leaseProvider?: string;
clientId?: string;
providerApp?: string;
providerOsVersion?: string;
providerProject?: string;
providerBuild?: string;
awsProjectArn?: string;
awsDeviceArn?: string;
awsAppArn?: string;
awsRegion?: string;
awsInteractionMode?: string;
};
}

Expand Down
126 changes: 126 additions & 0 deletions src/__tests__/default-cloud-artifact-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import { afterEach, test } from 'vitest';
import { createDefaultCloudArtifactProvider } from '../default-cloud-artifact-provider.ts';
import { withCommandExecutorOverride } from '../utils/exec.ts';

let activeServer: http.Server | undefined;

afterEach(async () => {
if (!activeServer) return;
await new Promise<void>((resolve, reject) => {
activeServer?.close((error) => (error ? reject(error) : resolve()));
});
activeServer = undefined;
});

test('default cloud artifact provider maps BrowserStack historical sessions from env credentials', async () => {
const server = await startSessionDetailsServer();
const provider = createDefaultCloudArtifactProvider({
BROWSERSTACK_USERNAME: 'user',
BROWSERSTACK_ACCESS_KEY: 'key',
BROWSERSTACK_SESSION_DETAILS_ENDPOINT: `${server.url}/sessions`,
});

const result = await provider.listCloudArtifacts?.({
provider: 'browserstack',
providerSessionId: 'wd-1',
});

assert.equal(result?.status, 'ready');
assert.deepEqual(
result?.cloudArtifacts.map((artifact) => artifact.kind),
['video', 'appium-log', 'device-log', 'provider-session', 'provider-session'],
);
});

test('default cloud artifact provider maps AWS Device Farm historical sessions via aws cli', async () => {
const calls: string[][] = [];
const provider = createDefaultCloudArtifactProvider({});
await withCommandExecutorOverride(
async (cmd, args) => {
calls.push([cmd, ...args]);
return {
stdout: JSON.stringify({
artifacts: [
{
name: args.includes('LOG') ? 'Appium Server Output' : 'Video',
type: args.includes('LOG') ? 'APPIUM_SERVER_OUTPUT' : 'VIDEO',
extension: args.includes('LOG') ? 'log' : 'mp4',
url: 'https://aws.example/artifact',
},
],
}),
stderr: '',
exitCode: 0,
};
},
async () => {
const result = await provider.listCloudArtifacts?.({
provider: 'aws-device-farm',
providerSessionId: 'arn:aws:devicefarm:us-west-2:123:session/project/session/00000',
});
assert.equal(result?.status, 'ready');
assert.deepEqual(
result?.cloudArtifacts.map((artifact) => artifact.kind),
['video', 'appium-log'],
);
},
);

assert.equal(calls[0]?.includes('us-west-2'), true);
assert.equal(calls[1]?.includes('LOG'), true);
});

test('default cloud artifact provider ignores lookups without a provider session id', async () => {
const provider = createDefaultCloudArtifactProvider({});

const result = await provider.listCloudArtifacts?.({
provider: 'browserstack',
});

assert.equal(result, undefined);
});

test('default cloud artifact provider does not treat broad aws as a provider name', async () => {
const provider = createDefaultCloudArtifactProvider({});

const result = await provider.listCloudArtifacts?.({
provider: 'aws',
providerSessionId: 'arn:aws:devicefarm:us-west-2:123:session/project/session/00000',
});

assert.equal(result, undefined);
});

async function startSessionDetailsServer(): Promise<{ url: string }> {
const server = http.createServer((req, res) => respond(req, res));
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
activeServer = server;
const address = server.address();
assert.ok(address && typeof address === 'object');
return { url: `http://127.0.0.1:${address.port}` };
}

function respond(req: IncomingMessage, res: ServerResponse): void {
if (req.method === 'GET' && req.url === '/sessions/wd-1.json') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
automation_session: {
video_url: 'https://browserstack.example/video.mp4',
appium_logs_url: 'https://browserstack.example/appium.log',
device_logs_url: 'https://browserstack.example/device.log',
browser_url: 'https://browserstack.example/dashboard',
public_url: 'https://browserstack.example/public',
},
}),
);
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
}
14 changes: 12 additions & 2 deletions src/__tests__/remote-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ test('connect proxy rejects remote-config and unknown provider combinations', as
},
client: createTestClient(),
}),
/Supported providers: proxy/,
/Supported providers: cloud, proxy, browserstack, aws-device-farm/,
);
fs.rmSync(tempRoot, { recursive: true, force: true });
});
Expand Down Expand Up @@ -1878,6 +1878,7 @@ test('disconnect without a session uses active connection state', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-disconnect-active-'));
const stateDir = path.join(tempRoot, '.state');
const remoteConfigPath = path.join(tempRoot, 'remote.json');
const closedSessions: Array<{ session: string | undefined; shutdown: boolean | undefined }> = [];
fs.writeFileSync(remoteConfigPath, '{}');
writeRemoteConnectionState({
stateDir,
Expand Down Expand Up @@ -1905,10 +1906,19 @@ test('disconnect without a session uses active connection state', async () => {
stateDir,
shutdown: true,
},
client: createTestClient(),
client: createTestClient({
closeSession: async (options) => {
closedSessions.push({ session: options?.session, shutdown: options?.shutdown });
return {
session: options?.session ?? 'default',
identifiers: { session: options?.session ?? 'default' },
};
},
}),
});
});

assert.deepEqual(closedSessions, [{ session: 'adc-android', shutdown: true }]);
assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-android' }), null);
fs.rmSync(tempRoot, { recursive: true, force: true });
});
Expand Down
10 changes: 10 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,16 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
leaseProvider: connection?.leaseProvider,
clientId: connection?.clientId,
deviceKey: connection?.deviceKey,
providerApp: currentFlags.providerApp,
providerOsVersion: currentFlags.providerOsVersion,
providerProject: currentFlags.providerProject,
providerBuild: currentFlags.providerBuild,
providerSessionName: currentFlags.providerSessionName,
awsProjectArn: currentFlags.awsProjectArn,
awsDeviceArn: currentFlags.awsDeviceArn,
awsAppArn: currentFlags.awsAppArn,
awsRegion: currentFlags.awsRegion,
awsInteractionMode: currentFlags.awsInteractionMode,
runtime,
lockPolicy: binding.lockPolicy,
lockPlatform: binding.defaultPlatform,
Expand Down
Loading
Loading