mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e34c0cb3f6 | ||
|
|
c7f766568c | ||
|
|
a328e59ee4 | ||
|
|
f843a97165 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Count Android web preview input as session activity, so `max_idle_time_minutes` works on Android sessions. `eas/start_serve_sim_remote_session` no longer rejects it on Android; the API does not accept an idle timeout for Android web-preview-only sessions yet.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Mark the default device for each platform in `eas simulator:devices` and include `isDefault` in JSON output.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
A successful remote device run session no longer fails when its agent-device, Appium, or Argent tunnel does not stop within its time-out.
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.9.0",
|
||||
"PRODUCTION": "27.9.0"
|
||||
"STAGING": "27.10.0",
|
||||
"PRODUCTION": "27.10.0"
|
||||
}
|
||||
+30
@@ -201,6 +201,21 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fail a session when only the agent-device tunnel cannot stop', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const tunnelError = new Error('Ngrok tunnel stop timed out after 4000ms.');
|
||||
mockTunnelStopAsync.mockRejectedValueOnce(tunnelError);
|
||||
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.LINUX)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: tunnelError },
|
||||
'Could not stop the agent-device tunnel during remote session teardown.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the warm daemon after adoption and starts no daemon of its own', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const warmServer = { port: 3200 } as never;
|
||||
@@ -918,6 +933,21 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
await expect(session).rejects.toBe(failed);
|
||||
});
|
||||
|
||||
it('reports a startup Stop when only the agent-device tunnel cannot stop', async () => {
|
||||
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
|
||||
let tasks!: StartupTasks;
|
||||
const session = startSession(startup => {
|
||||
tasks = startup;
|
||||
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
|
||||
});
|
||||
session.catch(() => {});
|
||||
await flushAsync();
|
||||
mockTunnelStopAsync.mockRejectedValueOnce(new Error('tunnel stop timed out'));
|
||||
tasks.abort(stopped);
|
||||
await expect(session).rejects.toBe(stopped);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retires startup monitoring after reporting and before lifetime waiting', async () => {
|
||||
const retired = deferred();
|
||||
const retiring = jest.fn(() => retired.promise);
|
||||
|
||||
@@ -850,6 +850,26 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
}
|
||||
|
||||
it('does not fail a session when only the Appium tunnel cannot stop', async () => {
|
||||
const tunnelError = new Error('Ngrok tunnel stop timed out after 4000ms.');
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
stopAppium.mockResolvedValueOnce(undefined);
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValueOnce({
|
||||
url: 'https://appium-abc.tunnel.example.com',
|
||||
subdomainId: 'appium-abc',
|
||||
stopAsync: jest.fn().mockRejectedValue(tunnelError),
|
||||
});
|
||||
|
||||
await expect(runSessionAsync(logger)).resolves.toBeUndefined();
|
||||
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: tunnelError },
|
||||
'Could not stop the Appium tunnel during remote session teardown.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not allocate Appium or a host after parent cancellation during Xcode selection', async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('parent step cancelled');
|
||||
|
||||
+28
-29
@@ -667,7 +667,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
['succeeded', false],
|
||||
['failed', true],
|
||||
])(
|
||||
'fails after stopping the host and tools when the tools tunnel cannot close and the session %s',
|
||||
'stops the host and tools when the tools tunnel cannot close and the session %s',
|
||||
async (_description, sessionFails) => {
|
||||
const closeError = new Error('ngrok close failed');
|
||||
const sessionError = new Error('session failed');
|
||||
@@ -677,23 +677,26 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
}
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
// The tunnel may still be serving, so its failure fails a session that otherwise succeeded.
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(sessionFails ? sessionError : closeError);
|
||||
// A stopped tunnel's proxy drops every connection, so its failure does not fail a session.
|
||||
const running = buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
if (sessionFails) {
|
||||
await expect(running).rejects.toBe(sessionError);
|
||||
} else {
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -702,17 +705,13 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
{ err: closeError },
|
||||
'Could not stop the Argent tunnel during remote session teardown.'
|
||||
);
|
||||
expect(jest.mocked(Sentry.capture).mock.calls).toEqual(
|
||||
sessionFails
|
||||
? [
|
||||
[
|
||||
'Could not stop the Argent tunnel after the remote session failed',
|
||||
closeError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]
|
||||
: []
|
||||
);
|
||||
expect(jest.mocked(Sentry.capture).mock.calls).toEqual([
|
||||
[
|
||||
`Could not stop the Argent tunnel after the remote session ${sessionFails ? 'failed' : 'ended'}`,
|
||||
closeError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+14
-21
@@ -246,17 +246,20 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses preview activity to reset the configured idle timeout', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
const observedAt = new Date();
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'uses preview activity to reset the configured idle timeout on %s',
|
||||
async runtimePlatform => {
|
||||
await runAsync(runtimePlatform, { max_idle_time_minutes: { value: 5 } });
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
const observedAt = new Date();
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'leaves the idle timer disabled when omitted on %s',
|
||||
@@ -277,16 +280,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an Android idle timeout before starting the host', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
max_idle_time_minutes: { value: 5 },
|
||||
})
|
||||
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'handles Stop while the %s host starts',
|
||||
async runtimePlatform => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { type StartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
DeviceRunSessionStoppedError,
|
||||
TOOL_TUNNEL_TEARDOWN_OPTIONS,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
@@ -352,7 +353,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
? [['startup cleanup', Promise.reject(result.reason)]]
|
||||
: []
|
||||
),
|
||||
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
|
||||
['agent-device tunnel', agentDeviceTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['agent-device daemon', toolTeardown],
|
||||
[
|
||||
'session host',
|
||||
|
||||
@@ -52,6 +52,7 @@ import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
TOOL_TUNNEL_TEARDOWN_OPTIONS,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
@@ -416,7 +417,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Appium tunnel', appiumTunnel?.stopAsync()],
|
||||
['Appium tunnel', appiumTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Appium server', appiumTeardown],
|
||||
[
|
||||
'session host',
|
||||
|
||||
@@ -44,6 +44,7 @@ import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../u
|
||||
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
|
||||
import {
|
||||
DeviceRunSessionStoppedError,
|
||||
TOOL_TUNNEL_TEARDOWN_OPTIONS,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
@@ -482,7 +483,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Argent tunnel', toolsTunnel?.stopAsync()],
|
||||
['Argent tunnel', toolsTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Argent tool-server', toolTeardown],
|
||||
[
|
||||
'session host',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -87,16 +86,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
if (
|
||||
maxIdleTimeMinutes !== undefined &&
|
||||
maxIdleTimeMinutes > 0 &&
|
||||
runtimePlatform !== BuildRuntimePlatform.DARWIN
|
||||
) {
|
||||
throw new UserError(
|
||||
'UNSUPPORTED_WEB_PREVIEW_IDLE_TIMEOUT',
|
||||
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
|
||||
);
|
||||
}
|
||||
const startupStop = new AbortController();
|
||||
const watchSignal = signal
|
||||
? AbortSignal.any([signal, startupStop.signal])
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
runServeSimActionWithResultAsync,
|
||||
stageServeSimAppAsync,
|
||||
} from '../serveSimActions';
|
||||
import { startServeSimActivityPolling } from '../serveSimActivity';
|
||||
import { startPreviewActivityPolling } from '../previewActivity';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import * as previewCapture from '../serveSimPreviewCapture';
|
||||
|
||||
@@ -59,7 +59,7 @@ jest.mock('../serveSimActions');
|
||||
jest.mock('../iosAppArtifact', () => ({
|
||||
readIosApplicationIdentifierAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../serveSimActivity');
|
||||
jest.mock('../previewActivity');
|
||||
jest.mock('../localEgress', () => ({
|
||||
...jest.requireActual('../localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null),
|
||||
@@ -644,7 +644,7 @@ it('starts the readiness budget after refusals that exceed the normal startup de
|
||||
it('reports preview activity from serve-sim on iOS and stops polling when finished', async () => {
|
||||
const observedAt = new Date('2026-10-19T12:00:00Z');
|
||||
const stopActivity = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(startServeSimActivityPolling).mockReturnValue({
|
||||
jest.mocked(startPreviewActivityPolling).mockReturnValue({
|
||||
getLastActivityObservedAt: () => observedAt,
|
||||
stopAsync: stopActivity,
|
||||
});
|
||||
@@ -661,7 +661,8 @@ it('reports preview activity from serve-sim on iOS and stops polling when finish
|
||||
]
|
||||
);
|
||||
|
||||
expect(startServeSimActivityPolling).toHaveBeenCalledWith({
|
||||
expect(startPreviewActivityPolling).toHaveBeenCalledWith({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token: 'preview-token',
|
||||
logger: expect.anything(),
|
||||
@@ -688,15 +689,33 @@ it('does not start polling preview activity when startup is cancelled', async ()
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
|
||||
expect(startPreviewActivityPolling).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports no preview activity on Android, where expo-device-hub keeps no event log', async () => {
|
||||
it('reports preview activity from expo-device-hub on Android and stops polling when finished', async () => {
|
||||
const observedAt = new Date('2026-11-13T12:00:00Z');
|
||||
const stopActivity = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(startPreviewActivityPolling).mockReturnValue({
|
||||
getLastActivityObservedAt: () => observedAt,
|
||||
stopAsync: stopActivity,
|
||||
});
|
||||
const host = await startHostAsync();
|
||||
const port = Number(
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].args[
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].args.indexOf('--port') + 1
|
||||
]
|
||||
);
|
||||
|
||||
expect(startPreviewActivityPolling).toHaveBeenCalledWith({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token: expect.any(String),
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(host.getLastPreviewActivityAt()).toBe(observedAt);
|
||||
|
||||
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
|
||||
expect(host.getLastPreviewActivityAt()).toBeUndefined();
|
||||
await host.finishAsync();
|
||||
expect(stopActivity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops a host that became ready on a different Simulator', async () => {
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
|
||||
import {
|
||||
readExpoDeviceHubInputLogAsync,
|
||||
readServeSimInputLogAsync,
|
||||
startPreviewActivityPolling,
|
||||
} from '../previewActivity';
|
||||
|
||||
const token = 'test-session-token';
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let events: { id: number; at?: string; timestamp?: string }[];
|
||||
let status: number;
|
||||
let requests: { url?: string; authorization?: string }[];
|
||||
let rawBody: string | undefined;
|
||||
let replaying: boolean;
|
||||
let replayStartedAt: string | null;
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
events = [];
|
||||
status = 200;
|
||||
requests = [];
|
||||
rawBody = undefined;
|
||||
replaying = false;
|
||||
replayStartedAt = null;
|
||||
server = http.createServer((req, res) => {
|
||||
requests.push({ url: req.url, authorization: req.headers.authorization });
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(rawBody ?? JSON.stringify({ events: events.slice(-1), replaying, replayStartedAt }));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe(readServeSimInputLogAsync, () => {
|
||||
it('reads the newest event ID with the session token', async () => {
|
||||
events = [{ id: 3 }, { id: 7 }];
|
||||
await expect(readServeSimInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: 7,
|
||||
latestEventAt: undefined,
|
||||
replaying: false,
|
||||
replayStartedAt: undefined,
|
||||
});
|
||||
expect(requests).toEqual([{ url: '/api/event-log?limit=1', authorization: `Bearer ${token}` }]);
|
||||
});
|
||||
|
||||
it('returns undefined when serve-sim has logged no event', async () => {
|
||||
await expect(readServeSimInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: undefined,
|
||||
latestEventAt: undefined,
|
||||
replaying: false,
|
||||
replayStartedAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', 'not json'],
|
||||
['a body without an events array', '{}'],
|
||||
['an entry without a numeric ID', '{"events":[{"id":"7"}]}'],
|
||||
])('rejects %s', async (_, body) => {
|
||||
rawBody = body;
|
||||
await expect(readServeSimInputLogAsync({ port, token })).rejects.toThrow('serve-sim event log');
|
||||
});
|
||||
|
||||
it('rejects a refused request', async () => {
|
||||
status = 401;
|
||||
await expect(readServeSimInputLogAsync({ port, token })).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
|
||||
describe(readExpoDeviceHubInputLogAsync, () => {
|
||||
it('reads the newest entry time from either server', async () => {
|
||||
events = [{ id: 2, timestamp: '2026-11-13T12:00:02.000Z' }];
|
||||
await expect(readServeSimInputLogAsync({ port, token })).resolves.toMatchObject({
|
||||
latestEventId: 2,
|
||||
latestEventAt: '2026-11-13T12:00:02.000Z',
|
||||
});
|
||||
events = [{ id: 3, at: '2026-11-13T12:00:03.000Z' }];
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toMatchObject({
|
||||
latestEventId: 3,
|
||||
latestEventAt: '2026-11-13T12:00:03.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the newest recorded Android input through the hub with the session token', async () => {
|
||||
events = [{ id: 3 }, { id: 9 }];
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: 9,
|
||||
latestEventAt: undefined,
|
||||
replaying: false,
|
||||
replayStartedAt: undefined,
|
||||
});
|
||||
expect(requests).toEqual([
|
||||
{ url: '/vendor/serve-emu/api/session?limit=1', authorization: `Bearer ${token}` },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a running replay and when it started', async () => {
|
||||
replaying = true;
|
||||
replayStartedAt = '2026-11-13T12:00:00.000Z';
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: undefined,
|
||||
latestEventAt: undefined,
|
||||
replaying: true,
|
||||
replayStartedAt: '2026-11-13T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads replay state from the paged response shape', async () => {
|
||||
rawBody = JSON.stringify({
|
||||
events: [{ id: 4, at: '2026-11-13T12:00:01.000Z' }],
|
||||
session: { replaying: true, replayStartedAt: '2026-11-13T12:00:00.000Z' },
|
||||
});
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: 4,
|
||||
latestEventAt: '2026-11-13T12:00:01.000Z',
|
||||
replaying: true,
|
||||
replayStartedAt: '2026-11-13T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no event ID when the hub has recorded no input', async () => {
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
|
||||
latestEventId: undefined,
|
||||
latestEventAt: undefined,
|
||||
replaying: false,
|
||||
replayStartedAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', 'not json'],
|
||||
['a body without an events array', '{}'],
|
||||
['an entry without a numeric ID', '{"events":[{"id":"9"}]}'],
|
||||
])('rejects %s', async (_, body) => {
|
||||
rawBody = body;
|
||||
await expect(readExpoDeviceHubInputLogAsync({ port, token })).rejects.toThrow(
|
||||
'expo-device-hub input log'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startPreviewActivityPolling, () => {
|
||||
async function waitForRequestsAsync(count: number): Promise<void> {
|
||||
while (requests.length < count) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
it('reports activity only when a new event appears', async () => {
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
|
||||
events = [{ id: 1 }];
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
|
||||
|
||||
events = [{ id: 1 }, { id: 2 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while the event log fails, and warns once', async () => {
|
||||
status = 500;
|
||||
const logger = createLoggerMock();
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
events = [{ id: 4 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the last observation while serve-sim refuses connections', async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
server = http.createServer();
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity when serve-sim drops the connection mid-response', async () => {
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
requests.push({ url: req.url });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '100' });
|
||||
res.write('{"events":');
|
||||
setImmediate(() => res.socket?.destroy());
|
||||
});
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while serve-sim answers without an event log', async () => {
|
||||
status = 404;
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports Android preview input from expo-device-hub', async () => {
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
expect(requests[0].url).toBe('/vendor/serve-emu/api/session?limit=1');
|
||||
|
||||
events = [{ id: 1 }];
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports activity while expo-device-hub replays recorded input', async () => {
|
||||
replaying = true;
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a replay that started and finished between two reads', async () => {
|
||||
events = [{ id: 1 }];
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
|
||||
replayStartedAt = '2026-11-13T12:00:00.000Z';
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const replayObservedAt = polling.getLastActivityObservedAt();
|
||||
expect(replayObservedAt).not.toBe(observedAt);
|
||||
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(replayObservedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('counts input in a new recording that repeats the last ID between two reads', async () => {
|
||||
events = [{ id: 1, at: '2026-11-13T12:00:00.000Z' }];
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen).toBeDefined();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
|
||||
|
||||
events = [{ id: 1, at: '2026-11-13T12:05:00.000Z' }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('counts input in a new recording whose IDs start again', async () => {
|
||||
events = [{ id: 1 }];
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen).toBeDefined();
|
||||
|
||||
events = [];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
events = [{ id: 1 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['before its headers', 'headers'],
|
||||
['after its headers', 'body'],
|
||||
] as const)(
|
||||
'reports current activity while an expo-device-hub read times out %s',
|
||||
async (_, stall) => {
|
||||
events = [{ id: 1 }];
|
||||
let stalled = false;
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
requests.push({ url: req.url });
|
||||
if (stalled && stall === 'headers') {
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
if (stalled) {
|
||||
res.write('{"events":[');
|
||||
return;
|
||||
}
|
||||
res.end(JSON.stringify({ events, replaying: false, replayStartedAt: null }));
|
||||
});
|
||||
const logger = createLoggerMock();
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs: 10,
|
||||
requestTimeoutMs: 100,
|
||||
});
|
||||
try {
|
||||
while (polling.getLastActivityObservedAt() === undefined) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
|
||||
stalled = true;
|
||||
// The next request starts only after the stalled read times out.
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const reportedAt = polling.getLastActivityObservedAt();
|
||||
expect(reportedAt).not.toBe(observedAt);
|
||||
expect(jest.mocked(logger.warn).mock.calls[0][1]).toContain(
|
||||
'idle timeout does not stop this session'
|
||||
);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
server.closeAllConnections();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('warns about an unreadable log after an earlier device outage', async () => {
|
||||
const logger = createLoggerMock();
|
||||
status = 503;
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
status = 500;
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(logger.warn).mock.calls[1][1]).toContain(
|
||||
'idle timeout does not stop this session'
|
||||
);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the last observation while expo-device-hub has no device', async () => {
|
||||
events = [{ id: 1 }];
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'expo-device-hub',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
expect(observedAt).toBeDefined();
|
||||
|
||||
status = 503;
|
||||
await waitForRequestsAsync(requests.length + 3);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity when serve-sim answers 503', async () => {
|
||||
status = 503;
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops polling after stopAsync', async () => {
|
||||
const polling = startPreviewActivityPolling({
|
||||
server: 'serve-sim',
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
await waitForRequestsAsync(1);
|
||||
await polling.stopAsync();
|
||||
const count = requests.length;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(requests.length).toBe(count);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { sleepAsync } from '../../../utils/retry';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
import {
|
||||
TOOL_TUNNEL_TEARDOWN_OPTIONS,
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
finishRemoteSessionAsync,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
@@ -226,6 +228,89 @@ function createEnvMock(): BuildStepEnv {
|
||||
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
}
|
||||
|
||||
describe(finishRemoteSessionAsync, () => {
|
||||
const tunnelError = new Error('tunnel stop timed out');
|
||||
const serverError = new Error('server stop failed');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(Sentry).capture.mockReset();
|
||||
});
|
||||
|
||||
it('does not fail a successful session when only a tunnel cannot stop', async () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
await expect(
|
||||
finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed: false,
|
||||
teardown: [
|
||||
['Appium tunnel', Promise.reject(tunnelError), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Appium server', Promise.resolve()],
|
||||
],
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: tunnelError },
|
||||
'Could not stop the Appium tunnel during remote session teardown.'
|
||||
);
|
||||
expect(jest.mocked(Sentry).capture.mock.calls).toEqual([
|
||||
[
|
||||
'Could not stop the Appium tunnel after the remote session ended',
|
||||
tunnelError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails a successful session with the first failure that fails the session', async () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
await expect(
|
||||
finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed: false,
|
||||
teardown: [
|
||||
['Appium tunnel', Promise.reject(tunnelError), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Appium server', Promise.reject(serverError)],
|
||||
],
|
||||
})
|
||||
).rejects.toBe(serverError);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(Sentry).capture.mock.calls).toEqual([
|
||||
[
|
||||
'Could not stop the Appium tunnel after the remote session ended',
|
||||
tunnelError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports every teardown failure to Sentry when the session failed', async () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
await expect(
|
||||
finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed: true,
|
||||
teardown: [
|
||||
['Appium tunnel', Promise.reject(tunnelError), TOOL_TUNNEL_TEARDOWN_OPTIONS],
|
||||
['Appium server', Promise.reject(serverError)],
|
||||
['session host', undefined],
|
||||
],
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
expect(jest.mocked(Sentry).capture.mock.calls).toEqual([
|
||||
[
|
||||
'Could not stop the Appium tunnel after the remote session failed',
|
||||
tunnelError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
[
|
||||
'Could not stop the Appium server after the remote session failed',
|
||||
serverError,
|
||||
{ level: 'warning' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createIosSessionStartupInputProviders, () => {
|
||||
it('declares optional inputs so existing workflows do not opt into owned startup', () => {
|
||||
const inputs = createIosSessionStartupInputProviders().map(provider =>
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
|
||||
import { fetchLatestServeSimEventIdAsync, startServeSimActivityPolling } from '../serveSimActivity';
|
||||
|
||||
const token = 'test-session-token';
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let events: { id: number }[];
|
||||
let status: number;
|
||||
let requests: { url?: string; authorization?: string }[];
|
||||
let rawBody: string | undefined;
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
events = [];
|
||||
status = 200;
|
||||
requests = [];
|
||||
rawBody = undefined;
|
||||
server = http.createServer((req, res) => {
|
||||
requests.push({ url: req.url, authorization: req.headers.authorization });
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(rawBody ?? JSON.stringify({ events: events.slice(-1) }));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe(fetchLatestServeSimEventIdAsync, () => {
|
||||
it('reads the newest event ID with the session token', async () => {
|
||||
events = [{ id: 3 }, { id: 7 }];
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBe(7);
|
||||
expect(requests).toEqual([{ url: '/api/event-log?limit=1', authorization: `Bearer ${token}` }]);
|
||||
});
|
||||
|
||||
it('returns undefined when serve-sim has logged no event', async () => {
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', 'not json'],
|
||||
['a body without an events array', '{}'],
|
||||
['an entry without a numeric ID', '{"events":[{"id":"7"}]}'],
|
||||
])('rejects %s', async (_, body) => {
|
||||
rawBody = body;
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow(
|
||||
'serve-sim event log'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a refused request', async () => {
|
||||
status = 401;
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
|
||||
describe(startServeSimActivityPolling, () => {
|
||||
async function waitForRequestsAsync(count: number): Promise<void> {
|
||||
while (requests.length < count) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
it('reports activity only when a new event appears', async () => {
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
|
||||
events = [{ id: 1 }];
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
|
||||
|
||||
events = [{ id: 1 }, { id: 2 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while the event log fails, and warns once', async () => {
|
||||
status = 500;
|
||||
const logger = createLoggerMock();
|
||||
const polling = startServeSimActivityPolling({ port, token, logger, pollIntervalMs: 10 });
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
events = [{ id: 4 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the last observation while serve-sim refuses connections', async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
server = http.createServer();
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity when serve-sim drops the connection mid-response', async () => {
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
requests.push({ url: req.url });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '100' });
|
||||
res.write('{"events":');
|
||||
setImmediate(() => res.socket?.destroy());
|
||||
});
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while serve-sim answers without an event log', async () => {
|
||||
status = 404;
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops polling after stopAsync', async () => {
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
await waitForRequestsAsync(1);
|
||||
await polling.stopAsync();
|
||||
const count = requests.length;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(requests.length).toBe(count);
|
||||
});
|
||||
});
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
readIosApplicationIdentifierAsync,
|
||||
} from './iosAppArtifact';
|
||||
import { createServeSimPreviewCapture } from './serveSimPreviewCapture';
|
||||
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
|
||||
import { type PreviewActivityPolling, startPreviewActivityPolling } from './previewActivity';
|
||||
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
|
||||
import { type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { createColdIosSessionAsync } from './coldIosSession';
|
||||
@@ -376,7 +376,7 @@ export type DeviceWebPreview = {
|
||||
export type DeviceSessionHost = {
|
||||
/** The preview tunnel opened during startup, or a new one after closeAsync. */
|
||||
openPreviewAsync(): Promise<DeviceWebPreview>;
|
||||
/** Last observed preview input. Always `undefined` on Android, which keeps no event log. */
|
||||
/** Last observed preview input from the serve-sim (iOS) or expo-device-hub (Android) input log. */
|
||||
getLastPreviewActivityAt(): Date | undefined;
|
||||
/**
|
||||
* Terminal and idempotent. Finalization, host-stop and upload failures are logged and reported.
|
||||
@@ -634,7 +634,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
const previewSetupController = new AbortController();
|
||||
let previewSetupTask: Promise<void> | undefined;
|
||||
let previewFailed = false;
|
||||
let previewActivity: ServeSimActivityPolling | null = null;
|
||||
let previewActivity: PreviewActivityPolling | null = null;
|
||||
|
||||
function openPreviewAsync(): Promise<DeviceWebPreview> {
|
||||
if (finishTask) {
|
||||
@@ -1025,9 +1025,14 @@ async function startDeviceSessionHostInternalAsync(
|
||||
secrets.push(previewToken);
|
||||
if (!isAndroid) {
|
||||
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
|
||||
if (!finishTask) {
|
||||
previewActivity = startServeSimActivityPolling({ port, token: previewToken, logger });
|
||||
}
|
||||
}
|
||||
if (!finishTask) {
|
||||
previewActivity = startPreviewActivityPolling({
|
||||
server: isAndroid ? 'expo-device-hub' : 'serve-sim',
|
||||
port,
|
||||
token: previewToken,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
|
||||
// readiness on macOS, so it starts after its encoder is installed there.
|
||||
|
||||
@@ -25,6 +25,10 @@ const RETRYABLE_NGROK_ERROR_CODES = new Set(['ERR_NGROK_802']);
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
/**
|
||||
* Stops the tunnel. The local proxy drops every connection at once; then the listeners close.
|
||||
* Rejects when the stop does not complete within the stop time-out.
|
||||
*/
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -61,6 +65,8 @@ export async function startNgrokTunnelAsync({
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const sockets = new Set<Socket>();
|
||||
// After an abort, this proxy drops every connection, even while an ngrok listener is still open.
|
||||
// Session teardown relies on this (TOOL_TUNNEL_TEARDOWN_OPTIONS), so keep it when changing this.
|
||||
const target = createServer({ allowHalfOpen: true }, client => {
|
||||
if (signal.aborted) {
|
||||
client.destroy();
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const REQUEST_TIMEOUT_MS = 5_000;
|
||||
// expo-device-hub serves serve-emu, which records Android preview input, under this prefix.
|
||||
const EXPO_DEVICE_HUB_SERVE_EMU_PREFIX = '/vendor/serve-emu';
|
||||
|
||||
/** The preview server whose input log counts as session activity. */
|
||||
export type PreviewActivityServer = 'serve-sim' | 'expo-device-hub';
|
||||
|
||||
export type PreviewActivityPolling = {
|
||||
/**
|
||||
* When a new input log entry (input or host action, not stream traffic) was first seen. Reports
|
||||
* now while a read fails for a reason other than a refused connection or a device-unavailable
|
||||
* answer, so an unobservable session stays active.
|
||||
*/
|
||||
getLastActivityObservedAt: () => Date | undefined;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** What one read of the server's input log shows. */
|
||||
export type PreviewInputLogState = {
|
||||
/** The newest entry's ID, or undefined when the log is empty. */
|
||||
latestEventId: number | undefined;
|
||||
/**
|
||||
* When the server logged the newest entry. A new recording starts its IDs again at 1, so the ID
|
||||
* alone can repeat for new input.
|
||||
*/
|
||||
latestEventAt: string | undefined;
|
||||
/** The server is replaying recorded input, which it does not log again. */
|
||||
replaying: boolean;
|
||||
/**
|
||||
* When the latest replay started, kept after it ends, so a replay that runs entirely between two
|
||||
* reads still shows up as a change.
|
||||
*/
|
||||
replayStartedAt: string | undefined;
|
||||
};
|
||||
|
||||
type ReadInputLogAsync = (options: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<PreviewInputLogState>;
|
||||
|
||||
const READ_INPUT_LOG: Record<PreviewActivityServer, ReadInputLogAsync> = {
|
||||
'serve-sim': readServeSimInputLogAsync,
|
||||
'expo-device-hub': readExpoDeviceHubInputLogAsync,
|
||||
};
|
||||
|
||||
/**
|
||||
* The server is up but has no device to take input, such as an offline emulator. Like a down
|
||||
* server, it receives no input, so the last observation stands.
|
||||
*/
|
||||
class PreviewDeviceUnavailableError extends Error {}
|
||||
|
||||
/** Nothing listens on the port. A reset or dropped response can come from a running server. */
|
||||
function isServerDownError(err: unknown): boolean {
|
||||
if (err instanceof PreviewDeviceUnavailableError) {
|
||||
return true;
|
||||
}
|
||||
// Not `instanceof`: undici errors can come from another realm.
|
||||
let error: unknown = err;
|
||||
for (let depth = 0; depth < 5 && typeof error === 'object' && error !== null; depth++) {
|
||||
const { code, cause } = error as { code?: unknown; cause?: unknown };
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return true;
|
||||
}
|
||||
error = cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A changed newest event ID, a new replay, or a running replay counts as activity, timed by the
|
||||
* local clock.
|
||||
*/
|
||||
export function startPreviewActivityPolling({
|
||||
server,
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
||||
}: {
|
||||
server: PreviewActivityServer;
|
||||
port: number;
|
||||
token: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
}): PreviewActivityPolling {
|
||||
const readInputLogAsync = READ_INPUT_LOG[server];
|
||||
const controller = new AbortController();
|
||||
let lastEventKey: string | undefined;
|
||||
let lastReplayStartedAt: string | undefined;
|
||||
let lastActivityObservedAt: Date | undefined;
|
||||
let eventLogUnavailable = false;
|
||||
let didWarnUnavailable = false;
|
||||
let didWarnDown = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
|
||||
const pollAsync = async (): Promise<void> => {
|
||||
try {
|
||||
const { latestEventId, latestEventAt, replaying, replayStartedAt } = await readInputLogAsync({
|
||||
port,
|
||||
token,
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(requestTimeoutMs)]),
|
||||
});
|
||||
eventLogUnavailable = false;
|
||||
const eventKey =
|
||||
latestEventId === undefined ? undefined : `${latestEventId}@${latestEventAt ?? ''}`;
|
||||
if (eventKey !== undefined && eventKey !== lastEventKey) {
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
lastEventKey = eventKey;
|
||||
if (replayStartedAt !== undefined && replayStartedAt !== lastReplayStartedAt) {
|
||||
lastReplayStartedAt = replayStartedAt;
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
if (replaying) {
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
// Only a down server or device, which receives no input either, keeps the last observation.
|
||||
// A timeout proves neither: expo-device-hub holds the read while it changes the capture
|
||||
// source, and the open preview keeps taking input meanwhile.
|
||||
eventLogUnavailable = !isServerDownError(err);
|
||||
if (eventLogUnavailable && !didWarnUnavailable) {
|
||||
didWarnUnavailable = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Could not read the ${server} input log. Preview input cannot be observed, so the ` +
|
||||
'idle timeout does not stop this session while the log cannot be read.'
|
||||
);
|
||||
} else if (!eventLogUnavailable && !didWarnDown) {
|
||||
didWarnDown = true;
|
||||
logger.warn({ err }, `Could not read ${server} preview activity.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
const tick = (): void => {
|
||||
pending = pollAsync().finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
timer = setTimeout(tick, pollIntervalMs);
|
||||
timer.unref();
|
||||
}
|
||||
});
|
||||
};
|
||||
tick();
|
||||
return {
|
||||
getLastActivityObservedAt: () => (eventLogUnavailable ? new Date() : lastActivityObservedAt),
|
||||
stopAsync: async () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
await pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readServeSimInputLogAsync({
|
||||
port,
|
||||
token,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<PreviewInputLogState> {
|
||||
return await readInputLogAsync({
|
||||
url: `http://127.0.0.1:${port}/api/event-log?limit=1`,
|
||||
token,
|
||||
signal,
|
||||
logName: 'serve-sim event log',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads serve-emu's session recording through expo-device-hub. It records browser preview and REST
|
||||
* input: touches, keys, text, navigation buttons, and location. It does not record input sent with
|
||||
* `record: false` (the preview's Reload button) or actions such as rotation and screenshots, so
|
||||
* those alone do not keep a session active. Controller commands count through the session's own
|
||||
* event stream. The hub scopes the request to its single device.
|
||||
*/
|
||||
export async function readExpoDeviceHubInputLogAsync({
|
||||
port,
|
||||
token,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<PreviewInputLogState> {
|
||||
return await readInputLogAsync({
|
||||
// The hub returns the whole recording and ignores `limit`; serve-emu's paged route honors it.
|
||||
url: `http://127.0.0.1:${port}${EXPO_DEVICE_HUB_SERVE_EMU_PREFIX}/api/session?limit=1`,
|
||||
token,
|
||||
signal,
|
||||
logName: 'expo-device-hub input log',
|
||||
// The hub answers 503 while it cannot reach the emulator.
|
||||
deviceUnavailableStatus: 503,
|
||||
});
|
||||
}
|
||||
|
||||
async function readInputLogAsync({
|
||||
url,
|
||||
token,
|
||||
signal,
|
||||
logName,
|
||||
deviceUnavailableStatus,
|
||||
}: {
|
||||
url: string;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
logName: string;
|
||||
deviceUnavailableStatus?: number;
|
||||
}): Promise<PreviewInputLogState> {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
const message = `${logName} request failed: ${response.status}`;
|
||||
throw response.status === deviceUnavailableStatus
|
||||
? new PreviewDeviceUnavailableError(message)
|
||||
: new Error(message);
|
||||
}
|
||||
const text = await response.text();
|
||||
type ReplayState = { replaying?: unknown; replayStartedAt?: unknown };
|
||||
// The hub route returns the recorder snapshot; serve-emu's paged route nests it under `session`.
|
||||
let body: ({ events?: unknown; session?: ReplayState } & ReplayState) | undefined;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {}
|
||||
if (!Array.isArray(body?.events)) {
|
||||
throw new Error(`${logName} response has no events array.`);
|
||||
}
|
||||
const replayState: ReplayState = body.session ?? body;
|
||||
const replaying = replayState.replaying === true;
|
||||
const replayStartedAt =
|
||||
typeof replayState.replayStartedAt === 'string' ? replayState.replayStartedAt : undefined;
|
||||
if (body.events.length === 0) {
|
||||
return { latestEventId: undefined, latestEventAt: undefined, replaying, replayStartedAt };
|
||||
}
|
||||
const latest = body.events.at(-1) as { id?: unknown; at?: unknown; timestamp?: unknown } | null;
|
||||
if (typeof latest?.id !== 'number') {
|
||||
throw new Error(`${logName} entry has no numeric ID.`);
|
||||
}
|
||||
// serve-emu names the time `at`; serve-sim names it `timestamp`.
|
||||
const at = latest.at ?? latest.timestamp;
|
||||
return {
|
||||
latestEventId: latest.id,
|
||||
latestEventAt: typeof at === 'string' ? at : undefined,
|
||||
replaying,
|
||||
replayStartedAt,
|
||||
};
|
||||
}
|
||||
@@ -649,34 +649,66 @@ export async function stopDetachedProcessAsync(
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every named teardown to completion and logs each failure. The first failure is rethrown
|
||||
* only when the session body succeeded, so a teardown error cannot replace the error that ended it.
|
||||
* Teardown options for the stop of a tool tunnel (agent-device, Appium, or Argent) at the end of a
|
||||
* session. The stop first makes the tunnel's local proxy drop every connection (see
|
||||
* `startNgrokTunnelAsync`), so a listener that does not close in time cannot reach the session. The
|
||||
* worker process exits after the job, and this closes the process's ngrok sessions. The warm-host
|
||||
* preview tunnel does not use these options: its retirement still fails the session.
|
||||
*/
|
||||
export const TOOL_TUNNEL_TEARDOWN_OPTIONS = { failsSession: false } as const;
|
||||
|
||||
/** A named task that `finishRemoteSessionAsync` waits for when a remote session ends. */
|
||||
export type RemoteSessionTeardown = [
|
||||
name: string,
|
||||
task: Promise<unknown> | undefined,
|
||||
options?: {
|
||||
/**
|
||||
* False when a failure of this task cannot affect the user or a later session, so it is
|
||||
* logged and reported to Sentry but does not fail a session that succeeded. Defaults to true.
|
||||
*/
|
||||
failsSession?: boolean;
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Runs every named teardown to completion and logs each failure. The first failure of a task that
|
||||
* fails the session is rethrown only when the session body succeeded, so a teardown error cannot
|
||||
* replace the error that ended it.
|
||||
*/
|
||||
export async function finishRemoteSessionAsync({
|
||||
teardown,
|
||||
sessionFailed,
|
||||
logger,
|
||||
}: {
|
||||
teardown: [name: string, task: Promise<unknown> | undefined][];
|
||||
teardown: RemoteSessionTeardown[];
|
||||
sessionFailed: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const results = await Promise.allSettled(teardown.map(([, task]) => task));
|
||||
const failures = results.flatMap((result, index) =>
|
||||
result.status === 'rejected' ? [{ name: teardown[index][0], err: result.reason }] : []
|
||||
);
|
||||
for (const { name, err } of failures) {
|
||||
const failures = results.flatMap((result, index) => {
|
||||
if (result.status !== 'rejected') {
|
||||
return [];
|
||||
}
|
||||
const [name, , options] = teardown[index];
|
||||
return [{ name, err: result.reason, failsSession: options?.failsSession ?? true }];
|
||||
});
|
||||
for (const { name, err, failsSession } of failures) {
|
||||
logger.warn({ err }, `Could not stop the ${name} during remote session teardown.`);
|
||||
if (sessionFailed) {
|
||||
// The session error is what the step reports, so a swallowed teardown failure goes to Sentry.
|
||||
if (sessionFailed || !failsSession) {
|
||||
// The step does not report this failure, so it goes to Sentry.
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture(`Could not stop the ${name} after the remote session failed`, error, {
|
||||
level: 'warning',
|
||||
});
|
||||
Sentry.capture(
|
||||
sessionFailed
|
||||
? `Could not stop the ${name} after the remote session failed`
|
||||
: `Could not stop the ${name} after the remote session ended`,
|
||||
error,
|
||||
{ level: 'warning' }
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!sessionFailed && failures.length > 0) {
|
||||
throw failures[0].err;
|
||||
const sessionFailure = failures.find(({ failsSession }) => failsSession);
|
||||
if (!sessionFailed && sessionFailure) {
|
||||
throw sessionFailure.err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type ServeSimActivityPolling = {
|
||||
/**
|
||||
* When a new event log entry (input or host action, not stream traffic) was first seen. Reports
|
||||
* now while the log is unreadable but serve-sim is up, so an unobservable session stays active.
|
||||
*/
|
||||
getLastActivityObservedAt: () => Date | undefined;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Nothing listens on the port. A reset or dropped response can come from a running serve-sim. */
|
||||
function isServeSimDownError(err: unknown): boolean {
|
||||
// Not `instanceof`: undici errors can come from another realm.
|
||||
let error: unknown = err;
|
||||
for (let depth = 0; depth < 5 && typeof error === 'object' && error !== null; depth++) {
|
||||
const { code, cause } = error as { code?: unknown; cause?: unknown };
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return true;
|
||||
}
|
||||
error = cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A changed newest event ID counts as activity, timed by the local clock. */
|
||||
export function startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): ServeSimActivityPolling {
|
||||
const controller = new AbortController();
|
||||
let lastEventId: number | undefined;
|
||||
let lastActivityObservedAt: Date | undefined;
|
||||
let eventLogUnavailable = false;
|
||||
let didWarn = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
|
||||
const pollAsync = async (): Promise<void> => {
|
||||
try {
|
||||
const eventId = await fetchLatestServeSimEventIdAsync({
|
||||
port,
|
||||
token,
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
||||
});
|
||||
eventLogUnavailable = false;
|
||||
if (eventId !== undefined && eventId !== lastEventId) {
|
||||
lastEventId = eventId;
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
// Only a down serve-sim, which receives no input either, keeps the last observation.
|
||||
eventLogUnavailable = !isServeSimDownError(err);
|
||||
if (!didWarn) {
|
||||
didWarn = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
eventLogUnavailable
|
||||
? 'Could not read the serve-sim event log. Preview input cannot be observed, so the ' +
|
||||
'idle timeout does not stop this session.'
|
||||
: 'Could not read serve-sim preview activity.'
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const tick = (): void => {
|
||||
pending = pollAsync().finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
timer = setTimeout(tick, pollIntervalMs);
|
||||
timer.unref();
|
||||
}
|
||||
});
|
||||
};
|
||||
tick();
|
||||
return {
|
||||
getLastActivityObservedAt: () => (eventLogUnavailable ? new Date() : lastActivityObservedAt),
|
||||
stopAsync: async () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
await pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLatestServeSimEventIdAsync({
|
||||
port,
|
||||
token,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<number | undefined> {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/event-log?limit=1`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`serve-sim event log request failed: ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
let body: { events?: unknown } | undefined;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {}
|
||||
if (!Array.isArray(body?.events)) {
|
||||
throw new Error('serve-sim event log response has no events array.');
|
||||
}
|
||||
if (body.events.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const id = (body.events.at(-1) as { id?: unknown } | null)?.id;
|
||||
if (typeof id !== 'number') {
|
||||
throw new Error('serve-sim event log entry has no numeric ID.');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
Generated
+32
@@ -9145,6 +9145,22 @@
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "isDefault",
|
||||
"description": "Whether a session starts this device when it requests no android.deviceIdentifier.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -63742,6 +63758,22 @@
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "isDefault",
|
||||
"description": "Whether a session starts this device when it requests no ios.deviceIdentifier.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
|
||||
@@ -40,11 +40,13 @@ const profiles: DeviceRunSessionProfilesQuery['deviceRunSessions']['profiles'] =
|
||||
__typename: 'IosDeviceRunSessionDevice',
|
||||
identifier: 'iPhone 17',
|
||||
displayName: 'iPhone 17',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
__typename: 'IosDeviceRunSessionDevice',
|
||||
identifier: 'iPhone 17 Pro',
|
||||
displayName: 'iPhone 17 Pro',
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -55,23 +57,25 @@ const profiles: DeviceRunSessionProfilesQuery['deviceRunSessions']['profiles'] =
|
||||
__typename: 'AndroidDeviceRunSessionDevice',
|
||||
identifier: 'medium_phone',
|
||||
displayName: 'Medium Phone',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
__typename: 'AndroidDeviceRunSessionDevice',
|
||||
identifier: 'pixel_9',
|
||||
displayName: 'Pixel 9',
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const iosDevices = [
|
||||
{ identifier: 'iPhone 17', displayName: 'iPhone 17' },
|
||||
{ identifier: 'iPhone 17 Pro', displayName: 'iPhone 17 Pro' },
|
||||
{ identifier: 'iPhone 17', displayName: 'iPhone 17', isDefault: true },
|
||||
{ identifier: 'iPhone 17 Pro', displayName: 'iPhone 17 Pro', isDefault: false },
|
||||
];
|
||||
const androidDevices = [
|
||||
{ identifier: 'medium_phone', displayName: 'Medium Phone' },
|
||||
{ identifier: 'pixel_9', displayName: 'Pixel 9' },
|
||||
{ identifier: 'medium_phone', displayName: 'Medium Phone', isDefault: true },
|
||||
{ identifier: 'pixel_9', displayName: 'Pixel 9', isDefault: false },
|
||||
];
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
@@ -111,11 +115,11 @@ describe(SimulatorDevices, () => {
|
||||
expect(mockProfilesAsync).toHaveBeenCalledWith(graphqlClient);
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
[
|
||||
'Platform Device --device value',
|
||||
'iOS iPhone 17 "iPhone 17"',
|
||||
'iOS iPhone 17 Pro "iPhone 17 Pro"',
|
||||
'Android Medium Phone medium_phone',
|
||||
'Android Pixel 9 pixel_9',
|
||||
'Platform Device --device value',
|
||||
'iOS iPhone 17 (default) "iPhone 17"',
|
||||
'iOS iPhone 17 Pro "iPhone 17 Pro"',
|
||||
'Android Medium Phone (default) medium_phone',
|
||||
'Android Pixel 9 pixel_9',
|
||||
].join('\n')
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
@@ -129,9 +133,9 @@ describe(SimulatorDevices, () => {
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
[
|
||||
'Platform Device --device value',
|
||||
'Android Medium Phone medium_phone',
|
||||
'Android Pixel 9 pixel_9',
|
||||
'Platform Device --device value',
|
||||
'Android Medium Phone (default) medium_phone',
|
||||
'Android Pixel 9 pixel_9',
|
||||
].join('\n')
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
@@ -139,6 +143,26 @@ describe(SimulatorDevices, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the default flag from the API instead of the device name or list order', async () => {
|
||||
mockProfilesAsync.mockResolvedValue({
|
||||
...profiles,
|
||||
ios: {
|
||||
...profiles.ios,
|
||||
devices: profiles.ios.devices.map(device => ({
|
||||
...device,
|
||||
isDefault: device.identifier === 'iPhone 17 Pro',
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
await createCommand(['--platform', 'ios']).runAsync();
|
||||
|
||||
const table = mockLog.mock.calls[0][0];
|
||||
expect(table).toContain('iPhone 17 Pro (default)');
|
||||
expect(table).not.toContain('iPhone 17 (default)');
|
||||
expect(table).toContain('"iPhone 17 Pro"');
|
||||
});
|
||||
|
||||
it('says so when the service lists no devices', async () => {
|
||||
mockProfilesAsync.mockResolvedValue({
|
||||
...profiles,
|
||||
|
||||
@@ -78,9 +78,10 @@ export default class SimulatorDevices extends EasCommand {
|
||||
Object.fromEntries(
|
||||
platforms.map(platform => [
|
||||
platform,
|
||||
profiles[platform].devices.map(({ identifier, displayName }) => ({
|
||||
profiles[platform].devices.map(({ identifier, displayName, isDefault }) => ({
|
||||
identifier,
|
||||
displayName,
|
||||
isDefault,
|
||||
})),
|
||||
])
|
||||
)
|
||||
@@ -89,9 +90,9 @@ export default class SimulatorDevices extends EasCommand {
|
||||
}
|
||||
|
||||
const devices = platforms.flatMap(platform =>
|
||||
profiles[platform].devices.map(({ identifier, displayName }) => ({
|
||||
profiles[platform].devices.map(({ identifier, displayName, isDefault }) => ({
|
||||
platform,
|
||||
displayName,
|
||||
displayName: isDefault ? `${displayName} (default)` : displayName,
|
||||
flagValue: formatDeviceFlagValue(identifier),
|
||||
}))
|
||||
);
|
||||
|
||||
+5
-1
@@ -1503,6 +1503,8 @@ export type AndroidDeviceRunSessionDevice = {
|
||||
displayName: Scalars['String']['output'];
|
||||
/** AVD hardware profile id accepted by android.deviceIdentifier (e.g. "pixel_9"). */
|
||||
identifier: Scalars['String']['output'];
|
||||
/** Whether a session starts this device when it requests no android.deviceIdentifier. */
|
||||
isDefault: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type AndroidDeviceRunSessionProfiles = {
|
||||
@@ -9142,6 +9144,8 @@ export type IosDeviceRunSessionDevice = {
|
||||
displayName: Scalars['String']['output'];
|
||||
/** Simulator device name accepted by ios.deviceIdentifier (e.g. "iPhone 17 Pro"). */
|
||||
identifier: Scalars['String']['output'];
|
||||
/** Whether a session starts this device when it requests no ios.deviceIdentifier. */
|
||||
isDefault: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type IosDeviceRunSessionProfiles = {
|
||||
@@ -16435,7 +16439,7 @@ export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSes
|
||||
export type DeviceRunSessionProfilesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type DeviceRunSessionProfilesQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', profiles: { __typename?: 'DeviceRunSessionProfiles', ios: { __typename?: 'IosDeviceRunSessionProfiles', devices: Array<{ __typename?: 'IosDeviceRunSessionDevice', identifier: string, displayName: string }> }, android: { __typename?: 'AndroidDeviceRunSessionProfiles', devices: Array<{ __typename?: 'AndroidDeviceRunSessionDevice', identifier: string, displayName: string }> } } } };
|
||||
export type DeviceRunSessionProfilesQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', profiles: { __typename?: 'DeviceRunSessionProfiles', ios: { __typename?: 'IosDeviceRunSessionProfiles', devices: Array<{ __typename?: 'IosDeviceRunSessionDevice', identifier: string, displayName: string, isDefault: boolean }> }, android: { __typename?: 'AndroidDeviceRunSessionProfiles', devices: Array<{ __typename?: 'AndroidDeviceRunSessionDevice', identifier: string, displayName: string, isDefault: boolean }> } } } };
|
||||
|
||||
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
|
||||
@@ -216,12 +216,14 @@ export const DeviceRunSessionQuery = {
|
||||
devices {
|
||||
identifier
|
||||
displayName
|
||||
isDefault
|
||||
}
|
||||
}
|
||||
android {
|
||||
devices {
|
||||
identifier
|
||||
displayName
|
||||
isDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user