Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9d17045
feat: add cloud webdriver artifacts
thymikee Jun 30, 2026
f1b44aa
fix: clean up local session after provider release failure
thymikee Jun 30, 2026
528f3da
fix: tag cloud webdriver provider requests
thymikee Jun 30, 2026
fd419fa
feat: connect hosted webdriver providers
thymikee Jun 30, 2026
11dbf37
docs: document hosted provider credentials
thymikee Jun 30, 2026
6204154
refactor: tighten cloud webdriver provider internals
thymikee Jun 30, 2026
dcb0001
refactor: consolidate cloud webdriver provider definitions
thymikee Jun 30, 2026
00dfb68
refactor: collapse hosted webdriver runtime wrapper
thymikee Jun 30, 2026
a995080
refactor: reduce cloud webdriver smell surface
thymikee Jun 30, 2026
52e934c
docs: clarify hosted provider interfaces
thymikee Jun 30, 2026
6f6ba31
fix: avoid regex slash trimming in webdriver urls
thymikee Jun 30, 2026
1f117ff
docs: rename hosted providers to device clouds
thymikee Jun 30, 2026
46f1f43
fix: align provider profile imports with remote modules
thymikee Jun 30, 2026
93127d5
fix: skip local android recovery for provider devices
thymikee Jun 30, 2026
76b36da
test: classify cloud provider integration flags
thymikee Jun 30, 2026
939134b
fix: close active cloud connection session
thymikee Jun 30, 2026
eca9afd
test: cover provider disconnect cli flow
thymikee Jun 30, 2026
e98a480
fix: make cloud webdriver sessions launchable
thymikee Jun 30, 2026
95596e8
fix: align cloud webdriver input gestures
thymikee Jun 30, 2026
e0d27de
fix: avoid keyboard input during cloud scroll
thymikee Jun 30, 2026
70b3de2
fix: constrain cloud webdriver scroll gestures
thymikee Jun 30, 2026
63de69d
refactor: isolate cloud webdriver scroll frame
thymikee Jun 30, 2026
73ce7e5
refactor: deduplicate cloud webdriver helpers
thymikee Jul 1, 2026
ecf95c4
refactor: tighten cloud webdriver action types
thymikee Jul 1, 2026
268a96b
fix: harden cloud webdriver release
thymikee Jul 1, 2026
3437d7b
fix: polish provider disconnect diagnostics
thymikee Jul 1, 2026
1365dee
refactor: group connection profile helpers
thymikee Jul 1, 2026
3ab36bf
fix: repair rebased internal paths
thymikee Jul 1, 2026
ce04b37
fix: satisfy cloud webdriver CI guards
thymikee Jul 1, 2026
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 @@ -83,6 +83,10 @@
"resolveRunnerBundleBuildSettings",
"assertSafeDerivedCleanup"
]
},
{
"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' }));
}
Loading
Loading