diff --git a/packages/eas-cli/graphql.schema.json b/packages/eas-cli/graphql.schema.json index 877091fe1f..364c3865aa 100644 --- a/packages/eas-cli/graphql.schema.json +++ b/packages/eas-cli/graphql.schema.json @@ -39972,6 +39972,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "eventLog", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeviceRunSessionEventLog", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "finishedAt", "description": null, @@ -40376,6 +40392,268 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "DeviceRunSessionEventLog", + "description": null, + "fields": [ + { + "name": "availability", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DeviceRunSessionEventLogAvailability", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "capabilities", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expiresAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logFileUrls", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "producers", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeviceRunSessionEventLogProducer", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "schemaVersion", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "DeviceRunSessionEventLogState", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "DeviceRunSessionEventLogAvailability", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "AVAILABLE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "EXPIRED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PENDING", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNSUPPORTED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DeviceRunSessionEventLogProducer", + "description": null, + "fields": [ + { + "name": "capabilities", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "version", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "DeviceRunSessionEventLogState", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "COMPLETE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PARTIAL", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "STREAMING", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "DeviceRunSessionFilterInput", diff --git a/packages/eas-cli/src/commands/simulator/__tests__/events.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/events.test.ts new file mode 100644 index 0000000000..29344fb8dd --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/__tests__/events.test.ts @@ -0,0 +1,682 @@ +import { Config } from '@oclif/core'; +import { CombinedError } from '@urql/core'; + +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + DeviceRunSessionEventLogAvailability, + DeviceRunSessionEventLogState, +} from '../../../graphql/generated'; +import { + DeviceRunSessionEventLogDescriptor, + DeviceRunSessionQuery, +} from '../../../graphql/queries/DeviceRunSessionQuery'; +import Log from '../../../log'; +import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env'; +import { + DeviceRunSessionEvent, + downloadDeviceRunSessionEventLogAsync, +} from '../../../simulator/events'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; +import SimulatorEvents from '../events'; + +jest.mock('../../../graphql/queries/DeviceRunSessionQuery'); +jest.mock('../../../log'); +jest.mock('../../../simulator/env', () => ({ + ...jest.requireActual('../../../simulator/env'), + loadSimulatorEnvAsync: jest.fn(), +})); +jest.mock('../../../simulator/events', () => ({ + ...jest.requireActual('../../../simulator/events'), + downloadDeviceRunSessionEventLogAsync: jest.fn(), +})); +jest.mock('../../../ora', () => ({ + ora: jest.fn(() => { + const spinner = { + fail: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + succeed: jest.fn(), + }; + spinner.start.mockReturnValue(spinner); + return spinner; + }), +})); +jest.mock('../../../utils/json'); + +const mockEventLogByIdAsync = jest.mocked(DeviceRunSessionQuery.eventLogByIdAsync); +const mockDownloadDeviceRunSessionEventLogAsync = jest.mocked( + downloadDeviceRunSessionEventLogAsync +); +const mockEnableJsonOutput = jest.mocked(enableJsonOutput); +const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync); +const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); +const mockLog = jest.mocked(Log.log); + +const SESSION_ID = 'session-123'; + +function makeDescriptor( + overrides: Partial = {} +): DeviceRunSessionEventLogDescriptor { + return { + availability: DeviceRunSessionEventLogAvailability.Available, + state: DeviceRunSessionEventLogState.Complete, + schemaVersion: 1, + capabilities: ['events.device-run-session.v1', 'stream.completion.v1'], + producers: [ + { + name: 'agent-device', + version: '0.19.1', + capabilities: ['event.operation.v1', 'stream.completion.v1'], + }, + ], + logFileUrls: ['https://logs.example.com/session.ndjson'], + expiresAt: '2026-08-10T10:00:00.000Z', + ...overrides, + }; +} + +function makeEvent(overrides: Partial = {}): DeviceRunSessionEvent { + return { + schemaVersion: 1, + eventId: 'event-1', + revision: 0, + streamId: 'stream-1', + deviceRunSessionId: SESSION_ID, + occurredAt: '2026-07-10T10:00:00.000Z', + producer: 'agent-device', + producerVersion: '0.19.1', + type: 'operation.completed', + outcome: 'success', + durationMs: 125, + summary: 'Tapped Continue', + ...overrides, + }; +} + +function makeCompletedStreamEvents(): DeviceRunSessionEvent[] { + return [ + makeEvent({ + eventId: 'stream-started', + type: 'stream.started', + outcome: 'unknown', + summary: 'agent-device source starting', + }), + makeEvent({ + eventId: 'stream-completed', + type: 'stream.completed', + outcome: 'success', + summary: 'agent-device source completed', + }), + ]; +} + +function makeFinalSnapshotDescriptor(): DeviceRunSessionEventLogDescriptor { + return makeDescriptor({ + capabilities: ['events.device-run-session.v1', 'stream.final-snapshot.v1'], + producers: [ + { + name: 'agent-device', + version: '0.19.1', + capabilities: ['event.operation.v1', 'stream.final-snapshot.v1'], + }, + ], + }); +} + +function makeFinalSnapshotEvents( + snapshotOverrides: Partial = {} +): DeviceRunSessionEvent[] { + return [ + makeCompletedStreamEvents()[0], + makeEvent({ + eventId: 'stream-snapshot', + type: 'stream.snapshot', + outcome: 'success', + summary: 'agent-device final snapshot persisted', + ...snapshotOverrides, + }), + ]; +} + +function getMockOclifConfig(): Config { + const config = new Config({ root: __dirname }); + config.runHook = async () => ({ failures: [], successes: [] }); + return config; +} + +describe(SimulatorEvents, () => { + const graphqlClient = {} as ExpoGraphqlClient; + const projectDir = '/test/project'; + const mockConfig = getMockOclifConfig(); + + beforeEach(() => { + jest.clearAllMocks(); + process.exitCode = undefined; + jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + mockLoadSimulatorEnvAsync.mockResolvedValue(); + mockEventLogByIdAsync.mockResolvedValue(makeDescriptor()); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: [makeEvent()], + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + process.exitCode = undefined; + }); + + function createCommand(argv: string[]): { + command: SimulatorEvents; + getContextAsync: jest.SpyInstance; + } { + const command = new SimulatorEvents(argv, mockConfig); + // @ts-expect-error getContextAsync is protected + const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({ + loggedIn: { graphqlClient }, + projectDir, + }); + return { command, getContextAsync }; + } + + it('downloads signed log files and prints structured events', async () => { + const { command } = createCommand(['--id', SESSION_ID]); + + await command.runAsync(); + + expect(mockEventLogByIdAsync).toHaveBeenCalledWith( + graphqlClient, + SESSION_ID, + expect.anything(), + { suppressTransientErrorLog: false } + ); + expect(mockDownloadDeviceRunSessionEventLogAsync).toHaveBeenCalledWith( + ['https://logs.example.com/session.ndjson'], + SESSION_ID, + expect.anything() + ); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Tapped Continue')); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('operation.completed')); + }); + + it('filters by producer and limits the historical snapshot', async () => { + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: [ + makeEvent({ eventId: 'agent-1' }), + makeEvent({ + eventId: 'serve-1', + streamId: 'serve-stream', + producer: 'serve-sim', + summary: 'First serve-sim event', + }), + makeEvent({ + eventId: 'serve-2', + streamId: 'serve-stream', + producer: 'serve-sim', + summary: 'Latest serve-sim event', + }), + ], + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand([ + '--id', + SESSION_ID, + '--source', + 'serve-sim', + '--limit', + '1', + ]); + + await command.runAsync(); + + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Latest serve-sim event')); + expect(mockLog).not.toHaveBeenCalledWith(expect.stringContaining('First serve-sim event')); + }); + + it('emits a self-describing JSON snapshot', async () => { + const descriptor = makeDescriptor({ state: DeviceRunSessionEventLogState.Partial }); + const events = [makeEvent()]; + mockEventLogByIdAsync.mockResolvedValue(descriptor); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events, + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand(['--id', SESSION_ID, '--json']); + + await command.runAsync(); + + expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ + deviceRunSessionId: SESSION_ID, + availability: 'AVAILABLE', + state: 'PARTIAL', + schemaVersion: 1, + producers: descriptor.producers, + isIncomplete: true, + logFiles: { total: 1, failed: 0 }, + events, + }); + }); + + it('reports a complete snapshot only when persisted producer markers verify it', async () => { + const events = makeCompletedStreamEvents(); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events, + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand(['--id', SESSION_ID, '--json']); + + await command.runAsync(); + + expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith( + expect.objectContaining({ + state: 'COMPLETE', + isIncomplete: false, + }) + ); + }); + + it('supports a finite NDJSON follow stream without stopping the session', async () => { + const event = makeEvent(); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: [event], + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand(['--id', SESSION_ID, '--follow', '--format', 'ndjson']); + + await command.runAsync(); + + expect(mockEventLogByIdAsync).toHaveBeenCalledTimes(1); + expect(mockLog).toHaveBeenCalledWith(JSON.stringify(event)); + }); + + it('clears the pending follow timer immediately on Ctrl+C', async () => { + jest.useFakeTimers(); + try { + mockEventLogByIdAsync.mockResolvedValue( + makeDescriptor({ state: DeviceRunSessionEventLogState.Streaming }) + ); + const { command } = createCommand(['--id', SESSION_ID, '--follow', '--format', 'ndjson']); + + const runPromise = command.runAsync(); + await jest.advanceTimersByTimeAsync(0); + expect(mockEventLogByIdAsync).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(1); + + process.emit('SIGINT'); + + await expect(runPromise).resolves.toBeUndefined(); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.useRealTimers(); + } + }); + + it('keeps every NDJSON stdout line parseable after a transient GraphQL refresh error', async () => { + jest.useFakeTimers(); + try { + const transientError = new CombinedError({ + graphQLErrors: [ + { + message: 'Temporary event log failure', + extensions: { isTransient: true }, + }, + ], + }); + mockEventLogByIdAsync + .mockResolvedValueOnce(makeDescriptor({ state: DeviceRunSessionEventLogState.Streaming })) + .mockRejectedValueOnce(transientError) + .mockResolvedValueOnce(makeDescriptor()); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: makeCompletedStreamEvents(), + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand(['--id', SESSION_ID, '--follow', '--format', 'ndjson']); + + const runPromise = command.runAsync(); + await jest.advanceTimersByTimeAsync(0); + await jest.advanceTimersByTimeAsync(5_000); + await jest.advanceTimersByTimeAsync(5_000); + await runPromise; + + const stdoutLines = mockLog.mock.calls.map(([line]) => String(line)); + expect(stdoutLines.length).toBeGreaterThan(0); + for (const line of stdoutLines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + expect(Log.debug).not.toHaveBeenCalled(); + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Temporary event log failure') + ); + for (const call of mockEventLogByIdAsync.mock.calls) { + expect(call[3]).toEqual({ suppressTransientErrorLog: true }); + } + } finally { + jest.useRealTimers(); + } + }); + + it('writes partial download warnings to stderr and exits nonzero in NDJSON mode', async () => { + const stderrWrite = jest.mocked(process.stderr.write); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: makeCompletedStreamEvents(), + totalLogFileCount: 2, + failedLogFileCount: 1, + }); + const { command } = createCommand(['--id', SESSION_ID, '--format', 'ndjson']); + + await command.runAsync(); + + expect(stderrWrite).toHaveBeenCalledWith(expect.stringContaining('Could not read 1 of 2')); + expect(process.exitCode).toBe(1); + }); + + it('writes truncation warnings to stderr and exits nonzero in NDJSON mode', async () => { + const stderrWrite = jest.mocked(process.stderr.write); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: [ + ...makeCompletedStreamEvents(), + makeEvent({ + eventId: 'stream-truncated', + type: 'stream.truncated', + outcome: 'failure', + summary: 'agent-device source reached its event limit', + }), + ], + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand(['--id', SESSION_ID, '--format', 'ndjson']); + + await command.runAsync(); + + expect(stderrWrite).toHaveBeenCalledWith(expect.stringContaining('safety limit')); + expect(process.exitCode).toBe(1); + }); + + it('writes failed persisted completion warnings to stderr and exits nonzero in NDJSON mode', async () => { + const stderrWrite = jest.mocked(process.stderr.write); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: [ + makeCompletedStreamEvents()[0], + makeEvent({ + eventId: 'stream-completed', + type: 'stream.completed', + outcome: 'failure', + summary: 'agent-device source failed to persist completely', + }), + ], + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand(['--id', SESSION_ID, '--format', 'ndjson']); + + await command.runAsync(); + + expect(stderrWrite).toHaveBeenCalledWith( + expect.stringContaining('successful stream finalization markers are missing') + ); + expect(process.exitCode).toBe(1); + }); + + it('accepts a final snapshot from a producer that does not claim verified completion', async () => { + const stderrWrite = jest.mocked(process.stderr.write); + mockEventLogByIdAsync.mockResolvedValue(makeFinalSnapshotDescriptor()); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: makeFinalSnapshotEvents(), + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand(['--id', SESSION_ID, '--format', 'ndjson']); + + await command.runAsync(); + + expect(process.exitCode).toBeUndefined(); + expect(stderrWrite).not.toHaveBeenCalledWith( + expect.stringContaining('finalization markers are missing') + ); + }); + + it('rejects a snapshot-only producer with no persisted final snapshot', async () => { + const stderrWrite = jest.mocked(process.stderr.write); + mockEventLogByIdAsync.mockResolvedValue(makeFinalSnapshotDescriptor()); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: [makeCompletedStreamEvents()[0]], + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand(['--id', SESSION_ID, '--format', 'ndjson']); + + await command.runAsync(); + + expect(stderrWrite).toHaveBeenCalledWith( + expect.stringContaining('successful stream finalization markers are missing') + ); + expect(process.exitCode).toBe(1); + }); + + it('rejects a failed final snapshot', async () => { + const stderrWrite = jest.mocked(process.stderr.write); + mockEventLogByIdAsync.mockResolvedValue(makeFinalSnapshotDescriptor()); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: makeFinalSnapshotEvents({ outcome: 'failure' }), + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand(['--id', SESSION_ID, '--format', 'ndjson']); + + await command.runAsync(); + + expect(stderrWrite).toHaveBeenCalledWith( + expect.stringContaining('successful stream finalization markers are missing') + ); + expect(process.exitCode).toBe(1); + }); + + it('rejects a final snapshot for a different stream', async () => { + const stderrWrite = jest.mocked(process.stderr.write); + mockEventLogByIdAsync.mockResolvedValue(makeFinalSnapshotDescriptor()); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: makeFinalSnapshotEvents({ streamId: 'stream-2' }), + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand(['--id', SESSION_ID, '--format', 'ndjson']); + + await command.runAsync(); + + expect(stderrWrite).toHaveBeenCalledWith( + expect.stringContaining('successful stream finalization markers are missing') + ); + expect(process.exitCode).toBe(1); + }); + + it('rejects a registered producer without a supported finalization capability', async () => { + const stderrWrite = jest.mocked(process.stderr.write); + mockEventLogByIdAsync.mockResolvedValue( + makeDescriptor({ + producers: [ + { + name: 'agent-device', + version: '0.19.1', + capabilities: ['event.operation.v1'], + }, + ], + }) + ); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: makeCompletedStreamEvents(), + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand(['--id', SESSION_ID, '--format', 'ndjson']); + + await command.runAsync(); + + expect(stderrWrite).toHaveBeenCalledWith( + expect.stringContaining('successful stream finalization markers are missing') + ); + expect(process.exitCode).toBe(1); + }); + + it('rejects an observed stream that is not covered by the producer registration', async () => { + const stderrWrite = jest.mocked(process.stderr.write); + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events: [ + ...makeCompletedStreamEvents(), + makeEvent({ + eventId: 'unregistered-stream-started', + streamId: 'unregistered-stream', + producer: 'future-producer', + producerVersion: undefined, + type: 'stream.started', + }), + ], + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand(['--id', SESSION_ID, '--format', 'ndjson']); + + await command.runAsync(); + + expect(stderrWrite).toHaveBeenCalledWith( + expect.stringContaining('successful stream finalization markers are missing') + ); + expect(process.exitCode).toBe(1); + }); + + it('aborts an in-flight GraphQL snapshot request on Ctrl+C', async () => { + let requestSignal: AbortSignal | undefined; + mockEventLogByIdAsync.mockImplementation(async (_graphqlClient, _sessionId, signal) => { + requestSignal = signal; + return await new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => { + reject(signal.reason); + }, + { once: true } + ); + }); + }); + const { command } = createCommand(['--id', SESSION_ID]); + + const runPromise = command.runAsync(); + while (!requestSignal) { + await new Promise(resolve => setImmediate(resolve)); + } + process.emit('SIGINT'); + + await expect(runPromise).resolves.toBeUndefined(); + expect(requestSignal?.aborted).toBe(true); + }); + + it('aborts an in-flight signed log download on Ctrl+C', async () => { + let downloadSignal: AbortSignal | undefined; + mockDownloadDeviceRunSessionEventLogAsync.mockImplementation( + async (_logFileUrls, _sessionId, signal) => { + downloadSignal = signal; + return await new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => { + reject(signal.reason); + }, + { once: true } + ); + }); + } + ); + const { command } = createCommand(['--id', SESSION_ID]); + + const runPromise = command.runAsync(); + while (!downloadSignal) { + await new Promise(resolve => setImmediate(resolve)); + } + process.emit('SIGINT'); + + await expect(runPromise).resolves.toBeUndefined(); + expect(downloadSignal?.aborted).toBe(true); + }); + + it('applies the history limit when a pending follow stream first becomes available', async () => { + jest.useFakeTimers(); + mockEventLogByIdAsync + .mockResolvedValueOnce( + makeDescriptor({ + availability: DeviceRunSessionEventLogAvailability.Pending, + state: null, + schemaVersion: null, + producers: [], + logFileUrls: [], + }) + ) + .mockResolvedValueOnce(makeDescriptor()); + const events = [ + makeEvent({ eventId: 'event-1', summary: 'Old event' }), + makeEvent({ eventId: 'event-2', summary: 'Latest event' }), + ]; + mockDownloadDeviceRunSessionEventLogAsync.mockResolvedValue({ + events, + totalLogFileCount: 1, + failedLogFileCount: 0, + }); + const { command } = createCommand([ + '--id', + SESSION_ID, + '--follow', + '--limit', + '1', + '--format', + 'ndjson', + ]); + + try { + const runPromise = command.runAsync(); + await jest.advanceTimersByTimeAsync(0); + await jest.advanceTimersByTimeAsync(5_000); + await runPromise; + + expect(mockEventLogByIdAsync).toHaveBeenCalledTimes(2); + expect(mockLog).not.toHaveBeenCalledWith(JSON.stringify(events[0])); + expect(mockLog).toHaveBeenCalledWith(JSON.stringify(events[1])); + } finally { + jest.useRealTimers(); + } + }); + + it('uses the simulator env session ID', async () => { + const previousSessionId = process.env[EAS_SIMULATOR_SESSION_ID]; + process.env[EAS_SIMULATOR_SESSION_ID] = SESSION_ID; + try { + const { command } = createCommand([]); + await command.runAsync(); + expect(mockLoadSimulatorEnvAsync).toHaveBeenCalledWith(projectDir); + expect(mockEventLogByIdAsync).toHaveBeenCalledWith( + graphqlClient, + SESSION_ID, + expect.anything(), + { suppressTransientErrorLog: false } + ); + } finally { + if (previousSessionId === undefined) { + delete process.env[EAS_SIMULATOR_SESSION_ID]; + } else { + process.env[EAS_SIMULATOR_SESSION_ID] = previousSessionId; + } + } + }); + + it('rejects snapshot JSON in follow mode with an NDJSON hint', async () => { + const { command, getContextAsync } = createCommand(['--id', SESSION_ID, '--follow', '--json']); + + await expect(command.runAsync()).rejects.toThrow('--follow --format ndjson'); + expect(getContextAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/commands/simulator/events.ts b/packages/eas-cli/src/commands/simulator/events.ts new file mode 100644 index 0000000000..0df1c636a0 --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/events.ts @@ -0,0 +1,589 @@ +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../commandUtils/flags'; +import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination'; +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + DeviceRunSessionEventLogAvailability, + DeviceRunSessionEventLogState, +} from '../../graphql/generated'; +import { + DeviceRunSessionEventLogDescriptor, + DeviceRunSessionQuery, +} from '../../graphql/queries/DeviceRunSessionQuery'; +import Log from '../../log'; +import { ora } from '../../ora'; +import { + EAS_SIMULATOR_SESSION_ID, + SIMULATOR_DOTENV_FILE_NAME, + loadSimulatorEnvAsync, +} from '../../simulator/env'; +import { + DeviceRunSessionEvent, + DeviceRunSessionEventDownloadResult, + downloadDeviceRunSessionEventLogAsync, + getDeviceRunSessionEventIdentity, +} from '../../simulator/events'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; + +const DEFAULT_LIMIT = 100; +const MAX_LIMIT = 5_000; +const POLL_INTERVAL_MS = 5_000; + +type EventOutputFormat = 'human' | 'ndjson'; + +type EventLogSnapshot = { + descriptor: DeviceRunSessionEventLogDescriptor; + events: DeviceRunSessionEvent[]; + download: DeviceRunSessionEventDownloadResult; +}; + +export default class SimulatorEvents extends EasCommand { + static override hidden = true; + static override description = + '[EXPERIMENTAL] show structured activity events from a remote simulator session'; + + static override flags = { + id: Flags.string({ + description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`, + }), + follow: Flags.boolean({ + char: 'f', + description: + 'Keep watching for new events until the event stream is complete or interrupted.', + }), + source: Flags.string({ + description: 'Filter by event producer (repeatable), for example agent-device or serve-sim.', + multiple: true, + }), + limit: getLimitFlagWithCustomValues({ + defaultTo: DEFAULT_LIMIT, + limit: MAX_LIMIT, + description: `Maximum historical events to print. Defaults to ${DEFAULT_LIMIT} and is capped at ${MAX_LIMIT}.`, + }), + format: Flags.option({ + description: 'Output format. Use ndjson with --follow for a machine-readable event stream.', + options: ['human', 'ndjson'] as const, + default: 'human' as const, + })(), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.LoggedIn, + ...this.ContextOptions.ProjectDir, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(SimulatorEvents); + const format: EventOutputFormat = flags.format; + const { json: jsonFlag, nonInteractive: resolvedNonInteractive } = + resolveNonInteractiveAndJsonFlags(flags); + const nonInteractive = resolvedNonInteractive || format === 'ndjson'; + + if (jsonFlag && format === 'ndjson') { + throw new Error('Use either --json or --format ndjson, not both.'); + } + if (jsonFlag && flags.follow) { + throw new Error( + 'The --json flag is snapshot-only. Use --follow --format ndjson for a machine-readable event stream.' + ); + } + if (jsonFlag) { + enableJsonOutput(); + } + + const { + projectDir, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(SimulatorEvents, { nonInteractive }); + + await loadSimulatorEnvAsync(projectDir); + const deviceRunSessionId = flags.id || process.env[EAS_SIMULATOR_SESSION_ID]; + if (!deviceRunSessionId) { + throw new Error( + `No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.` + ); + } + + const sources = new Set((flags.source ?? []).map(source => source.toLowerCase())); + const limit = flags.limit ?? DEFAULT_LIMIT; + + const abortController = new AbortController(); + const interruptHandler = (): void => { + abortController.abort(); + }; + process.on('SIGINT', interruptHandler); + + try { + if (flags.follow) { + await followEventsAsync({ + graphqlClient, + deviceRunSessionId, + sources, + limit, + format, + signal: abortController.signal, + }); + return; + } + + const spinner = + !jsonFlag && format === 'human' + ? ora(`Fetching activity for simulator session ${deviceRunSessionId}`).start() + : null; + let snapshot: EventLogSnapshot; + try { + snapshot = await fetchEventLogSnapshotAsync( + graphqlClient, + deviceRunSessionId, + abortController.signal, + format + ); + spinner?.succeed(`Fetched activity for simulator session ${deviceRunSessionId}`); + } catch (error) { + if (abortController.signal.aborted) { + spinner?.stop(); + return; + } + spinner?.fail(`Failed to fetch activity for simulator session ${deviceRunSessionId}`); + throw error; + } + + const events = filterAndLimitEvents(snapshot.events, sources, limit); + if (jsonFlag) { + const isTruncated = hasTruncationEvent(snapshot.events); + const isCompletionVerified = hasVerifiedPersistedCompletion(snapshot); + printJsonOnlyOutput({ + deviceRunSessionId, + availability: snapshot.descriptor.availability, + state: snapshot.descriptor.state, + schemaVersion: snapshot.descriptor.schemaVersion, + producers: snapshot.descriptor.producers, + isIncomplete: + snapshot.descriptor.state === DeviceRunSessionEventLogState.Partial || + snapshot.download.failedLogFileCount > 0 || + isTruncated || + (snapshot.descriptor.state === DeviceRunSessionEventLogState.Complete && + !isCompletionVerified), + logFiles: { + total: snapshot.download.totalLogFileCount, + failed: snapshot.download.failedLogFileCount, + }, + events, + }); + return; + } + + printSnapshot(snapshot, events, format); + if (format === 'ndjson') { + reportIncompleteNdjsonSnapshot(snapshot); + } + } finally { + process.removeListener('SIGINT', interruptHandler); + } + } +} + +async function fetchEventLogSnapshotAsync( + graphqlClient: ExpoGraphqlClient, + deviceRunSessionId: string, + signal: AbortSignal, + format: EventOutputFormat +): Promise { + const descriptor = await DeviceRunSessionQuery.eventLogByIdAsync( + graphqlClient, + deviceRunSessionId, + signal, + { suppressTransientErrorLog: format === 'ndjson' } + ); + const download = + descriptor.logFileUrls.length > 0 + ? await downloadDeviceRunSessionEventLogAsync( + descriptor.logFileUrls, + deviceRunSessionId, + signal + ) + : { events: [], totalLogFileCount: 0, failedLogFileCount: 0 }; + return { descriptor, events: download.events, download }; +} + +async function followEventsAsync({ + graphqlClient, + deviceRunSessionId, + sources, + limit, + format, + signal, +}: { + graphqlClient: ExpoGraphqlClient; + deviceRunSessionId: string; + sources: Set; + limit: number; + format: EventOutputFormat; + signal: AbortSignal; +}): Promise { + const latestRevisionByIdentity = new Map(); + let receivedSnapshot = false; + let initializedHistory = false; + let printedWaitingMessage = false; + let printedIncompleteDownloadWarning = false; + const reportedNdjsonWarnings = new Set(); + + if (format === 'human') { + Log.gray( + `Following simulator session activity. Press Ctrl+C to stop watching; the session will keep running.` + ); + } + + while (!signal.aborted) { + let snapshot: EventLogSnapshot; + try { + snapshot = await fetchEventLogSnapshotAsync( + graphqlClient, + deviceRunSessionId, + signal, + format + ); + } catch (error) { + if (signal.aborted) { + return; + } + if (!receivedSnapshot) { + throw error; + } + const diagnostic = `Failed to refresh simulator session activity: ${ + error instanceof Error ? error.message : String(error) + }`; + if (format === 'ndjson') { + process.stderr.write(`Warning: ${diagnostic}\n`); + } else { + Log.debug(diagnostic); + } + await waitForNextPollAsync(signal); + continue; + } + + const filteredEvents = filterEvents(snapshot.events, sources); + let eventsToPrint: DeviceRunSessionEvent[]; + if ( + !initializedHistory && + snapshot.descriptor.availability === DeviceRunSessionEventLogAvailability.Available + ) { + for (const event of filteredEvents) { + latestRevisionByIdentity.set(getDeviceRunSessionEventIdentity(event), event.revision); + } + eventsToPrint = filteredEvents.slice(-limit); + initializedHistory = true; + } else if (!initializedHistory) { + eventsToPrint = []; + } else { + eventsToPrint = filteredEvents.filter(event => { + const identity = getDeviceRunSessionEventIdentity(event); + const previousRevision = latestRevisionByIdentity.get(identity); + if (previousRevision !== undefined && previousRevision >= event.revision) { + return false; + } + latestRevisionByIdentity.set(identity, event.revision); + return true; + }); + } + receivedSnapshot = true; + + printEvents(eventsToPrint, format); + + if (format === 'ndjson') { + reportIncompleteNdjsonSnapshot(snapshot, reportedNdjsonWarnings); + } else if (snapshot.download.failedLogFileCount > 0 && !printedIncompleteDownloadWarning) { + Log.warn( + `Could not read ${snapshot.download.failedLogFileCount} of ${snapshot.download.totalLogFileCount} activity log files; displayed activity may be incomplete.` + ); + printedIncompleteDownloadWarning = true; + } else if (snapshot.download.failedLogFileCount === 0) { + printedIncompleteDownloadWarning = false; + } + + if ( + format === 'human' && + filteredEvents.length === 0 && + !printedWaitingMessage && + !isEventLogTerminal(snapshot.descriptor) + ) { + Log.gray('Waiting for session activity...'); + printedWaitingMessage = true; + } + + if (isEventLogTerminal(snapshot.descriptor)) { + const isCompletionVerified = hasVerifiedPersistedCompletion(snapshot); + if (format === 'human') { + printTerminalState( + snapshot.descriptor, + filteredEvents.length, + snapshot.download.failedLogFileCount, + hasTruncationEvent(snapshot.events), + isCompletionVerified + ); + } + return; + } + + await waitForNextPollAsync(signal); + } +} + +async function waitForNextPollAsync(signal: AbortSignal): Promise { + if (signal.aborted) { + return; + } + + await new Promise(resolve => { + const finish = (): void => { + clearTimeout(timeout); + signal.removeEventListener('abort', finish); + resolve(); + }; + const timeout = setTimeout(finish, POLL_INTERVAL_MS); + timeout.unref(); + signal.addEventListener('abort', finish, { once: true }); + }); +} + +function printSnapshot( + snapshot: EventLogSnapshot, + events: DeviceRunSessionEvent[], + format: EventOutputFormat +): void { + printEvents(events, format); + if (format === 'human') { + printTerminalState( + snapshot.descriptor, + events.length, + snapshot.download.failedLogFileCount, + hasTruncationEvent(snapshot.events), + hasVerifiedPersistedCompletion(snapshot) + ); + } +} + +function printEvents(events: DeviceRunSessionEvent[], format: EventOutputFormat): void { + for (const event of events) { + if (format === 'ndjson') { + Log.log(JSON.stringify(event)); + } else { + Log.log(formatHumanEvent(event)); + } + } +} + +function printTerminalState( + descriptor: DeviceRunSessionEventLogDescriptor, + eventCount: number, + failedLogFileCount = 0, + isTruncated = false, + isCompletionVerified = true +): void { + if (descriptor.availability === DeviceRunSessionEventLogAvailability.Pending) { + Log.gray('Session activity is not available yet.'); + } else if (descriptor.availability === DeviceRunSessionEventLogAvailability.Unsupported) { + Log.gray('This simulator session does not publish structured activity events.'); + } else if (descriptor.availability === DeviceRunSessionEventLogAvailability.Expired) { + Log.gray('Session activity logs have expired.'); + } else if (eventCount === 0) { + Log.gray( + descriptor.state === DeviceRunSessionEventLogState.Streaming + ? 'No structured session activity events yet. Re-run with --follow to keep watching.' + : 'No structured session activity events were found.' + ); + } + + if (descriptor.state === DeviceRunSessionEventLogState.Partial) { + Log.warn('The activity stream may be incomplete because it did not finish cleanly.'); + } + if (failedLogFileCount > 0) { + Log.warn('Some activity log files could not be read; displayed activity may be incomplete.'); + } + if (isTruncated) { + Log.warn('The activity stream reached its safety limit and is incomplete.'); + } + if (descriptor.state === DeviceRunSessionEventLogState.Complete && !isCompletionVerified) { + Log.warn('The activity job finished, but persisted stream finalization markers are missing.'); + } +} + +function reportIncompleteNdjsonSnapshot( + snapshot: EventLogSnapshot, + reportedWarnings = new Set() +): void { + const warnings: string[] = []; + if (snapshot.descriptor.state === DeviceRunSessionEventLogState.Partial) { + warnings.push('The activity stream is partial because it did not finish cleanly.'); + } + if (snapshot.download.failedLogFileCount > 0) { + warnings.push( + `Could not read ${snapshot.download.failedLogFileCount} of ${snapshot.download.totalLogFileCount} activity log files; the NDJSON stream is incomplete.` + ); + } + if (hasTruncationEvent(snapshot.events)) { + warnings.push( + 'The activity stream reached its safety limit and the NDJSON stream is incomplete.' + ); + } + if ( + snapshot.descriptor.state === DeviceRunSessionEventLogState.Complete && + !hasVerifiedPersistedCompletion(snapshot) + ) { + warnings.push( + 'The activity job finished, but persisted successful stream finalization markers are missing.' + ); + } + + if (warnings.length === 0) { + return; + } + + process.exitCode = 1; + for (const warning of warnings) { + if (!reportedWarnings.has(warning)) { + process.stderr.write(`Warning: ${warning}\n`); + reportedWarnings.add(warning); + } + } +} + +function hasTruncationEvent(events: DeviceRunSessionEvent[]): boolean { + return events.some(event => event.type === 'stream.truncated'); +} + +function hasVerifiedPersistedCompletion(snapshot: EventLogSnapshot): boolean { + if (snapshot.descriptor.state !== DeviceRunSessionEventLogState.Complete) { + return false; + } + + const finalizationProducers = snapshot.descriptor.producers.flatMap(producer => { + const markerType = producer.capabilities.includes('stream.completion.v1') + ? 'stream.completed' + : producer.capabilities.includes('stream.final-snapshot.v1') + ? 'stream.snapshot' + : null; + return markerType ? [{ ...producer, markerType }] : []; + }); + const matchesProducer = ( + event: DeviceRunSessionEvent, + producer: (typeof finalizationProducers)[number] + ): boolean => + event.producer === producer.name && + (!producer.version || event.producerVersion === producer.version); + const isSuccessfulMarker = ( + event: DeviceRunSessionEvent, + producer: (typeof finalizationProducers)[number] + ): boolean => + event.type === producer.markerType && + event.outcome === 'success' && + matchesProducer(event, producer); + + const everyRegisteredProducerFinalized = finalizationProducers.every(producer => + snapshot.events.some(event => isSuccessfulMarker(event, producer)) + ); + const everyObservedStreamFinalized = snapshot.events + .filter(event => event.type === 'stream.started') + .every(startedEvent => { + const producer = finalizationProducers.find(candidate => + matchesProducer(startedEvent, candidate) + ); + if (!producer) { + return false; + } + return snapshot.events.some( + event => event.streamId === startedEvent.streamId && isSuccessfulMarker(event, producer) + ); + }); + + return ( + snapshot.descriptor.producers.length > 0 && + finalizationProducers.length === snapshot.descriptor.producers.length && + everyObservedStreamFinalized && + everyRegisteredProducerFinalized + ); +} + +function isEventLogTerminal(descriptor: DeviceRunSessionEventLogDescriptor): boolean { + return ( + descriptor.availability === DeviceRunSessionEventLogAvailability.Unsupported || + descriptor.availability === DeviceRunSessionEventLogAvailability.Expired || + descriptor.state === DeviceRunSessionEventLogState.Complete || + descriptor.state === DeviceRunSessionEventLogState.Partial + ); +} + +function filterAndLimitEvents( + events: DeviceRunSessionEvent[], + sources: Set, + limit: number +): DeviceRunSessionEvent[] { + return filterEvents(events, sources).slice(-limit); +} + +function filterEvents( + events: DeviceRunSessionEvent[], + sources: Set +): DeviceRunSessionEvent[] { + if (sources.size === 0) { + return events; + } + return events.filter(event => sources.has(event.producer.toLowerCase())); +} + +function formatHumanEvent(event: DeviceRunSessionEvent): string { + const timestamp = chalk.dim(sanitizeTerminalText(event.occurredAt)); + const producer = chalk.cyan( + sanitizeTerminalText( + event.producerVersion ? `${event.producer}@${event.producerVersion}` : event.producer + ) + ); + const outcome = formatOutcome(event.outcome); + const metadata = [sanitizeTerminalText(event.type), formatDuration(event.durationMs)] + .filter(Boolean) + .join(' · '); + return `${timestamp} ${producer} ${outcome} ${sanitizeTerminalText(event.summary)}${ + metadata ? chalk.dim(` ${metadata}`) : '' + }`; +} + +function formatOutcome(outcome: string | undefined): string { + const normalizedOutcome = outcome?.toLowerCase(); + if (normalizedOutcome && ['success', 'succeeded', 'ok'].includes(normalizedOutcome)) { + return chalk.green('✓'); + } + if (normalizedOutcome && ['failure', 'failed', 'error', 'errored'].includes(normalizedOutcome)) { + return chalk.red('✗'); + } + if (normalizedOutcome && ['cancelled', 'canceled', 'aborted'].includes(normalizedOutcome)) { + return chalk.yellow('–'); + } + return chalk.dim('•'); +} + +function formatDuration(durationMs: number | undefined): string | undefined { + if (durationMs === undefined) { + return undefined; + } + if (durationMs < 1_000) { + return `${Math.round(durationMs)}ms`; + } + if (durationMs < 60_000) { + return `${(durationMs / 1_000).toFixed(durationMs < 10_000 ? 1 : 0)}s`; + } + return `${Math.floor(durationMs / 60_000)}m ${Math.round((durationMs % 60_000) / 1_000)}s`; +} + +function sanitizeTerminalText(value: string): string { + return value + .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/packages/eas-cli/src/graphql/__tests__/client-test.ts b/packages/eas-cli/src/graphql/__tests__/client-test.ts index 35ae50cf0a..22c4d6d463 100644 --- a/packages/eas-cli/src/graphql/__tests__/client-test.ts +++ b/packages/eas-cli/src/graphql/__tests__/client-test.ts @@ -31,6 +31,19 @@ describe(withErrorHandlingAsync, () => { ); }); + it('can suppress transient error logging for machine-readable output', async () => { + const error = makeError('Transient failure', { + isTransient: true, + errorCode: 'SOME_TRANSIENT', + }); + await expect( + withErrorHandlingAsync(Promise.resolve({ error } as any), { + logTransientErrors: false, + }) + ).rejects.toBe(error); + expect(mockLogError).not.toHaveBeenCalled(); + }); + it('suppresses the transient error message for EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE', async () => { const error = makeError('Asset not yet available', { isTransient: true, diff --git a/packages/eas-cli/src/graphql/client.ts b/packages/eas-cli/src/graphql/client.ts index d7e9f776d4..19d5801892 100644 --- a/packages/eas-cli/src/graphql/client.ts +++ b/packages/eas-cli/src/graphql/client.ts @@ -1,15 +1,77 @@ -import { CombinedError as GraphqlError, OperationResult } from '@urql/core'; +import { CombinedError as GraphqlError, OperationResult, OperationResultSource } from '@urql/core'; import { EasCommandError } from '../commandUtils/errors'; import Log from '../log'; export const EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE = 'EAS_CLI_UPGRADE_REQUIRED'; -export async function withErrorHandlingAsync(promise: Promise>): Promise { +/** + * Resolves an urql operation without replacing its client-level fetch options. + * + * Passing `fetchOptions: { signal }` as an operation context would replace the + * client's auth-bearing fetch options. Unsubscribing instead sends urql's normal + * teardown signal, which aborts the underlying request while retaining auth. + */ +export async function operationResultWithSignalAsync( + source: OperationResultSource, + signal: AbortSignal +): Promise { + if (signal.aborted) { + throw getAbortReason(signal); + } + + return await new Promise((resolve, reject) => { + let settled = false; + let subscription: { unsubscribe(): void } | undefined; + + const cleanUp = (): void => { + signal.removeEventListener('abort', handleAbort); + subscription?.unsubscribe(); + }; + const handleAbort = (): void => { + if (settled) { + return; + } + settled = true; + cleanUp(); + reject(getAbortReason(signal)); + }; + + signal.addEventListener('abort', handleAbort, { once: true }); + subscription = source.subscribe(result => { + if (settled || result.stale || result.hasNext) { + return; + } + settled = true; + cleanUp(); + resolve(result); + }); + + // Some test and cache sources can emit synchronously during subscribe(). + if (settled) { + subscription.unsubscribe(); + } + }); +} + +function getAbortReason(signal: AbortSignal): Error { + if (signal.reason instanceof Error) { + return signal.reason; + } + const error = new Error('The operation was aborted.'); + error.name = 'AbortError'; + return error; +} + +export async function withErrorHandlingAsync( + promise: Promise>, + { logTransientErrors = true }: { logTransientErrors?: boolean } = {} +): Promise { const { data, error } = await promise; if (error) { if ( + logTransientErrors && error.graphQLErrors.some( e => e?.extensions?.isTransient && diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 4273e78351..755da88629 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -5599,6 +5599,7 @@ export type DeviceRunSession = { app: App; artifacts: Array; createdAt: Scalars['DateTime']['output']; + eventLog: DeviceRunSessionEventLog; finishedAt?: Maybe; id: Scalars['ID']['output']; initiatingActor?: Maybe; @@ -5646,6 +5647,37 @@ export type DeviceRunSessionArtifactUploadSession = { url: Scalars['String']['output']; }; +export type DeviceRunSessionEventLog = { + __typename?: 'DeviceRunSessionEventLog'; + availability: DeviceRunSessionEventLogAvailability; + capabilities: Array; + expiresAt?: Maybe; + logFileUrls: Array; + producers: Array; + schemaVersion?: Maybe; + state?: Maybe; +}; + +export enum DeviceRunSessionEventLogAvailability { + Available = 'AVAILABLE', + Expired = 'EXPIRED', + Pending = 'PENDING', + Unsupported = 'UNSUPPORTED' +} + +export type DeviceRunSessionEventLogProducer = { + __typename?: 'DeviceRunSessionEventLogProducer'; + capabilities: Array; + name: Scalars['String']['output']; + version?: Maybe; +}; + +export enum DeviceRunSessionEventLogState { + Complete = 'COMPLETE', + Partial = 'PARTIAL', + Streaming = 'STREAMING' +} + export type DeviceRunSessionFilterInput = { platforms?: InputMaybe>; statuses?: InputMaybe>; @@ -13980,6 +14012,13 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{ export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, updatedAt: any, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, name: string, filename: string, downloadUrl: string, fileSizeBytes?: number | null, metadata?: any | null, createdAt: any, updatedAt: any }>, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null } | { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } }; +export type DeviceRunSessionEventLogByIdQueryVariables = Exact<{ + deviceRunSessionId: Scalars['ID']['input']; +}>; + + +export type DeviceRunSessionEventLogByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, eventLog: { __typename?: 'DeviceRunSessionEventLog', availability: DeviceRunSessionEventLogAvailability, state?: DeviceRunSessionEventLogState | null, schemaVersion?: number | null, capabilities: Array, logFileUrls: Array, expiresAt?: any | null, producers: Array<{ __typename?: 'DeviceRunSessionEventLogProducer', name: string, version?: string | null, capabilities: Array }> } } } }; + export type DeviceRunSessionsByAppIdQueryVariables = Exact<{ appId: Scalars['String']['input']; first?: InputMaybe; diff --git a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts index 51b76185fd..8045d0a264 100644 --- a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts +++ b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts @@ -1,15 +1,20 @@ import gql from 'graphql-tag'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; -import { withErrorHandlingAsync } from '../client'; +import { operationResultWithSignalAsync, withErrorHandlingAsync } from '../client'; import { DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables, + DeviceRunSessionEventLogByIdQuery, + DeviceRunSessionEventLogByIdQueryVariables, DeviceRunSessionFilterInput, DeviceRunSessionsByAppIdQuery, DeviceRunSessionsByAppIdQueryVariables, } from '../generated'; +export type DeviceRunSessionEventLogDescriptor = + DeviceRunSessionEventLogByIdQuery['deviceRunSessions']['byId']['eventLog']; + export const DeviceRunSessionQuery = { async byIdAsync( graphqlClient: ExpoGraphqlClient, @@ -80,6 +85,47 @@ export const DeviceRunSessionQuery = { ); return data.deviceRunSessions.byId; }, + async eventLogByIdAsync( + graphqlClient: ExpoGraphqlClient, + deviceRunSessionId: string, + signal?: AbortSignal, + { suppressTransientErrorLog = false }: { suppressTransientErrorLog?: boolean } = {} + ): Promise { + const resultSource = graphqlClient.query< + DeviceRunSessionEventLogByIdQuery, + DeviceRunSessionEventLogByIdQueryVariables + >( + gql` + query DeviceRunSessionEventLogByIdQuery($deviceRunSessionId: ID!) { + deviceRunSessions { + byId(deviceRunSessionId: $deviceRunSessionId) { + id + eventLog { + availability + state + schemaVersion + capabilities + producers { + name + version + capabilities + } + logFileUrls + expiresAt + } + } + } + } + `, + { deviceRunSessionId }, + { requestPolicy: 'network-only' } + ); + const data = await withErrorHandlingAsync( + signal ? operationResultWithSignalAsync(resultSource, signal) : resultSource.toPromise(), + { logTransientErrors: !suppressTransientErrorLog } + ); + return data.deviceRunSessions.byId.eventLog; + }, async listByAppIdAsync( graphqlClient: ExpoGraphqlClient, { diff --git a/packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts b/packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts new file mode 100644 index 0000000000..f4d9195937 --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/__tests__/DeviceRunSessionQuery-test.ts @@ -0,0 +1,86 @@ +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { DeviceRunSessionQuery } from '../DeviceRunSessionQuery'; + +const SESSION_ID = 'session-123'; + +function makeEventLogResponse(): any { + return { + data: { + deviceRunSessions: { + byId: { + id: SESSION_ID, + eventLog: { + availability: 'AVAILABLE', + state: 'STREAMING', + schemaVersion: 1, + capabilities: ['events.device-run-session.v1'], + producers: [], + logFileUrls: [], + expiresAt: null, + }, + }, + }, + }, + stale: false, + hasNext: false, + }; +} + +describe(DeviceRunSessionQuery.eventLogByIdAsync.name, () => { + it('retains client auth options while making the query abortable', async () => { + let emitResult: ((result: any) => void) | undefined; + const unsubscribe = jest.fn(); + const resultSource = { + subscribe: jest.fn((onResult: (result: any) => void) => { + emitResult = onResult; + return { unsubscribe }; + }), + toPromise: jest.fn(), + }; + const query = jest.fn(() => resultSource); + const graphqlClient = { query } as unknown as ExpoGraphqlClient; + const abortController = new AbortController(); + + const resultPromise = DeviceRunSessionQuery.eventLogByIdAsync( + graphqlClient, + SESSION_ID, + abortController.signal + ); + emitResult?.(makeEventLogResponse()); + + await expect(resultPromise).resolves.toMatchObject({ + availability: 'AVAILABLE', + state: 'STREAMING', + }); + expect(query).toHaveBeenCalledWith( + expect.anything(), + { deviceRunSessionId: SESSION_ID }, + { requestPolicy: 'network-only' } + ); + expect(unsubscribe).toHaveBeenCalled(); + expect(resultSource.toPromise).not.toHaveBeenCalled(); + }); + + it('unsubscribes the urql operation when aborted', async () => { + const unsubscribe = jest.fn(); + const resultSource = { + subscribe: jest.fn(() => ({ unsubscribe })), + toPromise: jest.fn(), + }; + const graphqlClient = { + query: jest.fn(() => resultSource), + } as unknown as ExpoGraphqlClient; + const abortController = new AbortController(); + + const resultPromise = DeviceRunSessionQuery.eventLogByIdAsync( + graphqlClient, + SESSION_ID, + abortController.signal + ); + abortController.abort(); + + await expect(resultPromise).rejects.toMatchObject({ name: 'AbortError' }); + expect(unsubscribe).toHaveBeenCalled(); + expect(resultSource.toPromise).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/simulator/__tests__/events.test.ts b/packages/eas-cli/src/simulator/__tests__/events.test.ts new file mode 100644 index 0000000000..5dabd5db27 --- /dev/null +++ b/packages/eas-cli/src/simulator/__tests__/events.test.ts @@ -0,0 +1,217 @@ +import nock from 'nock'; + +import { + coalesceDeviceRunSessionEvents, + downloadDeviceRunSessionEventLogAsync, + downloadDeviceRunSessionEventsAsync, + parseDeviceRunSessionEvents, +} from '../events'; + +const SESSION_ID = 'session-123'; + +function makeEvent(overrides: Record = {}): Record { + return { + schemaVersion: 1, + eventId: 'event-1', + streamId: 'agent-device:session-1', + deviceRunSessionId: SESSION_ID, + occurredAt: '2026-07-10T10:00:00.000Z', + producer: 'agent-device', + producerVersion: '0.19.1', + type: 'operation.completed', + operationId: 'operation-1', + name: 'tap', + outcome: 'success', + durationMs: 125, + summary: 'Tapped the Continue button', + data: { target: 'Continue' }, + ...overrides, + }; +} + +function makeLogLine(event: Record): string { + return JSON.stringify({ + level: 30, + msg: event.summary, + deviceRunSessionEvent: event, + }); +} + +describe(parseDeviceRunSessionEvents, () => { + it('extracts only valid canonical envelopes for the requested session', () => { + const ndjson = [ + JSON.stringify({ msg: 'ordinary worker log' }), + makeLogLine(makeEvent()), + makeLogLine(makeEvent({ deviceRunSessionId: 'another-session' })), + JSON.stringify({ + deviceRunSessionEvent: makeEvent({ schemaVersion: 0 }), + }), + '{"partial":', + ].join('\n'); + + expect(parseDeviceRunSessionEvents(ndjson, SESSION_ID)).toEqual([ + expect.objectContaining({ + schemaVersion: 1, + eventId: 'event-1', + revision: 0, + producer: 'agent-device', + summary: 'Tapped the Continue button', + }), + ]); + }); + + it('preserves unknown producers, event types, outcomes, and source metadata', () => { + const [event] = parseDeviceRunSessionEvents( + makeLogLine( + makeEvent({ + producer: 'future-runner', + type: 'future.event', + outcome: 'partially-successful', + source: { + eventId: 'native-42', + eventType: 'native.future', + cursor: 42, + }, + }) + ) + ); + + expect(event).toEqual( + expect.objectContaining({ + producer: 'future-runner', + type: 'future.event', + outcome: 'partially-successful', + source: { + eventId: 'native-42', + eventType: 'native.future', + cursor: 42, + }, + }) + ); + }); + + it('preserves canonical fields from future envelope versions', () => { + const [event] = parseDeviceRunSessionEvents( + makeLogLine( + makeEvent({ + schemaVersion: 2, + producer: 'future-runner', + type: 'future.event', + }) + ) + ); + + expect(event).toEqual( + expect.objectContaining({ + schemaVersion: 2, + producer: 'future-runner', + type: 'future.event', + }) + ); + }); + + it('redacts sensitive detail keys at the client boundary', () => { + const [event] = parseDeviceRunSessionEvents( + makeLogLine( + makeEvent({ + data: { + ref: '@e4', + authToken: 'top-secret', + nested: { password: 'hunter2', safe: true }, + }, + }) + ) + ); + + expect(event.data).toEqual({ + ref: '@e4', + authToken: '[REDACTED]', + nested: { password: '[REDACTED]', safe: true }, + }); + }); +}); + +describe(coalesceDeviceRunSessionEvents, () => { + it('keeps the highest revision of a mutable event and sorts chronologically', () => { + const events = [ + ...parseDeviceRunSessionEvents( + makeLogLine( + makeEvent({ + eventId: 'later', + occurredAt: '2026-07-10T10:00:02.000Z', + }) + ) + ), + ...parseDeviceRunSessionEvents( + makeLogLine(makeEvent({ revision: 0, summary: 'Drag started' })) + ), + ...parseDeviceRunSessionEvents( + makeLogLine(makeEvent({ revision: 1, summary: 'Drag completed' })) + ), + ]; + + expect(coalesceDeviceRunSessionEvents(events).map(event => event.summary)).toEqual([ + 'Drag completed', + 'Tapped the Continue button', + ]); + }); +}); + +describe(downloadDeviceRunSessionEventsAsync, () => { + afterEach(() => { + nock.cleanAll(); + }); + + it('downloads all worker log objects and coalesces their events', async () => { + nock('https://logs.example.com') + .get('/one') + .reply(200, makeLogLine(makeEvent({ revision: 0, summary: 'Initial event' }))) + .get('/two') + .reply(200, makeLogLine(makeEvent({ revision: 1, summary: 'Updated event' }))); + + const events = await downloadDeviceRunSessionEventsAsync( + ['https://logs.example.com/one', 'https://logs.example.com/two'], + SESSION_ID + ); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual(expect.objectContaining({ revision: 1, summary: 'Updated event' })); + expect(nock.isDone()).toBe(true); + }); + + it('keeps events from readable log objects when another object is unavailable', async () => { + nock('https://logs.example.com') + .get('/available') + .reply(200, makeLogLine(makeEvent())) + .get('/unavailable') + .reply(503, 'Unavailable'); + + const result = await downloadDeviceRunSessionEventLogAsync( + ['https://logs.example.com/available', 'https://logs.example.com/unavailable'], + SESSION_ID + ); + + expect(result).toEqual({ + events: [expect.objectContaining({ eventId: 'event-1' })], + totalLogFileCount: 2, + failedLogFileCount: 1, + }); + expect(nock.isDone()).toBe(true); + }); + + it('aborts in-flight signed log downloads', async () => { + nock('https://logs.example.com').get('/slow').delay(1_000).reply(200, makeLogLine(makeEvent())); + const abortController = new AbortController(); + + const downloadPromise = downloadDeviceRunSessionEventLogAsync( + ['https://logs.example.com/slow'], + SESSION_ID, + abortController.signal + ); + setImmediate(() => { + abortController.abort(); + }); + + await expect(downloadPromise).rejects.toMatchObject({ name: 'AbortError' }); + }); +}); diff --git a/packages/eas-cli/src/simulator/events.ts b/packages/eas-cli/src/simulator/events.ts new file mode 100644 index 0000000000..3ec06c3ace --- /dev/null +++ b/packages/eas-cli/src/simulator/events.ts @@ -0,0 +1,300 @@ +import fetch from '../fetch'; + +const MAX_LOG_LINE_LENGTH = 1024 * 1024; +const MAX_DATA_DEPTH = 5; +const MAX_DATA_ENTRIES = 100; +const MAX_DATA_STRING_LENGTH = 4_000; +const SENSITIVE_DATA_KEY = + /authorization|cookie|credential|password|private[-_]?key|secret|session[-_]?token|token/i; + +export type DeviceRunSessionEventSource = { + eventId?: string; + eventType?: string; + cursor?: string | number; +}; + +export type DeviceRunSessionEvent = { + schemaVersion: number; + eventId: string; + revision: number; + streamId: string; + deviceRunSessionId: string; + occurredAt: string; + producer: string; + producerVersion?: string; + source?: DeviceRunSessionEventSource; + type: string; + operationId?: string; + name?: string; + outcome?: string; + durationMs?: number; + summary: string; + data?: Record; +}; + +export type DeviceRunSessionEventDownloadResult = { + events: DeviceRunSessionEvent[]; + totalLogFileCount: number; + failedLogFileCount: number; +}; + +export async function downloadDeviceRunSessionEventsAsync( + logFileUrls: string[], + expectedDeviceRunSessionId: string, + signal?: AbortSignal +): Promise { + const result = await downloadDeviceRunSessionEventLogAsync( + logFileUrls, + expectedDeviceRunSessionId, + signal + ); + return result.events; +} + +export async function downloadDeviceRunSessionEventLogAsync( + logFileUrls: string[], + expectedDeviceRunSessionId: string, + signal?: AbortSignal +): Promise { + const uniqueLogFileUrls = [...new Set(logFileUrls)]; + const downloads = await Promise.allSettled( + uniqueLogFileUrls.map(async logFileUrl => { + const response = await fetch(logFileUrl, signal ? { signal } : undefined); + if (!response.ok) { + throw new Error(`Failed to download simulator session activity log (${response.status}).`); + } + return await response.text(); + }) + ); + const logFiles = downloads.flatMap(download => + download.status === 'fulfilled' ? [download.value] : [] + ); + if (uniqueLogFileUrls.length > 0 && logFiles.length === 0) { + const firstFailure = downloads.find( + (download): download is PromiseRejectedResult => download.status === 'rejected' + ); + throw firstFailure?.reason ?? new Error('Failed to download simulator session activity logs.'); + } + + return { + events: coalesceDeviceRunSessionEvents( + logFiles.flatMap(logFile => parseDeviceRunSessionEvents(logFile, expectedDeviceRunSessionId)) + ), + totalLogFileCount: uniqueLogFileUrls.length, + failedLogFileCount: downloads.length - logFiles.length, + }; +} + +export function parseDeviceRunSessionEvents( + ndjson: string, + expectedDeviceRunSessionId?: string +): DeviceRunSessionEvent[] { + const events: DeviceRunSessionEvent[] = []; + + for (const line of ndjson.split(/\r?\n/)) { + if (!line.trim() || line.length > MAX_LOG_LINE_LENGTH) { + continue; + } + + try { + const logRecord: unknown = JSON.parse(line); + if (!isRecord(logRecord)) { + continue; + } + + const event = parseDeviceRunSessionEvent(logRecord.deviceRunSessionEvent); + if ( + event && + (!expectedDeviceRunSessionId || event.deviceRunSessionId === expectedDeviceRunSessionId) + ) { + events.push(event); + } + } catch { + // Worker logs can contain partial lines while their GCS object is being replaced. + } + } + + return events; +} + +export function coalesceDeviceRunSessionEvents( + events: DeviceRunSessionEvent[] +): DeviceRunSessionEvent[] { + const eventByIdentity = new Map(); + + for (const event of events) { + const identity = getDeviceRunSessionEventIdentity(event); + const previous = eventByIdentity.get(identity); + if (!previous || event.revision > previous.revision) { + eventByIdentity.set(identity, event); + } + } + + return [...eventByIdentity.values()].sort(compareDeviceRunSessionEvents); +} + +export function getDeviceRunSessionEventIdentity(event: DeviceRunSessionEvent): string { + return `${event.deviceRunSessionId}:${event.streamId}:${event.eventId}`; +} + +function parseDeviceRunSessionEvent(value: unknown): DeviceRunSessionEvent | null { + if ( + !isRecord(value) || + !isPositiveInteger(value.schemaVersion) || + !isNonEmptyString(value.eventId) || + !isNonEmptyString(value.streamId) || + !isNonEmptyString(value.deviceRunSessionId) || + !isNonEmptyString(value.occurredAt) || + !Number.isFinite(Date.parse(value.occurredAt)) || + !isNonEmptyString(value.producer) || + !isNonEmptyString(value.type) || + !isNonEmptyString(value.summary) + ) { + return null; + } + + const revision = value.revision ?? 0; + if (!isNonNegativeInteger(revision)) { + return null; + } + + const producerVersion = optionalString(value.producerVersion); + const operationId = optionalString(value.operationId); + const name = optionalString(value.name); + const outcome = optionalString(value.outcome); + const durationMs = optionalNonNegativeNumber(value.durationMs); + const source = parseSource(value.source); + const data = isRecord(value.data) ? sanitizeDataObject(value.data, 0) : undefined; + + return { + schemaVersion: value.schemaVersion, + eventId: value.eventId, + revision, + streamId: value.streamId, + deviceRunSessionId: value.deviceRunSessionId, + occurredAt: value.occurredAt, + producer: value.producer, + ...(producerVersion ? { producerVersion } : {}), + ...(source ? { source } : {}), + type: value.type, + ...(operationId ? { operationId } : {}), + ...(name ? { name } : {}), + ...(outcome ? { outcome } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + summary: value.summary, + ...(data ? { data } : {}), + }; +} + +function parseSource(value: unknown): DeviceRunSessionEventSource | undefined { + if (!isRecord(value)) { + return undefined; + } + + const eventId = optionalString(value.eventId); + const eventType = optionalString(value.eventType); + const cursor = + typeof value.cursor === 'string' || + (typeof value.cursor === 'number' && Number.isFinite(value.cursor)) + ? value.cursor + : undefined; + + if (!eventId && !eventType && cursor === undefined) { + return undefined; + } + + return { + ...(eventId ? { eventId } : {}), + ...(eventType ? { eventType } : {}), + ...(cursor !== undefined ? { cursor } : {}), + }; +} + +function compareDeviceRunSessionEvents( + left: DeviceRunSessionEvent, + right: DeviceRunSessionEvent +): number { + const leftTimestamp = Date.parse(left.occurredAt); + const rightTimestamp = Date.parse(right.occurredAt); + if (Number.isFinite(leftTimestamp) && Number.isFinite(rightTimestamp)) { + const timestampDifference = leftTimestamp - rightTimestamp; + if (timestampDifference !== 0) { + return timestampDifference; + } + } else { + const timestampDifference = left.occurredAt.localeCompare(right.occurredAt); + if (timestampDifference !== 0) { + return timestampDifference; + } + } + + return getDeviceRunSessionEventIdentity(left).localeCompare( + getDeviceRunSessionEventIdentity(right) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function isNonNegativeInteger(value: unknown): value is number { + return Number.isInteger(value) && (value as number) >= 0; +} + +function isPositiveInteger(value: unknown): value is number { + return Number.isInteger(value) && (value as number) > 0; +} + +function optionalNonNegativeNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function sanitizeDataObject( + value: Record, + depth: number +): Record { + const sanitized: Record = {}; + for (const [key, item] of Object.entries(value).slice(0, MAX_DATA_ENTRIES)) { + if (SENSITIVE_DATA_KEY.test(key)) { + sanitized[key] = '[REDACTED]'; + continue; + } + const sanitizedItem = sanitizeDataValue(item, depth + 1); + if (sanitizedItem !== undefined) { + sanitized[key] = sanitizedItem; + } + } + return sanitized; +} + +function sanitizeDataValue(value: unknown, depth: number): unknown { + if (value === null || typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined; + } + if (typeof value === 'string') { + return value.length <= MAX_DATA_STRING_LENGTH + ? value + : `${value.slice(0, MAX_DATA_STRING_LENGTH - 1)}…`; + } + if (depth >= MAX_DATA_DEPTH) { + return undefined; + } + if (Array.isArray(value)) { + return value + .slice(0, MAX_DATA_ENTRIES) + .map(item => sanitizeDataValue(item, depth + 1)) + .filter(item => item !== undefined); + } + return isRecord(value) ? sanitizeDataObject(value, depth) : undefined; +}