diff --git a/package.json b/package.json index 8f5ed776..c41795f4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/skills/react-devtools/SKILL.md b/skills/react-devtools/SKILL.md index b58539f8..53172d33 100644 --- a/skills/react-devtools/SKILL.md +++ b/skills/react-devtools/SKILL.md @@ -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 --platform ios --relaunch +agent-device react-devtools start +agent-device open --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. diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index 53b2b19b..d3ac0c62 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -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'); diff --git a/src/__tests__/cli-react-devtools.test.ts b/src/__tests__/cli-react-devtools.test.ts index 91e38372..ce32e2bb 100644 --- a/src/__tests__/cli-react-devtools.test.ts +++ b/src/__tests__/cli-react-devtools.test.ts @@ -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 --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({ diff --git a/src/__tests__/client-metro-companion.test.ts b/src/__tests__/client-metro-companion.test.ts index edbb4de0..06fd4159 100644 --- a/src/__tests__/client-metro-companion.test.ts +++ b/src/__tests__/client-metro-companion.test.ts @@ -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-'), diff --git a/src/__tests__/upload-client.test.ts b/src/__tests__/upload-client.test.ts index 3836158b..75bb7492 100644 --- a/src/__tests__/upload-client.test.ts +++ b/src/__tests__/upload-client.test.ts @@ -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[] = []; @@ -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' }, diff --git a/src/cli.ts b/src/cli.ts index b340f660..cfcc0a94 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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) @@ -400,6 +401,7 @@ function resolveActiveConnectionDefaults(options: { stateDir: string; session: string; remoteConfig?: string; + hasResolvedSession: boolean; }): { flags: Partial; runtime?: SessionRuntimeHints; @@ -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; diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 96120935..db8004e0 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -52,6 +52,35 @@ function isRemoteBridgeBackend(leaseBackend: CliFlags['leaseBackend']): boolean return leaseBackend === 'android-instance' || leaseBackend === 'ios-instance'; } +function isRemoteIosBridgeBackend(leaseBackend: CliFlags['leaseBackend']): boolean { + return leaseBackend === 'ios-instance'; +} + +function isWaitConnectedCommand(args: string[]): boolean { + return args[0] === 'wait' && args.includes('--connected'); +} + +function maybePrintRemoteIosWaitHint( + args: string[], + flags: ReactDevtoolsCommandOptions['flags'], + exitCode: number, +): void { + if ( + exitCode === 0 || + !isWaitConnectedCommand(args) || + !isRemoteIosBridgeBackend(flags?.leaseBackend) + ) { + return; + } + process.stderr.write( + [ + 'Hint: Remote iOS React DevTools connects during JavaScript startup.', + 'If the app was already open before `agent-device react-devtools start`, relaunch it with `agent-device open --platform ios --relaunch`, then retry `agent-device react-devtools wait --connected`.', + '', + ].join('\n'), + ); +} + function readRemoteBridgeField( missing: string[], field: string, @@ -135,7 +164,7 @@ export async function runReactDevtoolsCommand( ): Promise { const cwd = options.cwd ?? process.cwd(); const env = options.env ?? process.env; - return await withRemoteDevtoolsCompanion(args, options, async () => { + const exitCode = await withRemoteDevtoolsCompanion(args, options, async () => { const result = await runCmdStreaming('npm', buildReactDevtoolsNpmExecArgs(args), { cwd, env, @@ -149,4 +178,6 @@ export async function runReactDevtoolsCommand( }); return result.exitCode; }); + maybePrintRemoteIosWaitHint(args, options.flags, exitCode); + return exitCode; } diff --git a/src/client-companion-tunnel.ts b/src/client-companion-tunnel.ts index 8068e414..ab42cf1c 100644 --- a/src/client-companion-tunnel.ts +++ b/src/client-companion-tunnel.ts @@ -181,6 +181,11 @@ function writeCompanionState(statePath: string, state: CompanionTunnelState): vo fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); } +function touchCompanionState(statePath: string): void { + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.closeSync(fs.openSync(statePath, 'a')); +} + function clearCompanionState(statePath: string): void { try { fs.unlinkSync(statePath); @@ -447,8 +452,18 @@ export async function ensureCompanionTunnel( clearCompanionArtifacts(paths, options.definition); } - const spawned = spawnCompanionProcess(options, paths.logPath); - writeCompanionState(paths.statePath, withConsumer(spawned, consumerKey)); + touchCompanionState(paths.statePath); + let spawned: CompanionTunnelState | undefined; + try { + spawned = spawnCompanionProcess(options, paths.logPath); + writeCompanionState(paths.statePath, withConsumer(spawned, consumerKey)); + } catch (error) { + if (spawned) { + await stopCompanionProcess(spawned, options.definition).catch(() => {}); + } + clearCompanionArtifacts(paths, options.definition); + throw error; + } return { pid: spawned.pid, spawned: true, diff --git a/src/upload-client.ts b/src/upload-client.ts index 2b290616..6a42c24a 100644 --- a/src/upload-client.ts +++ b/src/upload-client.ts @@ -136,13 +136,16 @@ async function createGzipTarArchive(localPath: string, cleanupPaths: string[]): const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-upload-${randomUUID()}-`)); cleanupPaths.push(tempDir); const archivePath = path.join(tempDir, `${path.basename(localPath)}.tar.gz`); - await runCmd('tar', [ - 'czf', - archivePath, - '-C', - path.dirname(localPath), - path.basename(localPath), - ]); + await runCmd( + 'tar', + ['czf', archivePath, '-C', path.dirname(localPath), path.basename(localPath)], + { + env: { + ...process.env, + COPYFILE_DISABLE: '1', + }, + }, + ); return archivePath; } diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 2ca51082..d54f3824 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -950,6 +950,7 @@ test('usageForCommand resolves react-devtools help topic', () => { assert.match(help, /@c refs reset after reload\/remount/); assert.match(help, /isolated --state-dir/); assert.match(help, /local service tunnel/); + assert.match(help, /Remote iOS apps attempt the legacy React DevTools websocket/); }); test('apps defaults to --all filter and allows overrides', () => { diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index aae4e35d..24cd4e10 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -460,6 +460,7 @@ Rules: Keep the profile window narrow; unrelated navigation makes render data noisy. For cross-platform validation with explicit device selectors, prefer isolated --state-dir and restart react-devtools between platforms. Remote Android and iOS bridge runs normally through agent-device react-devtools; the CLI keeps the needed local service tunnel alive until agent-device react-devtools stop or disconnect. Expo support depends on the SDK's bundled React Native runtime. + Remote iOS apps attempt the legacy React DevTools websocket during JavaScript startup. If the app was already open before react-devtools start, run open --platform ios --relaunch, then wait --connected. Example: agent-device react-devtools status diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index cec15991..41965ecf 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -591,6 +591,7 @@ agent-device react-devtools profile rerenders --limit 5 - Keep using `snapshot`, `press`, `fill`, `logs`, `network`, and `perf` for device/app runtime evidence. Use `react-devtools` for React internals. - React Native development builds can connect to the DevTools daemon on port 8097. For Android emulators or physical devices, run `adb reverse tcp:8097 tcp:8097` if the app cannot reach the host. If Metro is local, also run `adb reverse tcp:8081 tcp:8081`. - For Android and iOS sessions connected through a remote bridge profile, `react-devtools` registers a lease-scoped companion tunnel to the sandbox-local DevTools daemon at `127.0.0.1:8097`. Android bridge profiles use the bridge-owned remote `adb reverse` mapping; iOS bridge profiles use the bridge-owned wildcard Metro host tunnel. The CLI keeps the companion alive until `agent-device react-devtools stop` or `agent-device disconnect`. +- For remote iOS bridge sessions, open the app once to create the bridge session, run `agent-device react-devtools start`, then relaunch the same bundle id with `agent-device open --platform ios --relaunch` before `wait --connected`. React Native attempts the legacy DevTools websocket during JavaScript startup, so starting DevTools after the first launch can miss that connection attempt. - Remote bridge React DevTools assumes the React Native-bundled DevTools behavior in React Native 0.83+. Older browser/Chromium DevTools workflows are not assumed to exist inside remote sandboxes. Expo projects should be verified against the SDK's bundled React Native version before relying on this path; this release does not claim a separately verified Expo SDK version. - For cross-platform validation with explicit target selectors, prefer an isolated `--state-dir` over separate named sessions. Named sessions enable bound-session locks during setup. Restart `react-devtools` between iOS and Android runs.