Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages.

- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
- [build-tools] Preserve already-captured Agent Device app logs as simulator session artifacts after shutdown. ([#4429](https://github.com/expo/eas-cli/pull/4429) by [@sjkim-expo](https://github.com/sjkim-expo))

### 🐛 Bug fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Sentry } from '../../../sentry';
import { spawnDetached } from '../../utils/remoteDeviceRunSession';
import {
startAgentDeviceDaemonAsync,
stopAgentDeviceAndUploadAppLogsAsync,
stopAgentDeviceEventCollectionSafelyAsync,
} from '../startAgentDeviceRemoteSession';

Expand Down Expand Up @@ -34,6 +35,39 @@ async function writeDaemonEntry(cwd: string): Promise<string> {
return daemonPath;
}

describe(stopAgentDeviceAndUploadAppLogsAsync, () => {
it('waits for daemon shutdown before collecting final log bytes', async () => {
let finish!: () => void;
const daemonStopped = new Promise<void>(resolve => {
finish = resolve;
});
const upload = jest.fn(async () => {});
const stopped = stopAgentDeviceAndUploadAppLogsAsync({
daemonProcess: { stopAsync: () => daemonStopped },
appLogs: { stopAsync: upload },
});
expect(upload).not.toHaveBeenCalled();
finish();
await stopped;
expect(upload).toHaveBeenCalledTimes(1);
});

it('does not snapshot a daemon that failed to stop', async () => {
const upload = jest.fn();
await expect(
stopAgentDeviceAndUploadAppLogsAsync({
daemonProcess: {
stopAsync: async () => {
throw new Error('stop failed');
},
},
appLogs: { stopAsync: upload },
})
).rejects.toThrow('stop failed');
expect(upload).not.toHaveBeenCalled();
});
});

describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
resolvePackageAdd,
resolvePackageInstall,
} from '../../utils/packageManager';
import { startAgentDeviceAppLogCollectionAsync } from '../utils/agentDeviceAppLogs';
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import {
Expand Down Expand Up @@ -98,6 +99,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
}

logger.info('Launching agent-device daemon.');
const appLogs = await startAgentDeviceAppLogCollectionAsync({
ctx,
deviceRunSessionId,
stateDir: AGENT_DEVICE_STATE_DIR,
logger,
});
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });

logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
Expand Down Expand Up @@ -175,18 +182,21 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
: undefined,
});
} finally {
if (webPreview) {
await webPreview.stopAsync();
}
await agentDeviceTunnel.stopAsync();
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
try {
if (webPreview) {
await webPreview.stopAsync();
}
await agentDeviceTunnel.stopAsync();
if (eventCollection) {
await stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
logger,
});
}
} finally {
await stopAgentDeviceAndUploadAppLogsAsync({ daemonProcess, appLogs });
}
await daemonProcess.stopAsync();
}
}),
});
Expand Down Expand Up @@ -260,6 +270,17 @@ export async function startAgentDeviceDaemonAsync({
}
}

export async function stopAgentDeviceAndUploadAppLogsAsync({
daemonProcess,
appLogs,
}: {
daemonProcess: { stopAsync: () => Promise<void> };
appLogs: { stopAsync: () => Promise<void> };
}): Promise<void> {
await daemonProcess.stopAsync();
await appLogs.stopAsync();
}

export async function stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { type bunyan } from '@expo/logger';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { type Readable } from 'node:stream';

import { type CustomBuildContext } from '../../../customBuildContext';
import { startAgentDeviceAppLogCollectionAsync } from '../agentDeviceAppLogs';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';

jest.mock('../deviceRunSessionArtifacts');
jest.mock('../../../sentry');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');

describe(startAgentDeviceAppLogCollectionAsync, () => {
let stateDir: string;
const logger = { warn: jest.fn() } as unknown as bunyan;
const uploads: { name: string; contents: string }[] = [];

beforeEach(async () => {
jest.clearAllMocks();
uploads.length = 0;
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-app-logs-test-'));
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, args) => {
const chunks: string[] = [];
for await (const chunk of args.stream as Readable) {
chunks.push(Buffer.from(chunk).toString());
}
uploads.push({ name: args.name, contents: chunks.join('') });
});
});

afterEach(async () => {
jest.useRealTimers();
await fs.rm(stateDir, { recursive: true, force: true });
});

const start = () =>
startAgentDeviceAppLogCollectionAsync({
ctx: {} as CustomBuildContext,
deviceRunSessionId: 'run-id',
stateDir,
logger,
});

async function writeLog(session: string, contents: string): Promise<string> {
const directory = path.join(stateDir, 'sessions', session);
await fs.mkdir(directory, { recursive: true });
const filename = path.join(directory, 'app.log');
await fs.writeFile(filename, contents);
return filename;
}

it('does not upload when no logs were captured', async () => {
const collection = await start();
await collection.stopAsync();
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});

it('reads final contents once and attributes multiple logs to the run', async () => {
const collection = await start();
const filename = await writeLog('first', 'initial\n');
await writeLog('second', 'other app');
await fs.appendFile(filename, 'final\n');
await Promise.all([collection.stopAsync(), collection.stopAsync()]);
expect(uploads).toEqual([
{ name: 'App log (first)', contents: 'initial\nfinal\n' },
{ name: 'App log (second)', contents: 'other app' },
]);
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
deviceRunSessionId: 'run-id',
kind: 'native-app-log',
size: 14,
})
);
});

it('excludes preexisting session directories, empty logs and missing logs', async () => {
await writeLog('old', 'previous run');
const collection = await start();
await writeLog('empty', '');
await fs.mkdir(path.join(stateDir, 'sessions', 'missing'));
await collection.stopAsync();
expect(uploads).toEqual([]);
});

it('does not follow session or file symlinks', async () => {
const collection = await start();
const outside = path.join(stateDir, 'outside');
await fs.mkdir(outside);
await fs.writeFile(path.join(outside, 'app.log'), 'private');
await fs.mkdir(path.join(stateDir, 'sessions', 'file-link'), { recursive: true });
await fs.symlink(outside, path.join(stateDir, 'sessions', 'directory-link'));
await fs.symlink(
path.join(outside, 'app.log'),
path.join(stateDir, 'sessions', 'file-link', 'app.log')
);
await collection.stopAsync();
expect(uploads).toEqual([]);
expect(logger.warn).toHaveBeenCalled();
});

it('skips oversized logs without failing shutdown', async () => {
const collection = await start();
const file = await writeLog('large', '');
await fs.truncate(file, 10 * 1024 * 1024 + 1);
await expect(collection.stopAsync()).resolves.toBeUndefined();
expect(uploads).toEqual([]);
expect(logger.warn).toHaveBeenCalled();
});

it('continues after one upload fails without allocating a retry', async () => {
const collection = await start();
await writeLog('first', 'first');
await writeLog('second', 'second');
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockRejectedValueOnce(new Error('offline'));
await expect(collection.stopAsync()).resolves.toBeUndefined();
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(2);
expect(uploads).toEqual([{ name: 'App log (second)', contents: 'second' }]);
expect(logger.warn).toHaveBeenCalled();
});

it('aborts network work and returns when the shutdown budget expires', async () => {
const collection = await start();
await writeLog('first', 'first');
let markStarted!: () => void;
const started = new Promise<void>(resolve => {
markStarted = resolve;
});
let signal: AbortSignal | undefined;
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async (_ctx, args) => {
signal = args.signal;
markStarted();
await new Promise<void>((_resolve, reject) => {
args.signal!.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
});
});
jest.useFakeTimers();
const stopped = collection.stopAsync();
await started;
await jest.advanceTimersByTimeAsync(30_000);
await stopped;
expect(signal?.aborted).toBe(true);
expect(logger.warn).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
jest.mocked(fetch).mockReset();
});

it('streams an artifact through a signed upload URL', async () => {
it.each([false, true])('streams an artifact with cancellation enabled: %s', async cancelable => {
const controller = new AbortController();
const signal = cancelable ? controller.signal : undefined;
const stream = Readable.from(Buffer.from('artifact-data'));
const reportedSize = 1024;
const mutation = jest.fn().mockReturnValue({
Expand Down Expand Up @@ -50,6 +52,7 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
stream,
signal,
});

expect(mutation).toHaveBeenCalledWith(
Expand All @@ -63,14 +66,64 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
},
})
}),
cancelable ? { fetch: expect.any(Function) } : undefined
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/artifact',
expect.objectContaining({
method: 'PUT',
body: stream,
...(signal ? { signal } : {}),
})
);
if (cancelable) {
const requestFetch = mutation.mock.calls[0][2].fetch as typeof globalThis.fetch;
const nativeFetch = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new globalThis.Response());
try {
const requestController = new AbortController();
await requestFetch('https://api.expo.test/graphql', {
headers: { Authorization: 'Bearer test-token' },
signal: requestController.signal,
});
expect(nativeFetch).toHaveBeenCalledWith(
'https://api.expo.test/graphql',
expect.objectContaining({
headers: { Authorization: 'Bearer test-token' },
})
);
const forwardedSignal = nativeFetch.mock.calls[0][1]!.signal!;
expect(forwardedSignal.aborted).toBe(false);
controller.abort();
expect(forwardedSignal.aborted).toBe(true);
} finally {
nativeFetch.mockRestore();
}
}
});

it('does not allocate an upload after cancellation', async () => {
const controller = new AbortController();
controller.abort();
const mutation = jest.fn();
await expect(
uploadDeviceRunSessionArtifactAsync(
{ graphqlClient: { mutation } } as unknown as CustomBuildContext,
{
deviceRunSessionId: 'run',
artifactId: 'log',
name: 'log',
filename: 'app.log',
kind: 'native-app-log',
size: 1,
stream: Readable.from(['x']),
signal: controller.signal,
}
)
).rejects.toThrow();
expect(mutation).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
});
Loading
Loading