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

### 🎉 New features

- [eas-cli] Add `eas simulator:artifacts` command and store agent-device screenshots/screen recordings as device run session artifacts. ([#3853](https://github.com/expo/eas-cli/pull/3853) by [@szdziedzic](https://github.com/szdziedzic))

### 🐛 Bug fixes

- [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { GenericArtifactType, SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import {
BuildFunction,
Expand All @@ -14,11 +14,18 @@ import path from 'node:path';

import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { sleepAsync } from '../../utils/retry';
import {
type AgentDeviceMediaCollector,
startAgentDeviceMediaCollector,
stopActiveAgentDeviceRecordingsAsync,
} from '../utils/agentDeviceArtifacts';
import {
type DetachedProcessHandle,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
isDeviceRunSessionFinalAsync,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
Expand All @@ -32,6 +39,10 @@ const SRC_DIR = '/tmp/agent-device-src';
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
const ARTIFACTS_DIR = path.join(os.tmpdir(), 'eas-drs-artifacts');

const SESSION_STATUS_POLL_INTERVAL_MS = 5_000;
const SELF_DEADLINE_SAFETY_MARGIN_MS = 5 * 60 * 1000;

export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx: CustomBuildContext
Expand All @@ -49,6 +60,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
const stepStartTimestampMs = Date.now();

// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
Expand All @@ -68,6 +81,31 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
}

// The user can stop the session while setup is still running - www
// marks it final immediately. Bail out before the daemon setup instead
// of finishing it for a dead session.
if (await isDeviceRunSessionFinalAsync({ ctx, deviceRunSessionId, logger })) {
logger.info('The device run session was stopped during setup. Exiting.');
return;
}

// Start collecting agent-device media files (screenshots and screen
// recordings) before the daemon launches, so nothing is missed. The
// collected files are uploaded as session artifacts when the session
// ends.
let mediaCollector: AgentDeviceMediaCollector | null = null;
try {
await fs.promises.mkdir(ARTIFACTS_DIR, { recursive: true });
mediaCollector = startAgentDeviceMediaCollector({
artifactsDir: ARTIFACTS_DIR,
logger,
});
logger.info(`Collecting agent-device media files into ${ARTIFACTS_DIR}.`);
} catch (err) {
// Artifact collection is best-effort - never fail the session for it.
logger.warn({ err }, 'Failed to start the agent-device media collector.');
}

logger.info('Launching agent-device daemon.');
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });

Expand Down Expand Up @@ -111,10 +149,44 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
logger,
});

const selfDeadlineTimestampMs = computeSelfDeadlineTimestampMs({ stepStartTimestampMs, env });
if (selfDeadlineTimestampMs !== null) {
logger.info(
`The session will end automatically at ${new Date(
selfDeadlineTimestampMs
).toISOString()}, before the job's maximum runtime elapses.`
);
}

// Keep the turtle job alive (the daemon and tunnel stay reachable) by
// polling the session status in www until the user stops the session
// (or it errors), or until the self-deadline passes.
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
await waitForSessionToEndAsync({ ctx, deviceRunSessionId, selfDeadlineTimestampMs, logger });

// The session is over. Persist the collected media as session
// artifacts, then return normally so the job finishes as a regular
// success - artifact failures are logged warnings, never job errors.
try {
logger.info('Collecting agent-device session artifacts.');
// Finalize in-flight recordings through agent-device's own pipeline
// so the uploaded videos are playable; daemon shutdown alone would
// leave them truncated.
await stopActiveAgentDeviceRecordingsAsync({
daemonPort,
daemonToken,
artifactsDir: ARTIFACTS_DIR,
logger,
});
if (mediaCollector) {
await mediaCollector.sweepAsync();
mediaCollector.stop();
}
await uploadCollectedArtifactsAsync({ ctx, logger });
} catch (err) {
logger.warn({ err }, 'Failed to collect agent-device session artifacts.');
}
logger.info('The device run session has ended. Finishing the job.');
},
});
}
Expand Down Expand Up @@ -206,6 +278,109 @@ async function startAgentDeviceDaemonFromGitAsync({
});
}

/**
* Computes the timestamp at which the step should end the session on its own
* to stay ahead of the orchestrator's hard job timeout. The maximum job
* runtime comes from `__MAX_RUN_TIME_SECONDS` (injected into generic job
* envs by the API server; absent on older jobs - then there is no
* self-deadline and we rely on the orchestrator timeout alone).
*
* The job's runtime clock started when the orchestrator claimed the job -
* before the VM booted and this step started - so measuring the full max
* runtime from the step's start would overshoot the real deadline. The
* safety margin absorbs that spinup/setup skew and leaves time for the
* post-session artifact collection and upload. The margin is clamped so the
* deadline is never earlier than half the max runtime past the step's start,
* keeping the session useful even for very short max runtimes.
*/
function computeSelfDeadlineTimestampMs({
stepStartTimestampMs,
env,
}: {
stepStartTimestampMs: number;
env: BuildStepEnv;
}): number | null {
const rawMaxRunTimeSeconds = env.__MAX_RUN_TIME_SECONDS;
if (!rawMaxRunTimeSeconds) {
return null;
}
const maxRunTimeSeconds = Number(rawMaxRunTimeSeconds);
if (!Number.isFinite(maxRunTimeSeconds) || maxRunTimeSeconds <= 0) {
return null;
}
const maxRunTimeMs = maxRunTimeSeconds * 1000;
return (
stepStartTimestampMs + Math.max(maxRunTimeMs - SELF_DEADLINE_SAFETY_MARGIN_MS, maxRunTimeMs / 2)
);
}

async function waitForSessionToEndAsync({
ctx,
deviceRunSessionId,
selfDeadlineTimestampMs,
logger,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
selfDeadlineTimestampMs: number | null;
logger: bunyan;
}): Promise<void> {
for (;;) {
if (selfDeadlineTimestampMs !== null && Date.now() >= selfDeadlineTimestampMs) {
logger.info(
'The job is approaching its maximum runtime - ending the session early to leave time for artifact collection and upload.'
);
return;
}
if (await isDeviceRunSessionFinalAsync({ ctx, deviceRunSessionId, logger })) {
logger.info('The device run session was stopped.');
return;
}
await sleepAsync(SESSION_STATUS_POLL_INTERVAL_MS);
}
}

async function uploadCollectedArtifactsAsync({
ctx,
logger,
}: {
ctx: CustomBuildContext;
logger: bunyan;
}): Promise<void> {
let entries;
try {
entries = await fs.promises.readdir(ARTIFACTS_DIR, { withFileTypes: true });
} catch (err) {
logger.warn({ err }, `Failed to read the artifacts directory ${ARTIFACTS_DIR}.`);
return;
}

const fileNames = entries.filter(entry => entry.isFile()).map(entry => entry.name);
if (fileNames.length === 0) {
logger.info('No agent-device media files were collected during this session.');
return;
}

logger.info(`Uploading ${fileNames.length} agent-device media file(s) as session artifacts.`);
for (const fileName of fileNames) {
try {
// Each file is uploaded individually (not as a tarball) so the website
// can render images and videos inline.
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: GenericArtifactType.OTHER,
name: `agent-device/${fileName}`,
paths: [path.join(ARTIFACTS_DIR, fileName)],
},
logger,
});
logger.info(`Uploaded ${fileName}.`);
} catch (err) {
logger.warn({ err }, `Failed to upload ${fileName} - continuing with the remaining files.`);
}
}
}

async function cloneAgentDeviceAsync({
packageVersion,
env,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import fs from 'node:fs';
import path from 'node:path';

import { vol } from 'memfs';

import { createMockLogger } from '../../../__tests__/utils/logger';
import {
isAgentDeviceMediaFileName,
startAgentDeviceMediaCollector,
} from '../agentDeviceArtifacts';

const WATCH_DIR = '/watched-tmp';
const ARTIFACTS_DIR = '/eas-drs-artifacts';

describe(isAgentDeviceMediaFileName, () => {
it('matches screenshots and recordings with any extension', () => {
expect(isAgentDeviceMediaFileName('agent-device-screenshot-1700000000000-abc123.png')).toBe(
true
);
expect(isAgentDeviceMediaFileName('agent-device-recording-1700000000000-abc123.mp4')).toBe(
true
);
expect(isAgentDeviceMediaFileName('agent-device-recording-1700000000000-abc123.mov')).toBe(
true
);
});

it('ignores dot-prefixed post-processing temp files', () => {
expect(isAgentDeviceMediaFileName('.agent-device-recording-1700000000000-abc123.mp4')).toBe(
false
);
});

it('ignores unrelated files', () => {
expect(isAgentDeviceMediaFileName('agent-device-src')).toBe(false);
expect(isAgentDeviceMediaFileName('some-other-file.png')).toBe(false);
expect(isAgentDeviceMediaFileName('screenshot-123.png')).toBe(false);
});
});

describe(startAgentDeviceMediaCollector, () => {
beforeEach(() => {
vol.mkdirSync(WATCH_DIR, { recursive: true });
vol.mkdirSync(ARTIFACTS_DIR, { recursive: true });
});

function startCollector(): ReturnType<typeof startAgentDeviceMediaCollector> {
return startAgentDeviceMediaCollector({
artifactsDir: ARTIFACTS_DIR,
logger: createMockLogger(),
watchDir: WATCH_DIR,
});
}

it('links matching files into the artifacts directory', async () => {
const collector = startCollector();
try {
const fileName = 'agent-device-screenshot-1700000000000-abc123.png';
await fs.promises.writeFile(path.join(WATCH_DIR, fileName), 'png-bytes');

await collector.sweepAsync();

await expect(fs.promises.readFile(path.join(ARTIFACTS_DIR, fileName), 'utf8')).resolves.toBe(
'png-bytes'
);
} finally {
collector.stop();
}
});

it('retains file content after the original is deleted', async () => {
const collector = startCollector();
try {
const fileName = 'agent-device-recording-1700000000000-abc123.mp4';
const originalPath = path.join(WATCH_DIR, fileName);
await fs.promises.writeFile(originalPath, 'mp4-bytes');

await collector.sweepAsync();
// Simulates the daemon's delete-after-download behavior.
await fs.promises.unlink(originalPath);

await expect(fs.promises.readFile(path.join(ARTIFACTS_DIR, fileName), 'utf8')).resolves.toBe(
'mp4-bytes'
);
} finally {
collector.stop();
}
});

it('collects each file only once', async () => {
const collector = startCollector();
try {
const fileName = 'agent-device-screenshot-1700000000001-def456.png';
await fs.promises.writeFile(path.join(WATCH_DIR, fileName), 'png-bytes');

await collector.sweepAsync();
await collector.sweepAsync();

await expect(fs.promises.readdir(ARTIFACTS_DIR)).resolves.toEqual([fileName]);
} finally {
collector.stop();
}
});

it('ignores dot-prefixed and unrelated files', async () => {
const collector = startCollector();
try {
await fs.promises.writeFile(
path.join(WATCH_DIR, '.agent-device-recording-1700000000000-abc123.mp4'),
'temp-bytes'
);
await fs.promises.writeFile(path.join(WATCH_DIR, 'unrelated.png'), 'png-bytes');
await fs.promises.mkdir(path.join(WATCH_DIR, 'agent-device-src'));

await collector.sweepAsync();

await expect(fs.promises.readdir(ARTIFACTS_DIR)).resolves.toEqual([]);
} finally {
collector.stop();
}
});

it('collects pre-existing files via the initial scan', async () => {
const fileName = 'agent-device-screenshot-1699999999999-zzz999.png';
await fs.promises.writeFile(path.join(WATCH_DIR, fileName), 'png-bytes');

const collector = startCollector();
try {
// The initial scan is fire-and-forget; sweeping deterministically
// covers the same files.
await collector.sweepAsync();
await expect(fs.promises.readdir(ARTIFACTS_DIR)).resolves.toEqual([fileName]);
} finally {
collector.stop();
}
});

it('never throws when the watch directory does not exist', async () => {
const collector = startAgentDeviceMediaCollector({
artifactsDir: ARTIFACTS_DIR,
logger: createMockLogger(),
watchDir: '/does-not-exist',
});
try {
await expect(collector.sweepAsync()).resolves.toBeUndefined();
} finally {
collector.stop();
}
});
});
Loading
Loading