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
5 changes: 5 additions & 0 deletions .changeset/serve-sim-pr-4585.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---

Give agent-device and its simulator preview separate session logs.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createProcessOutput } from '../../utils/processOutput';
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
import { runAgentDeviceRemoteSessionAsync } from '../startAgentDeviceRemoteSession';

Expand Down Expand Up @@ -163,6 +164,27 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});

it('redacts learned daemon credentials in arbitrary output and retained diagnostics', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const token = 'daemon secret/+';
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token });
jest.mocked(startNgrokTunnelAsync).mockImplementation(async () => {
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.secrets).toContain(token);
const output = createProcessOutput(options.logger, options.secrets);
output.stdout.append('unrecognized value: daemon sec');
output.stdout.append('ret/+\n');
output.stderr.append(`escaped value: ${encodeURIComponent(token)}\n`);
expect(output.getOutput()).toBe(
'unrecognized value: [REDACTED]\nescaped value: [REDACTED]\n'
);
return { url: 'https://daemon.test', subdomainId: 'daemon', stopAsync: mockTunnelStopAsync };
});
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(token);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
});

it('hands network capture to serve-sim', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,28 @@ describe(startAgentDeviceDaemonAsync, () => {
}) as never);
});

it.each([false, true])(
'shares the daemon secret list through git fallback=%s',
async fallback => {
if (fallback) {
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
}
const secrets: string[] = [];
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
secrets,
});
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.command).toBe(fallback ? 'bun' : 'node');
expect(options.secrets).toBe(secrets);
secrets.push('learned-daemon-token');
expect(options.secrets).toContain('learned-daemon-token');
await handle.stopAsync();
}
);

it('installs with bun add by default and launches the published daemon', async () => {
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
Expand All @@ -108,6 +130,8 @@ describe(startAgentDeviceDaemonAsync, () => {
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(spawnDetached).toHaveBeenCalledWith({
command: 'node',
logger,
secrets: undefined,
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
env: expect.objectContaining({
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
Expand All @@ -121,6 +145,20 @@ describe(startAgentDeviceDaemonAsync, () => {
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});

it('removes the install directory when the daemon cannot be stopped', async () => {
const stopError = new Error('drain timed out');
stopAsync.mockRejectedValueOnce(stopError);
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;

await expect(handle.stopAsync()).rejects.toBe(stopError);
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});

it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
Expand All @@ -136,10 +174,15 @@ describe(startAgentDeviceDaemonAsync, () => {
});

it('kills the install and does not fall back to git when aborted', async () => {
let installStarted!: () => void;
const started = new Promise<void>(resolve => {
installStarted = resolve;
});
jest.mocked(spawn).mockImplementation(
((_command: string, _args: string[], options?: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
installStarted();
})) as never
);
const controller = new AbortController();
Expand All @@ -151,7 +194,7 @@ describe(startAgentDeviceDaemonAsync, () => {
logger,
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
await started;
controller.abort(failure);

await expect(daemon).rejects.toBe(failure);
Expand Down Expand Up @@ -199,6 +242,8 @@ describe(startAgentDeviceDaemonAsync, () => {
);
expect(spawnDetached).toHaveBeenCalledWith({
command: 'bun',
logger,
secrets: undefined,
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export async function runAgentDeviceRemoteSessionAsync(
let sessionHost: DeviceSessionHost | undefined;
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
let sessionFailed = false;
const daemonSecrets = [ngrokAuthtoken];

// Each task stores what it started, so the teardown below can stop it even when
// another task failed first.
Expand All @@ -131,12 +132,14 @@ export async function runAgentDeviceRemoteSessionAsync(
env,
logger: taskLogger,
signal: tasks.signal,
secrets: daemonSecrets,
});

taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
// The daemon is stored above, so the teardown stops it. The wait itself is bounded
// (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background.
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
daemonSecrets.push(daemonInfo.token);
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);

tasks.signal.throwIfAborted();
Expand All @@ -155,6 +158,7 @@ export async function runAgentDeviceRemoteSessionAsync(
await tasks.untilAborted(device.booted);
tasks.signal.throwIfAborted();
sessionHost = await startDeviceSessionHostAsync(ctx, {
separateLogPhase: true,
runtimePlatform,
env,
logger: taskLogger,
Expand Down Expand Up @@ -267,12 +271,14 @@ export async function startAgentDeviceDaemonAsync({
env,
logger,
signal,
secrets,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
/** Kills the install and stops before the daemon starts, when aborted. No git fallback then. */
signal?: AbortSignal;
secrets?: string[];
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
Expand All @@ -298,12 +304,17 @@ export async function startAgentDeviceDaemonAsync({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
logger,
secrets,
});
return {
...daemonProcess,
stopAsync: async () => {
await daemonProcess.stopAsync();
await fs.promises.rm(installDir, { recursive: true, force: true });
try {
await daemonProcess.stopAsync();
} finally {
await fs.promises.rm(installDir, { recursive: true, force: true });
}
},
};
} catch (err) {
Expand Down Expand Up @@ -334,7 +345,13 @@ export async function startAgentDeviceDaemonAsync({
logger.warn(
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger, signal });
return await startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
logger,
signal,
secrets,
});
}
}

Expand Down Expand Up @@ -365,11 +382,13 @@ async function startAgentDeviceDaemonFromGitAsync({
env,
logger,
signal,
secrets,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
signal?: AbortSignal;
secrets?: string[];
}): Promise<DetachedProcessHandle> {
logger.info(
packageVersion
Expand All @@ -396,6 +415,8 @@ async function startAgentDeviceDaemonFromGitAsync({
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
logger,
secrets,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { Client, fetchExchange } from '@urql/core';
import { getEventListeners } from 'node:events';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
import os from 'node:os';
import fetch from 'node-fetch';

import { type CustomBuildContext } from '../../../customBuildContext';
import * as processes from '../../../utils/processes';
import {
captureDeviceRunSessionPreviewAsync,
ensureMacosPreviewEncoderInstalledAsync,
Expand Down Expand Up @@ -383,18 +385,106 @@ describe(ensureMacosPreviewEncoderInstalledAsync, () => {
[
'brew',
['install', 'webp'],
{ env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' }, logger },
{
env: { HOME: '/Users/expo', HOMEBREW_NO_AUTO_UPDATE: '1' },
logger,
detached: true,
},
],
]);
}
}
);

it('rejects when webp cannot be installed', async () => {
it('does not start a command when setup was already canceled', async () => {
jest.mocked(spawn).mockReset();
jest.mocked(spawn).mockRejectedValue(new Error('brew failed'));
await expect(ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger })).rejects.toThrow(
'brew failed'
);
const controller = new AbortController();
const reason = new Error('session ended');
controller.abort(reason);
await expect(
ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger, signal: controller.signal })
).rejects.toBe(reason);
expect(spawn).not.toHaveBeenCalled();
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
});

it.each(['cwebp', 'brew'])(
'waits for canceled %s to close and never starts another command',
async command => {
jest.mocked(spawn).mockReset();
const controller = new AbortController();
const child = { pid: 12345, kill: jest.fn() };
const killed = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
const closeError = new Error(`${command} closed after cancellation`);
let entered!: () => void;
const commandEntered = new Promise<void>(resolve => {
entered = resolve;
});
let close!: (error: Error) => void;
jest.mocked(spawn).mockImplementation(current => {
if (current !== command) {
return Promise.reject(new Error('cwebp missing')) as ReturnType<typeof spawn>;
}
entered();
return Object.assign(
new Promise((_, reject) => {
close = reject;
}),
{ child }
) as unknown as ReturnType<typeof spawn>;
});
let settled = false;
const result = ensureMacosPreviewEncoderInstalledAsync({
env: {},
logger,
signal: controller.signal,
}).then(
() => {
settled = true;
},
error => {
settled = true;
return error;
}
);
try {
await commandEntered;
controller.abort(new Error('session ended'));
await Promise.resolve();
expect(killed).toHaveBeenCalledWith(child, 'SIGKILL');
expect(settled).toBe(false);
expect(jest.mocked(spawn).mock.calls.at(-1)).toEqual([
command,
command === 'cwebp' ? ['-version'] : ['install', 'webp'],
expect.objectContaining({ detached: true }),
]);
expect(jest.mocked(spawn).mock.calls.every(([, , options]) => !options?.signal)).toBe(true);
} finally {
close(closeError);
await result;
killed.mockRestore();
}
expect(spawn).toHaveBeenCalledTimes(command === 'cwebp' ? 1 : 2);
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
}
);

it('rejects an installation failure and removes its abort listeners', async () => {
jest.mocked(spawn).mockReset();
const controller = new AbortController();
const failure = new Error('brew failed');
jest.mocked(spawn).mockRejectedValue(failure);
await expect(
ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger, signal: controller.signal })
).rejects.toBe(failure);
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
});

it('removes its abort listener after a successful probe', async () => {
jest.mocked(spawn).mockReset();
jest.mocked(spawn).mockResolvedValue({ stdout: '', stderr: '' } as never);
const controller = new AbortController();
await ensureMacosPreviewEncoderInstalledAsync({ env: {}, logger, signal: controller.signal });
expect(getEventListeners(controller.signal, 'abort')).toEqual([]);
});
});
Loading
Loading