diff --git a/CHANGELOG.md b/CHANGELOG.md index a695366585..ffd3fc5c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts index 2a7ce0fca1..17c3bbb239 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts @@ -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, @@ -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, @@ -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 @@ -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 @@ -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 }); @@ -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(() => {}); + 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.'); }, }); } @@ -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 { + 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 { + 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, diff --git a/packages/build-tools/src/steps/utils/__tests__/agentDeviceArtifacts.test.ts b/packages/build-tools/src/steps/utils/__tests__/agentDeviceArtifacts.test.ts new file mode 100644 index 0000000000..6ac6de564e --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/agentDeviceArtifacts.test.ts @@ -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 { + 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(); + } + }); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index 4f90de3b03..a91345cfec 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -1,16 +1,22 @@ import { bunyan } from '@expo/logger'; import { BuildStepEnv } from '@expo/steps'; +import { Client } from '@urql/core'; +import { createMockLogger } from '../../../__tests__/utils/logger'; import { CustomBuildContext } from '../../../customBuildContext'; import { Sentry } from '../../../sentry'; import { turtleFetch } from '../../../utils/turtleFetch'; import { fetchServeSimTurnArgsAsync, + isDeviceRunSessionFinalAsync, turnIceServersToServeSimArgs, } from '../remoteDeviceRunSession'; jest.mock('../../../utils/turtleFetch'); jest.mock('../../../sentry'); +// The module under test imports the ngrok SDK (a native addon) at the top +// level - mock it so the unit tests don't load the native binary. +jest.mock('@ngrok/ngrok', () => ({ forward: jest.fn() })); function createLoggerMock(): bunyan { return { @@ -152,3 +158,92 @@ describe(fetchServeSimTurnArgsAsync, () => { expect(jest.mocked(Sentry).capture).toHaveBeenCalled(); }); }); + +const DEVICE_RUN_SESSION_ID = 'device-run-session-id'; + +describe(isDeviceRunSessionFinalAsync, () => { + let mockQueryFn: jest.Mock; + let ctx: CustomBuildContext; + + beforeEach(() => { + mockQueryFn = jest.fn(); + ctx = { + graphqlClient: { + query: jest.fn().mockReturnValue({ toPromise: mockQueryFn }), + } as unknown as Client, + } as CustomBuildContext; + }); + + function mockStatusResponse(status: string): void { + mockQueryFn.mockResolvedValue({ + data: { deviceRunSessions: { byId: { id: DEVICE_RUN_SESSION_ID, status } } }, + }); + } + + it.each(['STOPPED', 'ERRORED'])('returns true for final status %s', async status => { + mockStatusResponse(status); + + await expect( + isDeviceRunSessionFinalAsync({ + ctx, + deviceRunSessionId: DEVICE_RUN_SESSION_ID, + logger: createMockLogger(), + }) + ).resolves.toBe(true); + expect(ctx.graphqlClient.query).toHaveBeenCalledWith(expect.anything(), { + deviceRunSessionId: DEVICE_RUN_SESSION_ID, + }); + }); + + it.each(['NEW', 'IN_PROGRESS'])('returns false for non-final status %s', async status => { + mockStatusResponse(status); + + await expect( + isDeviceRunSessionFinalAsync({ + ctx, + deviceRunSessionId: DEVICE_RUN_SESSION_ID, + logger: createMockLogger(), + }) + ).resolves.toBe(false); + }); + + it('returns false and logs a warning when the query reports an error', async () => { + mockQueryFn.mockResolvedValue({ error: { message: 'transient API error' } }); + const logger = createMockLogger(); + + await expect( + isDeviceRunSessionFinalAsync({ + ctx, + deviceRunSessionId: DEVICE_RUN_SESSION_ID, + logger, + }) + ).resolves.toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('transient API error')); + }); + + it('returns false and logs a warning when the query throws', async () => { + mockQueryFn.mockRejectedValue(new Error('network down')); + const logger = createMockLogger(); + + await expect( + isDeviceRunSessionFinalAsync({ + ctx, + deviceRunSessionId: DEVICE_RUN_SESSION_ID, + logger, + }) + ).resolves.toBe(false); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('returns false when the response is missing the session', async () => { + mockQueryFn.mockResolvedValue({ data: { deviceRunSessions: { byId: null } } }); + + await expect( + isDeviceRunSessionFinalAsync({ + ctx, + deviceRunSessionId: DEVICE_RUN_SESSION_ID, + logger: createMockLogger(), + }) + ).resolves.toBe(false); + }); +}); diff --git a/packages/build-tools/src/steps/utils/agentDeviceArtifacts.ts b/packages/build-tools/src/steps/utils/agentDeviceArtifacts.ts new file mode 100644 index 0000000000..6cdb60e600 --- /dev/null +++ b/packages/build-tools/src/steps/utils/agentDeviceArtifacts.ts @@ -0,0 +1,304 @@ +import { bunyan } from '@expo/logger'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { turtleFetch } from '../../utils/turtleFetch'; + +/** + * agent-device's remote client rewrites media output paths to predictable + * temp paths before sending the RPC to the daemon: + * - screenshots -> /tmp/agent-device-screenshot--.png + * - recordings -> /tmp/agent-device-recording--. + * The daemon deletes those files shortly after the remote client downloads + * them, so we hardlink them into our own artifacts directory as they appear + * (a hardlink shares the inode, so it survives the daemon's delete and its + * content converges to the final bytes even when linked mid-write). + * + * These path patterns are agent-device internals, not an API. If they change + * upstream, collection degrades to "no artifacts" - it must never break the + * session, so everything in this module is best-effort and never throws. + */ +const AGENT_DEVICE_MEDIA_FILE_PATTERN = /^agent-device-(screenshot|recording)-/; + +const COLLECTOR_POLL_INTERVAL_MS = 500; +const DEFAULT_WATCH_DIR = '/tmp'; + +const AGENT_DEVICE_SESSIONS_DIR = path.join(os.homedir(), '.agent-device', 'sessions'); +const DEFAULT_AGENT_DEVICE_SESSION_NAME = 'default'; + +const STOP_RECORDINGS_TOTAL_BUDGET_MS = 60_000; +const STOP_RECORDINGS_PER_REQUEST_TIMEOUT_MS = 30_000; + +export function isAgentDeviceMediaFileName(fileName: string): boolean { + // Dot-prefixed files are agent-device post-processing temp files - the + // final file is renamed over the original path, which our hardlink of the + // original already points at (possibly the raw variant; the abort handler + // additionally copies the final processed file from the record-stop RPC + // response). + return !fileName.startsWith('.') && AGENT_DEVICE_MEDIA_FILE_PATTERN.test(fileName); +} + +export interface AgentDeviceMediaCollector { + /** Runs a full scan of the watch directory and waits for it to finish. */ + sweepAsync: () => Promise; + stop: () => void; +} + +/** + * Watches `watchDir` (fs.watch + initial scan + polling sweep fallback) for + * agent-device media files and hardlinks each match into `artifactsDir`. + * Entirely best-effort: never throws, logs at debug/warn level only. + */ +export function startAgentDeviceMediaCollector({ + artifactsDir, + logger, + watchDir = DEFAULT_WATCH_DIR, +}: { + artifactsDir: string; + logger: bunyan; + watchDir?: string; +}): AgentDeviceMediaCollector { + let stopped = false; + + const collectFileAsync = async (fileName: string): Promise => { + const sourcePath = path.join(watchDir, fileName); + const targetPath = path.join(artifactsDir, fileName); + try { + await fs.promises.link(sourcePath, targetPath); + logger.debug(`Collected agent-device media file ${fileName}.`); + } catch (err: any) { + if (err?.code === 'EEXIST') { + // Already collected. + return; + } + if (err?.code === 'ENOENT') { + // The file disappeared between detection and linking. + return; + } + // Hardlink failed (e.g. EXDEV when artifactsDir is on another device) - + // fall back to a copy. + try { + await fs.promises.copyFile(sourcePath, targetPath); + logger.debug(`Copied agent-device media file ${fileName} (hardlink failed).`); + } catch (copyErr: any) { + if (copyErr?.code !== 'ENOENT') { + logger.debug({ err: copyErr }, `Failed to collect agent-device media file ${fileName}.`); + } + } + } + }; + + const sweepAsync = async (): Promise => { + try { + const fileNames = await fs.promises.readdir(watchDir); + await Promise.all( + fileNames.filter(fileName => isAgentDeviceMediaFileName(fileName)).map(collectFileAsync) + ); + } catch (err) { + logger.debug({ err }, `Failed to sweep ${watchDir} for agent-device media files.`); + } + }; + + let watcher: fs.FSWatcher | undefined; + try { + watcher = fs.watch(watchDir, (_eventType, fileName) => { + if (stopped || typeof fileName !== 'string' || !isAgentDeviceMediaFileName(fileName)) { + return; + } + void collectFileAsync(fileName); + }); + watcher.on('error', err => { + logger.debug({ err }, `agent-device media watcher on ${watchDir} errored.`); + }); + } catch (err) { + // Polling sweeps still cover us. + logger.warn( + { err }, + `Failed to watch ${watchDir} for agent-device media files - relying on polling sweeps only.` + ); + } + + // Initial scan for files created before the collector started. + void sweepAsync(); + + // Polling sweep fallback for events missed by fs.watch. + const pollInterval = setInterval(() => { + void sweepAsync(); + }, COLLECTOR_POLL_INTERVAL_MS); + pollInterval.unref(); + + const stop = (): void => { + if (stopped) { + return; + } + stopped = true; + clearInterval(pollInterval); + try { + watcher?.close(); + } catch { + // Best-effort. + } + }; + + return { sweepAsync, stop }; +} + +/** + * Best-effort finalization of in-flight recordings: enumerates daemon + * sessions from the on-disk state directory (plus the default session) and + * issues a `record stop` to each one via the daemon's local JSON-RPC + * endpoint. Files referenced by successful responses (the final processed + * videos) are copied into `artifactsDir`, overwriting any same-named + * hardlinked raw variant. Never throws. + */ +export async function stopActiveAgentDeviceRecordingsAsync({ + daemonPort, + daemonToken, + artifactsDir, + logger, + sessionsDir = AGENT_DEVICE_SESSIONS_DIR, + budgetMs = STOP_RECORDINGS_TOTAL_BUDGET_MS, +}: { + daemonPort: number; + daemonToken: string; + artifactsDir: string; + logger: bunyan; + sessionsDir?: string; + budgetMs?: number; +}): Promise { + const deadline = Date.now() + budgetMs; + const sessionNames = await enumerateAgentDeviceSessionNamesAsync({ sessionsDir, logger }); + + for (const sessionName of sessionNames) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + logger.warn(`Ran out of time finalizing in-flight recordings - skipping remaining sessions.`); + return; + } + await stopRecordingForSessionAsync({ + daemonPort, + daemonToken, + sessionName, + artifactsDir, + logger, + timeoutMs: Math.min(STOP_RECORDINGS_PER_REQUEST_TIMEOUT_MS, remainingMs), + }); + } +} + +async function enumerateAgentDeviceSessionNamesAsync({ + sessionsDir, + logger, +}: { + sessionsDir: string; + logger: bunyan; +}): Promise { + const sessionNames = new Set(); + try { + const entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true }); + for (const entry of entries) { + // Session state lives in subdirectories; sibling files (e.g. + // .trace.log) are not sessions. + if (entry.isDirectory()) { + sessionNames.add(entry.name); + } + } + } catch (err) { + logger.debug({ err }, `Failed to enumerate agent-device sessions in ${sessionsDir}.`); + } + // Always try the default session, even when no session directories exist. + sessionNames.add(DEFAULT_AGENT_DEVICE_SESSION_NAME); + return [...sessionNames]; +} + +async function stopRecordingForSessionAsync({ + daemonPort, + daemonToken, + sessionName, + artifactsDir, + logger, + timeoutMs, +}: { + daemonPort: number; + daemonToken: string; + sessionName: string; + artifactsDir: string; + logger: bunyan; + timeoutMs: number; +}): Promise { + try { + const response = await turtleFetch(`http://127.0.0.1:${daemonPort}/rpc`, 'POST', { + json: { + jsonrpc: '2.0', + id: `eas-abort-cleanup-${Date.now()}`, + method: 'agent_device.command', + params: { + session: sessionName, + command: 'record', + positionals: ['stop'], + }, + }, + headers: { + Authorization: `Bearer ${daemonToken}`, + }, + timeout: timeoutMs, + retries: 0, + shouldThrowOnNotOk: false, + }); + const body = (await response.json()) as { + result?: { ok?: boolean; data?: { outPath?: unknown; artifacts?: unknown } }; + error?: { message?: string }; + }; + if (body?.error || !body?.result?.ok) { + // "No active recording" errors are expected for sessions without an + // in-flight recording. + logger.debug( + `record stop for agent-device session "${sessionName}" did not finalize a recording${ + body?.error?.message ? `: ${body.error.message}` : '.' + }` + ); + return; + } + const artifactPaths = extractArtifactPathsFromRecordStopData(body.result.data); + for (const artifactPath of artifactPaths) { + try { + await fs.promises.copyFile( + artifactPath, + path.join(artifactsDir, path.basename(artifactPath)) + ); + logger.info( + `Collected finalized recording ${path.basename(artifactPath)} from agent-device session "${sessionName}".` + ); + } catch (err) { + logger.debug({ err }, `Failed to copy finalized recording ${artifactPath}.`); + } + } + } catch (err) { + logger.debug({ err }, `record stop for agent-device session "${sessionName}" failed.`); + } +} + +function extractArtifactPathsFromRecordStopData(data: unknown): string[] { + const paths = new Set(); + if (!data || typeof data !== 'object') { + return []; + } + const { outPath, artifacts } = data as { outPath?: unknown; artifacts?: unknown }; + if (typeof outPath === 'string' && outPath) { + paths.add(outPath); + } + if (Array.isArray(artifacts)) { + for (const artifact of artifacts) { + if ( + artifact && + typeof artifact === 'object' && + typeof (artifact as { path?: unknown }).path === 'string' && + (artifact as { path: string }).path + ) { + paths.add((artifact as { path: string }).path); + } + } + } + return [...paths]; +} diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index 8708060329..95a385788a 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -28,6 +28,21 @@ const START_DEVICE_RUN_SESSION_MUTATION = graphql(` } `); +const DEVICE_RUN_SESSION_STATUS_QUERY = graphql(` + query DeviceRunSessionStatus($deviceRunSessionId: ID!) { + deviceRunSessions { + byId(deviceRunSessionId: $deviceRunSessionId) { + id + status + } + } + } +`); + +// Statuses (of NEW / IN_PROGRESS / STOPPED / ERRORED) after which the session +// will never become active again. +const FINAL_DEVICE_RUN_SESSION_STATUSES: readonly string[] = ['STOPPED', 'ERRORED']; + export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string { const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID; if (!deviceRunSessionId) { @@ -187,6 +202,42 @@ export async function uploadRemoteSessionConfigAsync({ } } +/** + * Checks whether the device run session has reached a final status (STOPPED + * or ERRORED) in www. Failures to check (network problems, transient API + * errors) are logged as warnings and reported as "not final" so callers can + * simply keep polling. + */ +export async function isDeviceRunSessionFinalAsync({ + ctx, + deviceRunSessionId, + logger, +}: { + ctx: CustomBuildContext; + deviceRunSessionId: string; + logger: bunyan; +}): Promise { + try { + const result = await ctx.graphqlClient + .query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId }) + .toPromise(); + if (result.error) { + logger.warn( + `Failed to check the status of device run session ${deviceRunSessionId}: ${result.error.message} - will retry.` + ); + return false; + } + const status: unknown = result.data?.deviceRunSessions?.byId?.status; + return typeof status === 'string' && FINAL_DEVICE_RUN_SESSION_STATUSES.includes(status); + } catch (err) { + logger.warn( + { err }, + `Failed to check the status of device run session ${deviceRunSessionId} - will retry.` + ); + return false; + } +} + export type DetachedProcessHandle = { getOutput: () => string; }; diff --git a/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts index b92e51cd2d..bc0c276a85 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/get.test.ts @@ -64,6 +64,7 @@ function makeDeviceRunSession(overrides: Partial = {}): De turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress, + artifacts: [], }, ...overrides, }; diff --git a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts index 309b257961..3b215a9819 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/start.test.ts @@ -14,6 +14,7 @@ import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSe import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery'; import Log from '../../../log'; import { ora } from '../../../ora'; +import { maybeWaitForSessionArtifactsAndPrintSummaryAsync } from '../../../simulator/artifacts'; import { EAS_SIMULATOR_SESSION_ID, SIMULATOR_DOTENV_FILE_HEADER, @@ -26,6 +27,7 @@ import SimulatorStart from '../start'; jest.mock('fs-extra'); jest.mock('../../../graphql/mutations/DeviceRunSessionMutation'); jest.mock('../../../graphql/queries/DeviceRunSessionQuery'); +jest.mock('../../../simulator/artifacts'); jest.mock('../../../log', () => ({ __esModule: true, default: { @@ -69,6 +71,9 @@ const mockCreateDeviceRunSessionAsync = jest.mocked( const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync); const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync); const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync); +const mockMaybeWaitForSessionArtifactsAndPrintSummaryAsync = jest.mocked( + maybeWaitForSessionArtifactsAndPrintSummaryAsync +); const mockOra = jest.mocked(ora); function makeCreatedDeviceRunSession( @@ -114,6 +119,7 @@ function makeDeviceRunSession(overrides: Partial = {}): De turtleJobRun: { id: 'job-123', status: JobRunStatus.InProgress, + artifacts: [], }, ...overrides, }; @@ -314,4 +320,19 @@ describe(SimulatorStart, () => { expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir); }); + + it('waits for the session artifacts after the session ends', async () => { + mockByIdAsync + .mockResolvedValueOnce(makeDeviceRunSession()) + .mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped })); + + const { command } = createCommand(['--platform', 'ios']); + await command.runAsync(); + + expect(mockMaybeWaitForSessionArtifactsAndPrintSummaryAsync).toHaveBeenCalledWith({ + graphqlClient, + deviceRunSessionId: 'session-123', + nonInteractive: false, + }); + }); }); diff --git a/packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts index 45cc2f2eb8..16d2665607 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/stop.test.ts @@ -6,6 +6,7 @@ import { EnsureDeviceRunSessionStoppedMutation, } from '../../../graphql/generated'; import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSessionMutation'; +import { maybeWaitForSessionArtifactsAndPrintSummaryAsync } from '../../../simulator/artifacts'; import { EAS_SIMULATOR_SESSION_ID, SIMULATOR_DOTENV_FILE_NAME, @@ -15,6 +16,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; import SimulatorStop from '../stop'; jest.mock('../../../graphql/mutations/DeviceRunSessionMutation'); +jest.mock('../../../simulator/artifacts'); jest.mock('../../../simulator/env', () => ({ ...jest.requireActual('../../../simulator/env'), loadSimulatorEnvAsync: jest.fn(), @@ -40,6 +42,9 @@ const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked( ); const mockEnableJsonOutput = jest.mocked(enableJsonOutput); const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync); +const mockMaybeWaitForSessionArtifactsAndPrintSummaryAsync = jest.mocked( + maybeWaitForSessionArtifactsAndPrintSummaryAsync +); const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); function makeStoppedDeviceRunSession( @@ -102,6 +107,22 @@ describe(SimulatorStop, () => { id: 'session-123', status: DeviceRunSessionStatus.Stopped, }); + expect(mockMaybeWaitForSessionArtifactsAndPrintSummaryAsync).not.toHaveBeenCalled(); + }); + + it('waits for the session artifacts after a non-JSON stop', async () => { + const { command } = createCommand(['--id', 'session-123']); + await command.runAsync(); + + expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith( + graphqlClient, + 'session-123' + ); + expect(mockMaybeWaitForSessionArtifactsAndPrintSummaryAsync).toHaveBeenCalledWith({ + graphqlClient, + deviceRunSessionId: 'session-123', + nonInteractive: false, + }); }); it(`uses ${EAS_SIMULATOR_SESSION_ID} from simulator env when --id is not passed`, async () => { diff --git a/packages/eas-cli/src/commands/simulator/artifacts.ts b/packages/eas-cli/src/commands/simulator/artifacts.ts new file mode 100644 index 0000000000..3606d32a01 --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/artifacts.ts @@ -0,0 +1,168 @@ +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; +import fs from 'fs-extra'; +import path from 'path'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../commandUtils/flags'; +import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery'; +import Log from '../../log'; +import { ora } from '../../ora'; +import { DeviceRunSessionArtifact, printArtifactsSummary } from '../../simulator/artifacts'; +import { + EAS_SIMULATOR_SESSION_ID, + SIMULATOR_DOTENV_FILE_NAME, + loadSimulatorEnvAsync, +} from '../../simulator/env'; +import { downloadFileWithProgressTrackerAsync } from '../../utils/download'; +import { formatBytes } from '../../utils/files'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; + +export default class SimulatorArtifacts extends EasCommand { + static override hidden = true; + static override description = + '[EXPERIMENTAL] list and download artifacts (screenshots and screen recordings) of a remote simulator session on EAS'; + + static override flags = { + id: Flags.string({ + description: `Device run session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`, + }), + 'output-dir': Flags.string({ + description: + 'Directory to download the artifacts to. Defaults to ./eas-simulator-artifacts/.', + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.LoggedIn, + ...this.ContextOptions.ProjectDir, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(SimulatorArtifacts); + const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + + if (jsonFlag) { + enableJsonOutput(); + } + + const { + projectDir, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(SimulatorArtifacts, { + nonInteractive, + }); + + await loadSimulatorEnvAsync(projectDir); + const sessionId = flags.id || process.env[EAS_SIMULATOR_SESSION_ID]; + if (!sessionId) { + throw new Error( + `No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.` + ); + } + + const fetchSpinner = ora(`Fetching artifacts of device run session ${sessionId}`).start(); + let session; + try { + session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId); + } catch (err) { + fetchSpinner.fail(`Failed to fetch device run session ${sessionId}`); + throw err; + } + + const artifacts = session.turtleJobRun?.artifacts ?? []; + if (artifacts.length === 0) { + fetchSpinner.succeed(`Device run session ${sessionId} has no artifacts`); + if (jsonFlag) { + printJsonOnlyOutput({ id: session.id, artifacts: [] }); + return; + } + Log.log( + 'No artifacts were saved for this session. ' + + 'Screenshots and screen recordings taken with agent-device are saved when the session is stopped — ' + + 'if the session is still running, stop it first and try again in a moment.' + ); + return; + } + + fetchSpinner.succeed( + `Found ${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'} for device run session ${sessionId}` + ); + if (!jsonFlag) { + printArtifactsSummary(artifacts); + } + + const outputDir = path.resolve( + flags['output-dir'] ?? path.join('eas-simulator-artifacts', sessionId) + ); + await fs.ensureDir(outputDir); + + const downloaded: { id: string; name: string; path: string }[] = []; + const failed: { id: string; name: string }[] = []; + const usedFileNames = new Set(); + for (const artifact of artifacts) { + if (!artifact.downloadUrl) { + Log.warn(`Artifact ${artifact.name} has no download URL — skipping.`); + failed.push({ id: artifact.id, name: artifact.name }); + continue; + } + const outputPath = path.join(outputDir, resolveUniqueFileName(usedFileNames, artifact)); + try { + await downloadFileWithProgressTrackerAsync( + artifact.downloadUrl, + outputPath, + (ratio, total) => + `Downloading ${artifact.filename} (${formatBytes(total * ratio)} / ${formatBytes( + total + )})`, + `Downloaded ${artifact.filename}` + ); + downloaded.push({ id: artifact.id, name: artifact.name, path: outputPath }); + } catch (err) { + Log.warn( + `Failed to download artifact ${artifact.name}: ${ + err instanceof Error ? err.message : String(err) + }` + ); + failed.push({ id: artifact.id, name: artifact.name }); + } + } + + if (jsonFlag) { + printJsonOnlyOutput({ + id: session.id, + outputDir, + artifacts: downloaded, + ...(failed.length > 0 ? { failed } : {}), + }); + return; + } + + Log.newLine(); + for (const { name, path: filePath } of downloaded) { + Log.log(`${name} saved to ${chalk.bold(filePath)}`); + } + if (failed.length > 0) { + Log.warn( + `Failed to download ${failed.length} artifact${failed.length === 1 ? '' : 's'}. Re-run the command to retry.` + ); + } + } +} + +function resolveUniqueFileName( + usedFileNames: Set, + artifact: DeviceRunSessionArtifact +): string { + const { name: base, ext } = path.parse(artifact.filename); + let candidate = artifact.filename; + for (let i = 1; usedFileNames.has(candidate); i++) { + candidate = `${base}-${i}${ext}`; + } + usedFileNames.add(candidate); + return candidate; +} diff --git a/packages/eas-cli/src/commands/simulator/start.ts b/packages/eas-cli/src/commands/simulator/start.ts index efeb608c1a..6e7d7afcf2 100644 --- a/packages/eas-cli/src/commands/simulator/start.ts +++ b/packages/eas-cli/src/commands/simulator/start.ts @@ -18,6 +18,7 @@ import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessi import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery'; import Log, { link } from '../../log'; import { ora } from '../../ora'; +import { maybeWaitForSessionArtifactsAndPrintSummaryAsync } from '../../simulator/artifacts'; import { EAS_SIMULATOR_SESSION_ID, SIMULATOR_DOTENV_FILE_NAME, @@ -229,12 +230,22 @@ export default class SimulatorStart extends EasCommand { return; } - await waitForSessionEndOrInterruptAsync({ + const sessionEnded = await waitForSessionEndOrInterruptAsync({ graphqlClient, deviceRunSessionId, jobRunUrl, projectDir, }); + if (sessionEnded) { + // Run after waitForSessionEndOrInterruptAsync removed its SIGINT + // handler, so another Ctrl+C skips the artifact wait instead of + // force-exiting the process. + await maybeWaitForSessionArtifactsAndPrintSummaryAsync({ + graphqlClient, + deviceRunSessionId, + nonInteractive, + }); + } } } @@ -255,6 +266,10 @@ async function writeSimulatorEnvSafelyAsync( } } +/** + * Returns true when the session ended (stopped by us or finished on its own), + * false when we could not confirm it was stopped. + */ async function waitForSessionEndOrInterruptAsync({ graphqlClient, deviceRunSessionId, @@ -265,7 +280,7 @@ async function waitForSessionEndOrInterruptAsync({ deviceRunSessionId: string; jobRunUrl: string; projectDir: string; -}): Promise { +}): Promise { const spinner = ora( `Device run session active — press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell` ).start(); @@ -322,7 +337,7 @@ async function waitForSessionEndOrInterruptAsync({ ) { spinner.succeed(`Device run session ended. ${link(jobRunUrl)}`); await resetSimulatorEnvVerboseAsync(projectDir); - return; + return true; } await Promise.race([sleepAsync(POLL_INTERVAL_MS), abortPromise]); @@ -336,10 +351,12 @@ async function waitForSessionEndOrInterruptAsync({ if (stopped) { spinner.succeed('Device run session stopped'); await resetSimulatorEnvVerboseAsync(projectDir); + return true; } else { spinner.fail( `Could not confirm the device run session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.` ); + return false; } } finally { process.removeListener('SIGINT', sigintHandler); diff --git a/packages/eas-cli/src/commands/simulator/stop.ts b/packages/eas-cli/src/commands/simulator/stop.ts index d009a9676d..d375def304 100644 --- a/packages/eas-cli/src/commands/simulator/stop.ts +++ b/packages/eas-cli/src/commands/simulator/stop.ts @@ -7,6 +7,7 @@ import { } from '../../commandUtils/flags'; import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation'; import { ora } from '../../ora'; +import { maybeWaitForSessionArtifactsAndPrintSummaryAsync } from '../../simulator/artifacts'; import { EAS_SIMULATOR_SESSION_ID, SIMULATOR_DOTENV_FILE_NAME, @@ -69,6 +70,13 @@ export default class SimulatorStop extends EasCommand { if (jsonFlag) { printJsonOnlyOutput({ id: session.id, status: session.status }); + return; } + + await maybeWaitForSessionArtifactsAndPrintSummaryAsync({ + graphqlClient, + deviceRunSessionId: session.id, + nonInteractive, + }); } } diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 09e2619b55..67a5f07d06 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -13349,7 +13349,7 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{ }>; -export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null } | { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, webPreviewUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } }; +export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null } | { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, webPreviewUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus, artifacts: Array<{ __typename?: 'WorkflowArtifact', id: string, name: string, filename: string, downloadUrl?: string | null, contentType?: string | null, fileSizeBytes?: number | null }> } | null } } }; export type DeviceRunSessionsByAppIdQueryVariables = Exact<{ appId: Scalars['String']['input']; diff --git a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts index 8f7637261f..31648f1925 100644 --- a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts +++ b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts @@ -52,6 +52,14 @@ export const DeviceRunSessionQuery = { turtleJobRun { id status + artifacts { + id + name + filename + downloadUrl + contentType + fileSizeBytes + } } } } diff --git a/packages/eas-cli/src/simulator/artifacts.ts b/packages/eas-cli/src/simulator/artifacts.ts new file mode 100644 index 0000000000..6bb5c937ed --- /dev/null +++ b/packages/eas-cli/src/simulator/artifacts.ts @@ -0,0 +1,125 @@ +import chalk from 'chalk'; + +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { DeviceRunSessionByIdQuery, JobRunStatus } from '../graphql/generated'; +import { DeviceRunSessionQuery } from '../graphql/queries/DeviceRunSessionQuery'; +import Log from '../log'; +import { ora } from '../ora'; +import { formatBytes } from '../utils/files'; +import { sleepAsync } from '../utils/promise'; + +const ARTIFACTS_POLL_INTERVAL_MS = 5_000; // 5 seconds +const ARTIFACTS_POLL_TIMEOUT_MS = 3 * 60 * 1_000; // 3 minutes + +const FINAL_JOB_RUN_STATUSES: JobRunStatus[] = [ + JobRunStatus.Errored, + JobRunStatus.Canceled, + JobRunStatus.Finished, +]; + +export type DeviceRunSessionArtifact = NonNullable< + DeviceRunSessionByIdQuery['deviceRunSessions']['byId']['turtleJobRun'] +>['artifacts'][number]; + +export function getSimulatorArtifactsHint(deviceRunSessionId: string): string { + return `Run ${chalk.bold( + `eas simulator:artifacts --id ${deviceRunSessionId}` + )} to list and download the session artifacts (screenshots and screen recordings).`; +} + +export function printArtifactsSummary(artifacts: DeviceRunSessionArtifact[]): void { + for (const artifact of artifacts) { + Log.log( + ` - ${artifact.name}${ + artifact.fileSizeBytes != null ? ` (${formatBytes(artifact.fileSizeBytes)})` : '' + }` + ); + } +} + +/** + * After a device run session is stopped, the session artifacts (screenshots + * and screen recordings) are uploaded by the worker during the abort cleanup, + * which finishes when the underlying turtle job run reaches a final status. + * This waits (politely, skippable with Ctrl+C) for the job run to settle and + * prints a summary of the artifacts. In non-interactive mode it only prints + * the download hint. + */ +export async function maybeWaitForSessionArtifactsAndPrintSummaryAsync({ + graphqlClient, + deviceRunSessionId, + nonInteractive, +}: { + graphqlClient: ExpoGraphqlClient; + deviceRunSessionId: string; + nonInteractive: boolean; +}): Promise { + if (nonInteractive) { + Log.log(getSimulatorArtifactsHint(deviceRunSessionId)); + return; + } + + const spinner = ora('Waiting for session artifacts to be saved — press Ctrl+C to skip').start(); + + const abortController = new AbortController(); + const { signal } = abortController; + const abortPromise = new Promise(resolve => { + signal.addEventListener( + 'abort', + () => { + resolve(); + }, + { once: true } + ); + }); + const sigintHandler = (): void => { + abortController.abort(); + }; + process.on('SIGINT', sigintHandler); + + try { + const deadline = Date.now() + ARTIFACTS_POLL_TIMEOUT_MS; + while (!signal.aborted && Date.now() < deadline) { + let session; + try { + session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId); + } catch (err) { + Log.debug( + `Failed to poll device run session: ${err instanceof Error ? err.message : String(err)}` + ); + await Promise.race([sleepAsync(ARTIFACTS_POLL_INTERVAL_MS), abortPromise]); + continue; + } + + const jobRunStatus = session.turtleJobRun?.status; + if ( + !session.turtleJobRun || + (jobRunStatus && FINAL_JOB_RUN_STATUSES.includes(jobRunStatus)) + ) { + const artifacts = session.turtleJobRun?.artifacts ?? []; + if (artifacts.length === 0) { + spinner.succeed('The session produced no artifacts'); + } else { + spinner.succeed( + `The session produced ${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}:` + ); + printArtifactsSummary(artifacts); + Log.newLine(); + Log.log(getSimulatorArtifactsHint(deviceRunSessionId)); + } + return; + } + + await Promise.race([sleepAsync(ARTIFACTS_POLL_INTERVAL_MS), abortPromise]); + } + + if (signal.aborted) { + spinner.warn('Skipped waiting for session artifacts'); + } else { + spinner.warn('Timed out waiting for session artifacts to be saved'); + } + Log.log(getSimulatorArtifactsHint(deviceRunSessionId)); + } finally { + process.removeListener('SIGINT', sigintHandler); + } +} diff --git a/packages/worker/src/__unit__/upload.test.ts b/packages/worker/src/__unit__/upload.test.ts index 861ea8d2ec..6c5d850630 100644 --- a/packages/worker/src/__unit__/upload.test.ts +++ b/packages/worker/src/__unit__/upload.test.ts @@ -550,6 +550,141 @@ describe('with signed upload url provided via www', () => { }); describe(uploadWorkflowArtifactAsync.name, () => { + it('should upload a device run session artifact when only DEVICE_RUN_SESSION_ID is set', async () => { + vol.fromJSON({ + './recording.mp4': JSON.stringify(randomBytes(20)), + }); + const deviceRunSessionId = randomUUID(); + // @ts-expect-error + const ctx: BuildContext = { + env: { + DEVICE_RUN_SESSION_ID: deviceRunSessionId, + }, + job: { + secrets: { + robotAccessToken: 'fake-token', + }, + } as Job, + logger: mockLogger, + }; + const bucketKey = `test/${randomUUID()}/recording.mp4`; + const uploadUrl = `https://upload.url/${randomUUID()}`; + const testSignedUploadAuthorization = randomUUID(); + const expectedArtifactId = randomUUID(); + turtleFetchMock.mockImplementation(async url => { + if ( + url === + `https://api.expo.test/v2/device-run-sessions/${deviceRunSessionId}/upload-sessions/` + ) { + return { + ok: true, + status: 200, + json: async () => ({ + data: { + id: expectedArtifactId, + bucketKey, + url: uploadUrl, + headers: { + authorization: testSignedUploadAuthorization, + }, + storageType: 'R2', + }, + }), + } as Response; + } else { + return { + ok: false, + status: 404, + } as Response; + } + }); + const { artifactId } = await uploadWorkflowArtifactAsync(ctx, { + artifactPaths: ['./recording.mp4'], + name: 'agent-device/recording.mp4', + logger: ctx.logger, + }); + expect(artifactId).toBe(expectedArtifactId); + expect(GCS.uploadWithSignedUrl).toHaveBeenCalledTimes(1); + expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith( + expect.objectContaining({ + signedUrl: { + url: uploadUrl, + headers: { + authorization: testSignedUploadAuthorization, + }, + }, + }) + ); + expect(turtleFetchMock).toHaveBeenCalledTimes(1); + expect(turtleFetchMock).toHaveBeenNthCalledWith( + 1, + `https://api.expo.test/v2/device-run-sessions/${deviceRunSessionId}/upload-sessions/`, + 'POST', + expect.objectContaining({ + json: { + filename: 'recording.mp4', + name: 'agent-device/recording.mp4', + size: expect.any(Number), + }, + headers: { + Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`, + }, + }) + ); + }); + + it('should prefer the workflows endpoint when both __WORKFLOW_JOB_ID and DEVICE_RUN_SESSION_ID are set', async () => { + vol.fromJSON({ + './recording.mp4': JSON.stringify(randomBytes(20)), + }); + const workflowJobId = randomUUID(); + // @ts-expect-error + const ctx: BuildContext = { + env: { + __WORKFLOW_JOB_ID: workflowJobId, + DEVICE_RUN_SESSION_ID: randomUUID(), + }, + job: { + secrets: { + robotAccessToken: 'fake-token', + }, + } as Job, + logger: mockLogger, + }; + const uploadUrl = `https://upload.url/${randomUUID()}`; + turtleFetchMock.mockImplementation(async url => { + if (url === `https://api.expo.test/v2/workflows/${workflowJobId}/upload-sessions/`) { + return { + ok: true, + status: 200, + json: async () => ({ + data: { + bucketKey: 'test/bucketKey', + url: uploadUrl, + headers: {}, + storageType: 'GCS', + }, + }), + } as Response; + } else { + return { + ok: false, + status: 404, + } as Response; + } + }); + await uploadWorkflowArtifactAsync(ctx, { + artifactPaths: ['./recording.mp4'], + name: 'agent-device/recording.mp4', + logger: ctx.logger, + }); + expect(turtleFetchMock).toHaveBeenCalledWith( + `https://api.expo.test/v2/workflows/${workflowJobId}/upload-sessions/`, + 'POST', + expect.anything() + ); + }); + it('should upload a workflow artifact', async () => { vol.fromJSON({ './video.mp4': JSON.stringify(randomBytes(20)), diff --git a/packages/worker/src/upload.ts b/packages/worker/src/upload.ts index 9ffd7b6106..d49ece7c1f 100644 --- a/packages/worker/src/upload.ts +++ b/packages/worker/src/upload.ts @@ -285,8 +285,9 @@ async function createUploadSessionAsync( }> { const workflowJobId = ctx.env.__WORKFLOW_JOB_ID; const buildId = ctx.env.EAS_BUILD_ID; + const deviceRunSessionId = ctx.env.DEVICE_RUN_SESSION_ID; - if (!workflowJobId && !buildId) { + if (!workflowJobId && !buildId && !deviceRunSessionId) { throw new Error('Failed to create upload session - the env variables are not set.'); } @@ -295,38 +296,37 @@ async function createUploadSessionAsync( throw new Error('Failed to create upload session - the robot access token is not set'); } - let responseResult; + let uploadSessionUrl: string; if (ctx.job.platform) { - responseResult = await asyncResult( - turtleFetch( - new URL(`turtle-builds/${buildId}/upload-sessions/`, config.wwwApiV2BaseUrl).toString(), - 'POST', - // 'name' is ignored by Turtle Build router, but provide it for potential use for telemetry, etc. - { - json: { filename, name, size }, - headers: { - Authorization: `Bearer ${robotAccessToken}`, - }, - shouldThrowOnNotOk: false, - } - ) - ); + uploadSessionUrl = new URL( + `turtle-builds/${buildId}/upload-sessions/`, + config.wwwApiV2BaseUrl + ).toString(); + } else if (workflowJobId) { + uploadSessionUrl = new URL( + `workflows/${workflowJobId}/upload-sessions/`, + config.wwwApiV2BaseUrl + ).toString(); + } else if (deviceRunSessionId) { + uploadSessionUrl = new URL( + `device-run-sessions/${deviceRunSessionId}/upload-sessions/`, + config.wwwApiV2BaseUrl + ).toString(); } else { - responseResult = await asyncResult( - turtleFetch( - new URL(`workflows/${workflowJobId}/upload-sessions/`, config.wwwApiV2BaseUrl).toString(), - 'POST', - { - json: { filename, name, size }, - headers: { - Authorization: `Bearer ${robotAccessToken}`, - }, - shouldThrowOnNotOk: false, - } - ) - ); + throw new Error('Failed to create upload session - the env variables are not set.'); } + const responseResult = await asyncResult( + turtleFetch(uploadSessionUrl, 'POST', { + // 'name' is ignored by Turtle Build router, but provide it for potential use for telemetry, etc. + json: { filename, name, size }, + headers: { + Authorization: `Bearer ${robotAccessToken}`, + }, + shouldThrowOnNotOk: false, + }) + ); + if (!responseResult.ok) { if (!(responseResult.reason instanceof TurtleFetchError)) { throw responseResult.reason;