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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-device",
"version": "0.14.5",
"version": "0.14.6",
"description": "Agent-driven CLI for mobile UI automation, network inspection, and performance diagnostics across iOS, Android, tvOS, and macOS.",
"license": "MIT",
"author": "Callstack",
Expand Down
11 changes: 10 additions & 1 deletion skills/react-devtools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ agent-device react-devtools profile slow --limit 5
agent-device react-devtools profile rerenders --limit 5
```

Remote iOS bridge order:

```bash
agent-device open <bundle-id> --platform ios --relaunch
agent-device react-devtools start
agent-device open <bundle-id> --platform ios --relaunch
agent-device react-devtools wait --connected
```

Rules:

Keep reads bounded with `--depth`/`find`, treat `@c` refs as reload-local, profile only the investigated interaction, and run the same command in remote Android sessions; the CLI manages the needed local service tunnel.
Keep reads bounded with `--depth`/`find`, treat `@c` refs as reload-local, profile only the investigated interaction, and run the same command in remote Android sessions; the CLI manages the needed local service tunnel. For remote iOS, relaunch after `react-devtools start` because React Native opens the legacy DevTools websocket during JavaScript startup.
155 changes: 155 additions & 0 deletions src/__tests__/cli-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,161 @@ test('normal commands accept direct remote-config usage', async () => {
fs.rmSync(root, { recursive: true, force: true });
});

test('remote-config commands reuse active generated session when profile has no session', async () => {
const { root, home, project } = makeTempWorkspace();
const stateDir = path.join(root, 'state');
const remoteConnectionsDir = path.join(stateDir, 'remote-connections');
fs.mkdirSync(remoteConnectionsDir, { recursive: true });
const remoteConfig = path.join(project, 'agent-device.remote.json');
fs.writeFileSync(
remoteConfig,
JSON.stringify({
daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device',
tenant: 'acme',
runId: 'run-123',
platform: 'android',
}),
'utf8',
);
const now = new Date().toISOString();
fs.writeFileSync(
path.join(remoteConnectionsDir, 'adc-generated.json'),
JSON.stringify({
version: 1,
session: 'adc-generated',
remoteConfigPath: remoteConfig,
remoteConfigHash: hashRemoteConfigFile(remoteConfig),
tenant: 'acme',
runId: 'run-123',
leaseId: 'lease-generated-001',
leaseBackend: 'android-instance',
platform: 'android',
connectedAt: now,
updatedAt: now,
}),
'utf8',
);
fs.writeFileSync(
path.join(remoteConnectionsDir, '.active-session.json'),
JSON.stringify({ session: 'adc-generated' }),
'utf8',
);

const result = await runCliCapture(
['snapshot', '--remote-config', remoteConfig, '--state-dir', stateDir, '--json'],
{
cwd: project,
env: { HOME: home },
sendToDaemon: async (req) => {
if (req.command === 'lease_allocate') {
throw new Error('should reuse the active generated lease');
}
if (req.command === 'lease_heartbeat') {
return {
ok: true,
data: {
lease: {
leaseId: 'lease-generated-001',
tenantId: 'acme',
runId: 'run-123',
backend: 'android-instance',
},
},
};
}
return { ok: true, data: { nodes: [], truncated: false } };
},
},
);

assert.equal(result.code, null);
assert.equal(result.calls.length, 2);
assert.equal(result.calls[0]?.command, 'lease_heartbeat');
assert.equal(result.calls[1]?.command, 'snapshot');
assert.equal(result.calls[1]?.session, 'adc-generated');
assert.equal(result.calls[1]?.meta?.leaseId, 'lease-generated-001');

fs.rmSync(root, { recursive: true, force: true });
});

test('remote-config commands keep profile session over active generated session', async () => {
const { root, home, project } = makeTempWorkspace();
const stateDir = path.join(root, 'state');
const remoteConnectionsDir = path.join(stateDir, 'remote-connections');
fs.mkdirSync(remoteConnectionsDir, { recursive: true });
const remoteConfig = path.join(project, 'agent-device.remote.json');
fs.writeFileSync(
remoteConfig,
JSON.stringify({
daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device',
tenant: 'acme',
runId: 'run-profile',
session: 'profile-session',
platform: 'android',
}),
'utf8',
);
const now = new Date().toISOString();
fs.writeFileSync(
path.join(remoteConnectionsDir, 'adc-generated.json'),
JSON.stringify({
version: 1,
session: 'adc-generated',
remoteConfigPath: remoteConfig,
remoteConfigHash: hashRemoteConfigFile(remoteConfig),
tenant: 'acme',
runId: 'run-active',
leaseId: 'lease-generated-001',
leaseBackend: 'android-instance',
platform: 'android',
connectedAt: now,
updatedAt: now,
}),
'utf8',
);
fs.writeFileSync(
path.join(remoteConnectionsDir, '.active-session.json'),
JSON.stringify({ session: 'adc-generated' }),
'utf8',
);

const result = await runCliCapture(
['snapshot', '--remote-config', remoteConfig, '--state-dir', stateDir, '--json'],
{
cwd: project,
env: { HOME: home },
sendToDaemon: async (req) => {
if (req.command === 'lease_heartbeat') {
throw new Error('should not reuse the active generated lease');
}
if (req.command === 'lease_allocate') {
return {
ok: true,
data: {
lease: {
leaseId: 'lease-profile-001',
tenantId: 'acme',
runId: 'run-profile',
backend: 'android-instance',
},
},
};
}
return { ok: true, data: { nodes: [], truncated: false } };
},
},
);

assert.equal(result.code, null);
assert.equal(result.calls.length, 2);
assert.equal(result.calls[0]?.command, 'lease_allocate');
assert.equal(result.calls[1]?.command, 'snapshot');
assert.equal(result.calls[1]?.session, 'profile-session');
assert.equal(result.calls[1]?.meta?.leaseId, 'lease-profile-001');

fs.rmSync(root, { recursive: true, force: true });
});

test('devices allocates a pending remote lease before listing devices', async () => {
const { root, home, project } = makeTempWorkspace();
const stateDir = path.join(root, 'state');
Expand Down
42 changes: 42 additions & 0 deletions src/__tests__/cli-react-devtools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,48 @@ for (const { label, leaseBackend } of remoteBridgeBackends) {
});
}

test('react-devtools wait hints to relaunch remote iOS after startup-time connection misses', async () => {
const env = { ...process.env };
let stderr = '';
const originalStderrWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: string | Uint8Array) => {
stderr += chunk.toString();
return true;
}) as typeof process.stderr.write;
vi.mocked(runCmdStreaming).mockResolvedValueOnce({
exitCode: 1,
stdout: '',
stderr: '',
});
vi.mocked(ensureReactDevtoolsCompanion).mockResolvedValueOnce({
pid: 123,
spawned: true,
statePath: '/tmp/state.json',
logPath: '/tmp/companion.log',
});

try {
const exitCode = await runReactDevtoolsCommand(['wait', '--connected'], {
stateDir: '/tmp/agent-device-state',
session: 'default',
cwd: '/tmp/project',
env,
flags: {
...remoteBridgeScope,
leaseBackend: 'ios-instance',
remoteConfig: '/tmp/remote.json',
session: 'default',
},
});

assert.equal(exitCode, 1);
assert.match(stderr, /Remote iOS React DevTools connects during JavaScript startup/);
assert.match(stderr, /open <bundle-id> --platform ios --relaunch/);
} finally {
process.stderr.write = originalStderrWrite;
}
});

test('react-devtools stop cleans up remote companion', async () => {
const env = { ...process.env };
vi.mocked(runCmdStreaming).mockResolvedValueOnce({
Expand Down
37 changes: 37 additions & 0 deletions src/__tests__/client-metro-companion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,43 @@ test('spawned companion uses neutral env names', async () => {
}
});

test('state sentinel exists before spawning companion worker', async () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-companion-sentinel-'));
try {
vi.mocked(runCmdDetached).mockImplementationOnce((_, __, options) => {
const statePath = options?.env?.AGENT_DEVICE_COMPANION_TUNNEL_STATE_PATH;
if (typeof statePath !== 'string') {
throw new Error('expected companion state path env');
}
assert.equal(fs.existsSync(statePath), true);
assert.equal(fs.readFileSync(statePath, 'utf8'), '');
return 1001;
});
vi.mocked(readProcessStartTime).mockReturnValue('start-1001');
vi.mocked(readProcessCommand).mockReturnValue(
`${process.execPath} src/companion-tunnel.ts --agent-device-run-metro-companion`,
);

const spawned = await ensureMetroCompanion({
projectRoot,
serverBaseUrl: 'https://bridge.example.test',
bearerToken: 'token',
localBaseUrl: 'http://127.0.0.1:8081',
bridgeScope: TEST_BRIDGE_SCOPE,
consumerKey: 'session-a',
});

const state = JSON.parse(fs.readFileSync(spawned.statePath, 'utf8')) as {
pid?: number;
consumers?: string[];
};
assert.equal(state.pid, 1001);
assert.deepEqual(state.consumers, ['session-a']);
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});

test('legacy state without bridge scope is stopped before respawn', async () => {
const projectRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'agent-device-metro-companion-legacy-'),
Expand Down
49 changes: 48 additions & 1 deletion src/__tests__/upload-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import os from 'node:os';
import path from 'node:path';
import { once } from 'node:events';
import { uploadArtifact } from '../upload-client.ts';
import { runCmdSync } from '../utils/exec.ts';
import { runCmdSync, withCommandExecutorOverride } from '../utils/exec.ts';

const TEST_TOKEN = 'agent-device-upload-test-token';
const tempDirs: string[] = [];
Expand Down Expand Up @@ -400,6 +400,53 @@ test('uploadArtifact preflights and legacy-uploads compressed app bundle directo
}
});

test('uploadArtifact disables macOS AppleDouble entries when archiving app bundles', async () => {
const tempRoot = createTempDir();
const appPath = path.join(tempRoot, 'Sample.app');
fs.mkdirSync(appPath, { recursive: true });
fs.writeFileSync(path.join(appPath, 'Info.plist'), 'fake-plist');
let tarEnv: NodeJS.ProcessEnv | undefined;

const server = await startServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/upload/preflight') {
const body = JSON.parse((await readRequestBody(req)).toString('utf8')) as {
fileName: string;
artifactType: string;
};
assert.equal(body.fileName, 'Sample.app');
assert.equal(body.artifactType, 'app-bundle');
sendJson(res, { ok: true, cacheHit: true, uploadId: 'upload-cached-app' });
return;
}
res.statusCode = 404;
res.end('not found');
});

try {
const uploadId = await withCommandExecutorOverride(
(cmd, args, options) => {
if (cmd !== 'tar') return undefined;
tarEnv = options.env;
const archivePath = args[1];
assert.equal(args[0], 'czf');
assert.equal(typeof archivePath, 'string');
fs.writeFileSync(archivePath, 'fake-archive');
return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 });
},
async () =>
await uploadArtifact({
localPath: appPath,
baseUrl: server.baseUrl,
token: TEST_TOKEN,
}),
);
assert.equal(uploadId, 'upload-cached-app');
assert.equal(tarEnv?.COPYFILE_DISABLE, '1');
} finally {
await server.close();
}
});

test('uploadArtifact uploads APK, AAB, and IPA files without wrapping them', async () => {
const cases = [
{ filename: 'app.apk', platform: 'android' },
Expand Down
4 changes: 3 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
stateDir: daemonPaths.baseDir,
session: sessionName,
remoteConfig: flags.remoteConfig,
hasResolvedSession: flags.session !== undefined,
});
effectiveFlags = connectionDefaults
? mergeConnectionFlags(flags, connectionDefaults.flags, explicitFlagKeys)
Expand Down Expand Up @@ -400,6 +401,7 @@ function resolveActiveConnectionDefaults(options: {
stateDir: string;
session: string;
remoteConfig?: string;
hasResolvedSession: boolean;
}): {
flags: Partial<CliFlags>;
runtime?: SessionRuntimeHints;
Expand All @@ -413,7 +415,7 @@ function resolveActiveConnectionDefaults(options: {
env: process.env,
allowActiveFallback:
!options.explicitFlagKeys.has('session') &&
(!options.remoteConfig || options.command === 'disconnect'),
(!options.remoteConfig || options.command === 'disconnect' || !options.hasResolvedSession),
validateRemoteConfigHash: options.command !== 'disconnect',
});
return defaults;
Expand Down
Loading
Loading