Compare commits

...
9 Commits
Author SHA1 Message Date
Expo CI 74292d2ace v23.2.0 2026-08-31 20:25:25 +00:00
Szymon Dziedzic b0b7cad316 [eas-cli] Warn about Android simulator parity (#4320)
* [eas-cli] warn about Android simulator parity

* [eas-cli] add Android simulator warning changelog
2026-08-31 22:22:53 +02:00
expo-tuft[bot]andtchayen 926887d14e [eas-cli] Add --tag to simulator session commands (#4318)
Lets projects with several app variants group and filter their sessions.

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: tchayen <[email protected]>
2026-08-31 22:40:52 +03:00
Gabe DebesandTomasz Mazur 2e6bb2d276 [eas-cli] Add --ssh to workflow:run (#4033)
* [eas-cli] add --ssh to workflow:run

* [eas-cli] Restore WorkflowRunSshInput types dropped by staging regen

Staging schema does not include the --ssh input yet (universe #29128),
but the CLI stack already depends on it. Keep the types until that ships.

* Regenerate GraphQL

* Dedupe changelog

---------

Co-authored-by: Tomasz Mazur <[email protected]>
2026-08-31 17:30:17 +02:00
Gabe DebesandTomasz Mazur 30656509ac [eas-cli] Add workflow:ssh command (#4032)
* [eas-cli] add workflow:ssh command

* Poll for an open SSH session before sleeping.

Avoids a needless wait when the first connect-info fetch is already ready.

* Update SSH wait test for poll-before-sleep.

The wait loop now polls first, so keep one pending poll before ready.

* Cover WorkflowJobSshQuery error and turtleBuild session paths.

* [eas-cli] Regen GraphQL types from staging (TurtleSshSession)

* [eas-cli] address workflow:ssh review feedback

* [eas-cli] drop the GET_BUILD check from sshRequested

* [eas-cli] accept a job run id in workflow:ssh

* Use URL to parse the host

* Regenerate GraphQL

* Move changelog entry

---------

Co-authored-by: Tomasz Mazur <[email protected]>
2026-08-31 17:30:17 +02:00
66e3f1ad7b [build-tools] Use expo-device-hub for Android web previews (#4304)
Co-authored-by: szdziedzic <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-08-31 15:49:49 +02:00
Tomasz Mazur 9aadf2d467 [eas-cli] Stream workflow run logs in real time (#4228)
* [eas-cli] Add centrifuge and ws to deps

* [eas-cli] Stream workflow job logs while waiting for a run

* [eas-cli] Add changelog entry for realtime workflow logs

* [eas-cli] Make msg optional and fix flickering

* [eas-cli] Fetch job logs without casing on job type

* [eas-cli] Ingest realtime incrementally instead of rebuilding full logs on every message (#4293)
2026-08-31 13:38:38 +00:00
Stanisław Chmiela ba80e57753 [eas-cli] Update GraphQL schema for web preview sessions (#4315)
Updated GraphQL and adjusted code.
2026-08-31 15:20:33 +02:00
Expo CI 50c76f41e3 update CHANGELOG.md and bump STAGING and PRODUCTION in cli-versions.json 2026-08-31 11:27:55 +00:00
56 changed files with 4935 additions and 590 deletions

No files matched your search

+16
View File
@@ -8,6 +8,22 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `--tag` to `eas simulator` and `eas simulator:list` to group and filter simulator sessions by app variant. ([#4318](https://github.com/expo/eas-cli/pull/4318) by [@tchayen](https://github.com/tchayen))
- [eas-cli] Update workflow run logs in real time, instead of every 10 seconds. ([#4228](https://github.com/expo/eas-cli/pull/4228) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Add `expo-device-hub` web previews to Android web-preview-only, Agent Device, Argent, and Appium remote sessions. ([#4304](https://github.com/expo/eas-cli/pull/4304) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `eas workflow:ssh <workflow-job-id> [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--ssh` (and `--ssh-idle-timeout`) to `eas workflow:run` to enable ssh on the run's VM jobs. ([#4033](https://github.com/expo/eas-cli/pull/4033) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
### 🧹 Chores
- [eas-cli] Warn when starting Android EAS Simulator sessions that support is still in development and iOS parity is coming soon. ([#4320](https://github.com/expo/eas-cli/pull/4320) by [@szdziedzic](https://github.com/szdziedzic))
## [23.1.0](https://github.com/expo/eas-cli/releases/tag/v23.1.0) - 2026-08-31
### 🎉 New features
- [eas-cli] Prompt for the Expo SDK version in `eas new` and add a `--sdk-version` flag. The template now downloads from npm instead of GitHub to support SDK-specific versions. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Clean up template metadata in projects created with `eas new`: remove the template LICENSE file and reset the package.json name, version, and license fields. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Support `EAS_PNPM_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `pnpm install --filter`. ([#4302](https://github.com/expo/eas-cli/pull/4302) by [@robingullo](https://github.com/robingullo))
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "22.6.0",
"PRODUCTION": "22.4.0"
"STAGING": "23.1.0",
"PRODUCTION": "23.1.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "23.1.0",
"version": "23.2.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "23.1.0",
"version": "23.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -51,7 +51,7 @@ import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAp
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
@@ -106,7 +106,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createStartIosSimulatorRecordingsBuildFunction(),
createFinishIosSimulatorRecordingsBuildFunction(),
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartServeSimRemoteSessionBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx),
createStartServeSimMetricsBuildFunction(),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
@@ -14,6 +14,7 @@ import {
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
@@ -46,8 +47,8 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
getNgrokTunnelDomainOrThrow: jest.fn(),
selectXcodeDeveloperDirectoryAsync: jest.fn(),
spawnDetached: jest.fn(),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
startServeSimWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
@@ -58,6 +59,7 @@ const EXPECTED_EVENT_LOG_PATH = path.join(ARGENT_STATE_DIR, 'tool-server-events.
const mockStopAsync = jest.fn();
const mockTunnelStopAsync = jest.fn();
const mockPreviewStopAsync = jest.fn();
describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
beforeEach(async () => {
@@ -68,6 +70,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined);
mockStopAsync.mockResolvedValue(undefined);
mockTunnelStopAsync.mockResolvedValue(undefined);
mockPreviewStopAsync.mockResolvedValue(undefined);
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({
stopAsync: mockStopAsync,
getLastEventObservedAt: () => undefined,
@@ -86,6 +89,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
url: 'https://argent-abc.tunnel.example.com',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.tunnel.example.com',
stopAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
@@ -151,5 +158,17 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
);
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://web-preview.tunnel.example.com',
}),
})
);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,85 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
import { type CustomBuildContext } from '../../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
jest.mock('../../utils/remoteDeviceRunSession');
const ctx = {} as CustomBuildContext;
const env = {} as BuildStepEnv;
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const stopAsync = jest.fn();
async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
{
logger,
global: { runtimePlatform },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: '1.2.3' },
max_duration_seconds: { value: 120 },
},
outputs: {},
env,
} as never
);
}
describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
stopAsync.mockResolvedValue(undefined);
});
it.each([
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.LINUX, false],
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
await runAsync(runtimePlatform);
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
runtimePlatform,
baseDomain: 'tunnel.example.com',
env,
logger,
timeoutMs: 60_000,
packageVersion: '1.2.3',
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
logger,
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
logger,
maxDurationSeconds: 120,
signal: undefined,
});
expect(stopAsync).toHaveBeenCalledTimes(1);
});
});
@@ -23,8 +23,8 @@ import {
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
@@ -107,22 +107,19 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
let eventCollection:
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
| undefined;
try {
// serve-sim is iOS-only — only launch it (and report a webPreviewUrl)
// on Darwin. Android sessions go without a preview URL.
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${serveSim.previewUrl}`);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
await uploadRemoteSessionConfigAsync({
ctx,
@@ -130,7 +127,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
webPreviewUrl: webPreview.previewUrl,
},
logger,
});
@@ -163,8 +160,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
: undefined,
});
} finally {
if (serveSim) {
await serveSim.stopAsync();
if (webPreview) {
await webPreview.stopAsync();
}
await agentDeviceTunnel.stopAsync();
if (eventCollection) {
@@ -26,8 +26,8 @@ import {
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -118,7 +118,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger,
});
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
try {
appiumTunnel = await startNgrokTunnelAsync({
port: APPIUM_PORT,
@@ -128,18 +128,15 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger,
});
switch (runtimePlatform) {
case BuildRuntimePlatform.DARWIN:
serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
});
break;
case BuildRuntimePlatform.LINUX:
break;
}
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
// a single booted Android emulator so the Hub and Appium resolve the same device.
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
});
await uploadRemoteSessionConfigAsync({
ctx,
@@ -151,7 +148,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
webPreviewUrl: webPreview.previewUrl,
},
logger,
});
@@ -170,8 +167,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
: undefined,
});
} finally {
if (serveSim) {
await serveSim.stopAsync();
if (webPreview) {
await webPreview.stopAsync();
}
if (appiumTunnel) {
await appiumTunnel.stopAsync();
@@ -26,8 +26,8 @@ import {
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -186,7 +186,7 @@ export function createStartArgentRemoteSessionBuildFunction(
});
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
try {
toolsTunnel = await startNgrokTunnelAsync({
port: toolServerPort,
@@ -199,18 +199,14 @@ export function createStartArgentRemoteSessionBuildFunction(
const publicToolsUrl = toolsTunnel.url;
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = serveSim.previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
await uploadRemoteSessionConfigAsync({
ctx,
@@ -218,7 +214,7 @@ export function createStartArgentRemoteSessionBuildFunction(
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
...(webPreviewUrl ? { webPreviewUrl } : {}),
webPreviewUrl: webPreview.previewUrl,
},
logger,
});
@@ -238,8 +234,8 @@ export function createStartArgentRemoteSessionBuildFunction(
: undefined,
});
} finally {
if (serveSim) {
await serveSim.stopAsync();
if (webPreview) {
await webPreview.stopAsync();
}
if (toolsTunnel) {
await toolsTunnel.stopAsync();
@@ -10,22 +10,21 @@ import {
getDeviceRunSessionIdOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startServeSimWithTunnelAsync,
startDeviceWebPreviewWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartServeSimRemoteSessionBuildFunction(
export function createStartWebPreviewRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_serve_sim_remote_session',
name: 'Start serve-sim remote session',
name: 'Start web preview remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
@@ -38,30 +37,34 @@ export function createStartServeSimRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger }, { inputs, env, signal }) => {
fn: async ({ logger, global }, { inputs, env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
logger.info('Starting serve-sim remote session.');
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
await selectXcodeDeveloperDirectoryAsync({ env, logger });
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const serveSim = await startServeSimWithTunnelAsync(ctx, {
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
runtimePlatform,
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
packageVersion,
});
logger.info(`Preview URL: ${serveSim.previewUrl}`);
logger.info(`Preview URL: ${webPreview.previewUrl}`);
try {
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: { previewUrl: serveSim.previewUrl },
remoteConfig: { previewUrl: webPreview.previewUrl },
logger,
});
@@ -73,7 +76,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
signal,
});
} finally {
await serveSim.stopAsync();
await webPreview.stopAsync();
}
},
});
@@ -13,14 +13,16 @@ import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { sleepAsync } from '../../../utils/retry';
import {
createExpoDeviceHubArgs,
createServeSimArgs,
ensureFfmpegInstalledAsync,
fetchServeSimTurnArgsAsync,
fetchWebPreviewTurnArgsAsync,
metricsCorsOriginToServeSimArgs,
startDeviceWebPreviewWithTunnelAsync,
startNgrokTunnelAsync,
turnIceServersToServeSimArgs,
turnIceServersToWebPreviewArgs,
waitForDeviceRunSessionStoppedAsync,
waitForServeSimReadyAsync,
waitForWebPreviewReadyAsync,
} from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
@@ -173,6 +175,49 @@ describe(createServeSimArgs, () => {
});
});
describe(createExpoDeviceHubArgs, () => {
it('uses the latest Expo package and applies the EAS Android streaming policy', () => {
expect(
createExpoDeviceHubArgs({
port: 4321,
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
})
).toEqual([
'--yes',
'expo-device-hub@latest',
'--port',
'4321',
'--host',
'127.0.0.1',
'--platform',
'android',
'--transport',
'webrtc',
'--webrtc-codec',
'h264',
'--webrtc-ice-policy',
'all',
'--max-dimension',
'1280',
'--video-bitrate',
'6000000',
'--video-fps',
'60',
'--hide-sidebar',
'--hide-boot-device',
'--turn-url',
'turns:turn.example.test:443',
]);
});
it('pins the requested package version', () => {
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
'--yes',
'[email protected]',
]);
});
});
describe(metricsCorsOriginToServeSimArgs, () => {
it('returns no args when the origin is unset or empty', () => {
expect(metricsCorsOriginToServeSimArgs({} as BuildStepEnv)).toEqual([]);
@@ -200,7 +245,7 @@ describe(metricsCorsOriginToServeSimArgs, () => {
});
});
describe(waitForServeSimReadyAsync, () => {
describe(waitForWebPreviewReadyAsync, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
jest.mocked(sleepAsync).mockReset();
@@ -215,8 +260,9 @@ describe(waitForServeSimReadyAsync, () => {
json: async () => ({ status: 'ready', device: 'DEVICE-A' }),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
await waitForServeSimReadyAsync({
serveSim: { pid: undefined, getOutput: () => '' },
await waitForWebPreviewReadyAsync({
previewServer: { pid: undefined, getOutput: () => '' },
serverName: 'expo-device-hub',
port: 4321,
timeoutMs: 10_000,
});
@@ -261,14 +307,125 @@ describe(startNgrokTunnelAsync, () => {
});
});
describe(turnIceServersToServeSimArgs, () => {
describe(startDeviceWebPreviewWithTunnelAsync, () => {
const baseDomain = 'eas-simulator.ngrok.dev';
const turnArgs = [
'--turn-url',
'turns:turn.example.test:443',
'--turn-username',
'turn-user',
'--turn-credential',
'turn-credential',
];
const metricsCorsArgs = ['--metrics-cors-origin', 'https://metrics.expo.test'];
const env = {
DEVICE_RUN_SESSION_ID: 'drs-id',
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://metrics.expo.test',
NGROK_AUTHTOKEN: 'ngrok-token',
} as unknown as BuildStepEnv;
beforeEach(() => {
jest.mocked(spawn).mockReset();
jest.mocked(ngrok.forward).mockReset();
jest.mocked(turtleFetch).mockReset();
const spawnPromise = Object.assign(Promise.resolve(undefined), {
child: {
pid: undefined,
unref: jest.fn(),
},
});
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
jest.mocked(turtleFetch).mockImplementation(async url => {
if (url.endsWith('/turn-ice-servers')) {
return {
json: async () => ({
data: {
iceServers: [
{
urls: ['turns:turn.example.test:443'],
username: 'turn-user',
credential: 'turn-credential',
},
],
},
}),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
}
return {
json: async () => ({ status: 'ready', device: 'device-id' }),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
});
});
it('starts expo-device-hub for Linux and cleans up the preview resources', async () => {
const packageVersion = '1.2.3';
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
close,
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.LINUX,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://android-preview.example.test');
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
const packageVersion = '4.5.6';
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://ios-preview.example.test',
close,
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion,
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
expect(args).toEqual(createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }));
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
});
describe(turnIceServersToWebPreviewArgs, () => {
it('returns no args for an empty ICE server list', () => {
expect(turnIceServersToServeSimArgs([])).toEqual([]);
expect(turnIceServersToWebPreviewArgs([])).toEqual([]);
});
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
expect(
turnIceServersToServeSimArgs([
turnIceServersToWebPreviewArgs([
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
{
urls: [
@@ -293,7 +450,7 @@ describe(turnIceServersToServeSimArgs, () => {
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
expect(
turnIceServersToServeSimArgs([
turnIceServersToWebPreviewArgs([
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
@@ -311,19 +468,19 @@ describe(turnIceServersToServeSimArgs, () => {
});
it('emits only --stun-url when there is no credentialed TURN entry', () => {
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
expect(turnIceServersToWebPreviewArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
'--stun-url',
'stun:stun.cloudflare.com:3478',
]);
});
});
describe(fetchServeSimTurnArgsAsync, () => {
describe(fetchWebPreviewTurnArgsAsync, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
it('requests TURN ICE servers from the device run session endpoint and returns web preview args', async () => {
jest.mocked(turtleFetch).mockResolvedValue({
json: async () => ({
data: {
@@ -339,7 +496,7 @@ describe(fetchServeSimTurnArgsAsync, () => {
}),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger: createLoggerMock(),
});
@@ -363,11 +520,11 @@ describe(fetchServeSimTurnArgsAsync, () => {
);
});
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
it('returns [] and warns when the request fails so the web preview falls back to P2P/STUN', async () => {
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
const logger = createLoggerMock();
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger,
});
@@ -19,12 +19,16 @@ import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const WEB_PREVIEW_HOST = '127.0.0.1';
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
const SERVE_SIM_HOST = '127.0.0.1';
const SERVE_SIM_MAX_DIMENSION = '960';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
const SERVE_SIM_VIDEO_BITRATE = '6000000';
const SERVE_SIM_VIDEO_FPS = '60';
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
const EXPO_DEVICE_HUB_MAX_DIMENSION = '1280';
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
@@ -390,11 +394,12 @@ const TurnIceServersResponseSchema = z.object({
});
/**
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
* Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
* (the entry carrying the short-lived credentials).
* (the entry carrying the short-lived credentials). serve-sim and expo-device-hub
* intentionally expose the same ICE flag contract.
*/
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): string[] {
const stunUrls = iceServers
.filter(server => !server.username && !server.credential)
.flatMap(server => server.urls);
@@ -420,14 +425,14 @@ export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string
/**
* Fetch short-lived Cloudflare TURN ICE servers for this job run from www
* (minted on demand, mirroring how the worker fetches project clone URLs) and
* translate them into serve-sim CLI flags.
* translate them into web preview CLI flags.
*
* Best-effort: on any failure we log and return [] so serve-sim falls back to
* its built-in P2P/STUN behavior. The credential is passed to serve-sim as a
* process arg and deliberately not logged (turtle-spawn never logs argv and the
* worker is single-tenant).
* Best-effort: on any failure we log and return [] so the preview server falls
* back to its built-in P2P/STUN behavior. The credential is passed as a process
* arg and deliberately not logged (turtle-spawn never logs argv and the worker
* is single-tenant).
*/
export async function fetchServeSimTurnArgsAsync(
export async function fetchWebPreviewTurnArgsAsync(
ctx: CustomBuildContext,
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
): Promise<string[]> {
@@ -456,9 +461,9 @@ export async function fetchServeSimTurnArgsAsync(
);
const { data } = TurnIceServersResponseSchema.parse(await response.json());
const args = turnIceServersToServeSimArgs(data.iceServers);
const args = turnIceServersToWebPreviewArgs(data.iceServers);
if (args.length > 0) {
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
}
return args;
} catch (err) {
@@ -466,7 +471,7 @@ export async function fetchServeSimTurnArgsAsync(
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
logger.warn(
{ err: error },
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
);
return [];
}
@@ -600,6 +605,10 @@ function createServeSimPackageSpec(packageVersion: string | undefined): string {
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
export function createServeSimArgs({
port,
turnArgs = [],
@@ -617,7 +626,7 @@ export function createServeSimArgs({
'--port',
String(port),
'--host',
SERVE_SIM_HOST,
WEB_PREVIEW_HOST,
'--transport',
'webrtc',
'--webrtc-codec',
@@ -635,44 +644,84 @@ export function createServeSimArgs({
];
}
export function createExpoDeviceHubArgs({
port,
turnArgs = [],
packageVersion,
}: {
port: number;
turnArgs?: string[];
packageVersion?: string;
}): string[] {
return [
'--yes',
createExpoDeviceHubPackageSpec(packageVersion),
'--port',
String(port),
'--host',
WEB_PREVIEW_HOST,
'--platform',
'android',
'--transport',
'webrtc',
'--webrtc-codec',
'h264',
'--webrtc-ice-policy',
'all',
'--max-dimension',
EXPO_DEVICE_HUB_MAX_DIMENSION,
'--video-bitrate',
EXPO_DEVICE_HUB_VIDEO_BITRATE,
'--video-fps',
EXPO_DEVICE_HUB_VIDEO_FPS,
'--hide-sidebar',
'--hide-boot-device',
...turnArgs,
];
}
async function findAvailablePortAsync(): Promise<number> {
const server = createServer();
server.unref();
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, SERVE_SIM_HOST, () => resolve());
server.listen(0, WEB_PREVIEW_HOST, () => resolve());
});
const address = server.address();
await new Promise<void>((resolve, reject) => {
server.close(err => (err ? reject(err) : resolve()));
});
if (!address || typeof address === 'string') {
throw new SystemError('Could not allocate a local port for serve-sim.');
throw new SystemError('Could not allocate a local port for the web preview.');
}
return address.port;
}
const ServeSimReadyResponseSchema = z.object({
const WebPreviewReadyResponseSchema = z.object({
status: z.literal('ready'),
device: z.string(),
});
export async function waitForServeSimReadyAsync({
serveSim,
export async function waitForWebPreviewReadyAsync({
previewServer,
serverName,
port,
timeoutMs,
}: {
serveSim: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
serverName: string;
port: number;
timeoutMs: number;
}): Promise<void> {
const readyUrl = `http://${SERVE_SIM_HOST}:${port}/readyz`;
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
if (serveSim.pid !== undefined && !isProcessRunning(serveSim.pid)) {
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
throw new SystemError(
`serve-sim exited before becoming ready. Last output:\n${serveSim.getOutput() || '<empty>'}`
`${serverName} exited before becoming ready. Last output:\n${
previewServer.getOutput() || '<empty>'
}`
);
}
try {
@@ -680,7 +729,7 @@ export async function waitForServeSimReadyAsync({
retries: 0,
timeout: 2_000,
});
ServeSimReadyResponseSchema.parse(await response.json());
WebPreviewReadyResponseSchema.parse(await response.json());
return;
} catch (error) {
lastError = error;
@@ -688,17 +737,75 @@ export async function waitForServeSimReadyAsync({
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for serve-sim readiness at ${readyUrl}${
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}. Last output:\n${serveSim.getOutput() || '<empty>'}`
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
);
}
export type ServeSimPreviewHandle = {
export type DeviceWebPreviewHandle = {
previewUrl: string;
stopAsync: () => Promise<void>;
};
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
async function startWebPreviewWithTunnelAsync(
ctx: CustomBuildContext,
{
baseDomain,
env,
logger,
timeoutMs,
serverName,
packageSpec,
createArgs,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
serverName: string;
packageSpec: string;
createArgs: (port: number, turnArgs: string[]) => string[];
}
): Promise<DeviceWebPreviewHandle> {
const port = await findAvailablePortAsync();
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const previewServer = spawnDetached({
command: 'npx',
args: createArgs(port, turnArgs),
env,
});
try {
logger.info(`Waiting for ${serverName} to become ready.`);
await waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs });
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'web-preview',
baseDomain,
authtoken: getNgrokAuthtokenOrThrow(env),
logger,
});
return {
previewUrl: tunnel.url,
stopAsync: async () => {
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
for (const result of results) {
if (result.status === 'rejected') {
logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`);
}
}
},
};
} catch (error) {
await previewServer.stopAsync();
throw error;
}
}
export async function startServeSimWithTunnelAsync(
ctx: CustomBuildContext,
{
@@ -715,42 +822,65 @@ export async function startServeSimWithTunnelAsync(
packageVersion?: string;
}
): Promise<ServeSimPreviewHandle> {
const port = await findAvailablePortAsync();
logger.info(
`Launching ${createServeSimPackageSpec(packageVersion)} on ${SERVE_SIM_HOST}:${port}.`
);
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
const serveSim = spawnDetached({
command: 'npx',
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
logger,
timeoutMs,
serverName: 'serve-sim',
packageSpec: createServeSimPackageSpec(packageVersion),
createArgs: (port, turnArgs) =>
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
});
}
try {
logger.info('Waiting for serve-sim to become ready.');
await waitForServeSimReadyAsync({ serveSim, port, timeoutMs });
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'web-preview',
baseDomain,
authtoken: getNgrokAuthtokenOrThrow(env),
logger,
});
return {
previewUrl: tunnel.url,
stopAsync: async () => {
const results = await Promise.allSettled([tunnel.stopAsync(), serveSim.stopAsync()]);
for (const result of results) {
if (result.status === 'rejected') {
logger.warn({ err: result.reason }, 'Could not stop a serve-sim preview resource.');
}
}
},
};
} catch (error) {
await serveSim.stopAsync();
throw error;
export async function startExpoDeviceHubWithTunnelAsync(
ctx: CustomBuildContext,
{
baseDomain,
env,
logger,
timeoutMs,
packageVersion,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
): Promise<DeviceWebPreviewHandle> {
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
logger,
timeoutMs,
serverName: 'expo-device-hub',
packageSpec: createExpoDeviceHubPackageSpec(packageVersion),
createArgs: (port, turnArgs) => createExpoDeviceHubArgs({ port, turnArgs, packageVersion }),
});
}
export async function startDeviceWebPreviewWithTunnelAsync(
ctx: CustomBuildContext,
{
runtimePlatform,
...options
}: {
runtimePlatform: BuildRuntimePlatform;
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
packageVersion?: string;
}
): Promise<DeviceWebPreviewHandle> {
switch (runtimePlatform) {
case BuildRuntimePlatform.DARWIN:
return await startServeSimWithTunnelAsync(ctx, options);
case BuildRuntimePlatform.LINUX:
return await startExpoDeviceHubWithTunnelAsync(ctx, options);
}
}
+170 -147
View File
File diff suppressed because it is too large. Load diff
+1082 -2
View File
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "23.1.0",
"version": "23.2.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -77,6 +77,7 @@
"ajv-formats": "2.1.1",
"better-opn": "3.0.2",
"bplist-parser": "^0.3.0",
"centrifuge": "5.7.1",
"chalk": "4.1.2",
"cli-progress": "3.12.0",
"dateformat": "4.6.3",
@@ -132,6 +133,7 @@
"untildify": "4.0.0",
"uuid": "11.1.1",
"wrap-ansi": "7.0.0",
"ws": "8.21.1",
"yaml": "2.6.0",
"zod": "^4.1.3"
},
@@ -160,6 +162,7 @@
"@types/tar-stream": "3.1.3",
"@types/tough-cookie": "4.0.2",
"@types/wrap-ansi": "3.0.0",
"@types/ws": "8.5.10",
"axios": "1.18.1",
"eslint-plugin-graphql": "4.0.0",
"jest": "29.7.0",
@@ -8,10 +8,7 @@ import {
mockProjectId,
mockTestCommand,
} from './utils';
import {
fetchRawLogsForBuildJobAsync,
fetchRawLogsForCustomJobAsync,
} from '../../commandUtils/workflow/fetchLogs';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/logs/fetchLogs';
import WorkflowLogView from '../../commands/workflow/logs';
import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -38,7 +35,7 @@ jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
jest.mock('../../commandUtils/workflow/fetchLogs');
jest.mock('../../commandUtils/workflow/logs/fetchLogs');
describe(WorkflowLogView, () => {
beforeEach(() => {
@@ -49,6 +46,7 @@ describe(WorkflowLogView, () => {
});
afterEach(() => {
jest.clearAllMocks();
jest.mocked(fetchRawLogsForJobAsync).mockReset();
});
test('view logs, passing in no parameters, no runs found', async () => {
const ctx = mockCommandContext(WorkflowLogView, {
@@ -105,7 +103,7 @@ describe(WorkflowLogView, () => {
return { selectedStep: 'step1' };
}
});
jest.mocked(fetchRawLogsForCustomJobAsync).mockResolvedValue(null);
jest.mocked(fetchRawLogsForJobAsync).mockResolvedValue(null);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
@@ -139,7 +137,7 @@ describe(WorkflowLogView, () => {
}
});
jest
.mocked(fetchRawLogsForCustomJobAsync)
.mocked(fetchRawLogsForJobAsync)
.mockResolvedValue(
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install dependencies","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
);
@@ -209,7 +207,7 @@ describe(WorkflowLogView, () => {
}
});
jest
.mocked(fetchRawLogsForBuildJobAsync)
.mocked(fetchRawLogsForJobAsync)
.mockResolvedValue(
'{"result":"success","marker":"end-step","buildStepDisplayName":"step1","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"test"}'
);
@@ -255,7 +253,7 @@ describe(WorkflowLogView, () => {
}
});
jest
.mocked(fetchRawLogsForCustomJobAsync)
.mocked(fetchRawLogsForJobAsync)
.mockResolvedValue(
[
'{"result":"success","marker":"end-step","buildStepDisplayName":"Install","buildStepId":"step-id-1","time":"2022-01-01T00:00:00.000Z","msg":"first"}',
+10
View File
@@ -112,3 +112,13 @@ export function getEASUpdateURL(projectId: string, manifestHostOverride: string
export function getExpoApiWorkflowSchemaURL(): string {
return getExpoApiBaseUrl() + '/v2/workflows/schema';
}
export function getEASLogsWebsocketUrl(): string {
if (process.env.EXPO_STAGING) {
return `wss://staging-logs.expo.dev/connection/websocket`;
} else if (process.env.EXPO_LOCAL) {
return `ws://localhost:4997/connection/websocket`;
} else {
return `wss://logs.expo.dev/connection/websocket`;
}
}
@@ -1,35 +1,137 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
import { fetchRawLogsForCustomJobAsync } from '../fetchLogs';
import { infoForActiveWorkflowRunAsync } from '../utils';
import { WorkflowJobStatus } from '../../../graphql/generated';
import { groupLogLinesIntoSteps, parseLogLines } from '../logs/parseLogs';
import { WorkflowLogs, WorkflowRawLogLine } from '../types';
import { formatActiveWorkflowRun, formatFailedWorkflowRun } from '../utils';
jest.mock('../fetchLogs');
function jobWithLogs(
logLines: WorkflowRawLogLine[],
status: WorkflowJobStatus = WorkflowJobStatus.InProgress
): {
job: ReturnType<typeof getMockWorkflowRunWithJobsFragment>['jobs'][number];
logs: WorkflowLogs;
} {
return {
job: { ...getMockWorkflowRunWithJobsFragment().jobs[0], status },
logs: groupLogLinesIntoSteps(logLines),
};
}
describe('workflow utils', () => {
afterEach(() => {
jest.clearAllMocks();
});
function stepLines(buildStepId: string, count: number): WorkflowRawLogLine[] {
return Array.from({ length: count }, (_, index) => ({ buildStepId, msg: `line${index}` }));
}
test('shows the display name for the current step while keying logs by step id', async () => {
const workflowRun = getMockWorkflowRunWithJobsFragment();
workflowRun.jobs = workflowRun.jobs.map(job => ({
...job,
status: WorkflowJobStatus.InProgress,
}));
jest
.mocked(fetchRawLogsForCustomJobAsync)
.mockResolvedValue(
[
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","time":"2022-01-01T00:00:00.000Z","msg":"npm ci"}',
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","marker":"end-step","result":"success","time":"2022-01-01T00:00:01.000Z","msg":"done"}',
].join('\n')
);
const output = await infoForActiveWorkflowRunAsync({} as any, workflowRun);
describe(formatActiveWorkflowRun, () => {
test('shows the display name for the current step while keying logs by step id', () => {
const output = formatActiveWorkflowRun([
jobWithLogs(
parseLogLines(
[
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","time":"2022-01-01T00:00:00.000Z","msg":"npm ci"}',
'{"buildStepId":"step-id-1","buildStepDisplayName":"Install dependencies","marker":"end-step","result":"success","time":"2022-01-01T00:00:01.000Z","msg":"done"}',
].join('\n')
).logLines
),
]);
expect(output).toContain('Current step');
expect(output).toContain('Install dependencies');
expect(output).not.toContain('step-id-1');
});
test('shows exactly maxLogLines trailing lines of the current step', () => {
const output = formatActiveWorkflowRun([jobWithLogs(stepLines('step-id-1', 10))], 5);
expect(output).toContain('line5');
expect(output).toContain('line9');
expect(output).not.toContain('line4');
});
test('keeps five trailing lines by default', () => {
const output = formatActiveWorkflowRun([jobWithLogs(stepLines('step-id-1', 10))]);
expect(output).toContain('line5');
expect(output).toContain('line9');
expect(output).not.toContain('line4');
});
test('reports the last step as the current one', () => {
const output = formatActiveWorkflowRun([
jobWithLogs([
{ buildStepId: 'install', buildStepDisplayName: 'Install', msg: 'installing' },
{ buildStepId: 'build', buildStepDisplayName: 'Build', msg: 'compiling' },
]),
]);
expect(output).toContain('Build');
expect(output).not.toContain('Install');
});
test('passes over a trailing skipped step', () => {
const output = formatActiveWorkflowRun([
jobWithLogs([
{ buildStepId: 'install', buildStepDisplayName: 'Install', msg: 'installing' },
{
buildStepId: 'build',
buildStepDisplayName: 'Build',
msg: 'skipping',
marker: 'end-step',
result: 'skipped',
},
]),
]);
expect(output).toContain('Install');
expect(output).not.toContain('Build');
});
test('reports a step that has not ended yet', () => {
const output = formatActiveWorkflowRun([
jobWithLogs([
{
buildStepId: 'install',
buildStepDisplayName: 'Install',
msg: 'installed',
marker: 'end-step',
result: 'success',
},
{ buildStepId: 'build', buildStepDisplayName: 'Build', msg: 'compiling' },
]),
]);
expect(output).toContain('Build');
});
test('omits the current step when the job has no steps yet', () => {
const output = formatActiveWorkflowRun([jobWithLogs([])]);
expect(output).not.toContain('Current step');
expect(output).not.toContain('Current logs');
});
test('omits the current step for a job that is not in progress', () => {
const output = formatActiveWorkflowRun([
jobWithLogs(stepLines('step-id-1', 3), WorkflowJobStatus.Success),
]);
expect(output).not.toContain('Current step');
expect(output).not.toContain('line0');
});
});
describe(formatFailedWorkflowRun, () => {
test('prints every line of the failed step when there is no limit', () => {
const output = formatFailedWorkflowRun([
jobWithLogs(
[
...stepLines('build', 8),
{ buildStepId: 'build', msg: 'it failed', marker: 'end-step', result: 'fail' },
],
WorkflowJobStatus.Failure
),
]);
expect(output).toContain('line0');
expect(output).toContain('line7');
});
});
@@ -1,51 +0,0 @@
import { WorkflowJobResult } from './types';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
// This function is in a separate module for testing purposes
export async function fetchRawLogsForCustomJobAsync(
job: WorkflowJobResult
): Promise<string | null> {
const firstLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
if (!firstLogFileUrl) {
return null;
}
const response = await fetch(firstLogFileUrl, {
method: 'GET',
});
const rawLogs = await response.text();
return rawLogs;
}
export async function fetchRawLogsForBuildJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<string | null> {
// Prefer turtleJobRun logs, which contain JSONL-formatted step logs
// for both built-in and custom build steps
const turtleLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
if (turtleLogFileUrl) {
const response = await fetch(turtleLogFileUrl, {
method: 'GET',
});
return await response.text();
}
// Fall back to build logFiles
const buildId = job.outputs?.build_id;
if (!buildId) {
return null;
}
const buildFragment = await BuildQuery.byIdAsync(state.graphqlClient, buildId, {
useCache: false,
});
const firstLogFileUrl = buildFragment.logFiles?.[0];
if (!firstLogFileUrl) {
return null;
}
const response = await fetch(firstLogFileUrl, {
method: 'GET',
});
return await response.text();
}
@@ -0,0 +1,27 @@
import { getMockWorkflowCustomJobFragment } from '../../../../__tests__/commands/utils';
import { BuildQuery } from '../../../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../../../context/contextUtils/createGraphqlClient';
import { fetchRawLogsForJobAsync } from '../fetchLogs';
jest.mock('../../../../graphql/queries/BuildQuery');
describe(fetchRawLogsForJobAsync, () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('reads a custom job from its turtle job run log file', async () => {
const rawLogs = '{"logId":"1","buildStepId":"install","msg":"from the turtle job run"}';
const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(rawLogs));
await expect(
fetchRawLogsForJobAsync(
{ graphqlClient: {} as ExpoGraphqlClient },
getMockWorkflowCustomJobFragment()
)
).resolves.toBe(rawLogs);
expect(fetchSpy).toHaveBeenCalledWith('https://example.com/log1');
expect(BuildQuery.byIdAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,186 @@
import { groupLogLinesIntoSteps, mergeLogLines, parseLogLines } from '../parseLogs';
import { WorkflowRawLogLine } from '../../types';
function logLine(overrides: Partial<WorkflowRawLogLine> = {}): WorkflowRawLogLine {
return { msg: 'a message', ...overrides };
}
describe(parseLogLines, () => {
it('parses a JSONL log file', () => {
const { logLines, errors } = parseLogLines(
[
'{"logId":"1","buildStepId":"install","msg":"npm ci"}',
'{"logId":"2","buildStepId":"install","msg":"done"}',
].join('\n')
);
expect(errors).toEqual([]);
expect(logLines).toEqual([
{ logId: '1', buildStepId: 'install', msg: 'npm ci' },
{ logId: '2', buildStepId: 'install', msg: 'done' },
]);
});
it('skips blank lines, including a trailing newline', () => {
const { logLines, errors } = parseLogLines('{"logId":"1","msg":"one"}\n\n');
expect(errors).toEqual([]);
expect(logLines).toHaveLength(1);
});
it('reports malformed lines as errors while keeping the parsable ones', () => {
const { logLines, errors } = parseLogLines(
['{"logId":"1","msg":"one"}', 'this is not json', '{"logId":"2","msg":"two"}'].join('\n')
);
expect(logLines.map(line => line.msg)).toEqual(['one', 'two']);
expect(errors).toHaveLength(1);
expect(errors[0]).toBeInstanceOf(Error);
});
it('parses a line that carries no message', () => {
const { logLines, errors } = parseLogLines('{"logId":"1"}');
expect(errors).toEqual([]);
expect(logLines).toEqual([{ logId: '1' }]);
});
});
describe(mergeLogLines, () => {
it('keeps the rightmost line for a repeated logId', () => {
const fileLogLines = [
logLine({ logId: '1', msg: 'from the file' }),
logLine({ logId: '2', msg: 'from the file' }),
];
const realtimeLogLines = [
logLine({ logId: '2', msg: 'from realtime' }),
logLine({ logId: '3', msg: 'from realtime' }),
];
expect(mergeLogLines(fileLogLines, realtimeLogLines)).toEqual([
{ logId: '1', msg: 'from the file' },
{ logId: '2', msg: 'from realtime' },
{ logId: '3', msg: 'from realtime' },
]);
});
it('keeps identical messages that have different logIds', () => {
const merged = mergeLogLines(
[logLine({ logId: '1', msg: 'Repeated text' })],
[logLine({ logId: '2', msg: 'Repeated text' })]
);
expect(merged).toEqual([
{ logId: '1', msg: 'Repeated text' },
{ logId: '2', msg: 'Repeated text' },
]);
});
it('keeps identical lines without logId', () => {
const merged = mergeLogLines(
[logLine({ msg: 'same' }), logLine({ msg: 'same' })],
[logLine({ msg: 'same' })]
);
expect(merged).toHaveLength(3);
});
it('deduplicates within a single group', () => {
const merged = mergeLogLines([logLine({ logId: '1' }), logLine({ logId: '1' })]);
expect(merged).toHaveLength(1);
});
});
describe(groupLogLinesIntoSteps, () => {
it('groups lines by step id and labels the step with its display name', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepId: 'step-id-1', msg: 'npm ci' }),
logLine({
buildStepId: 'step-id-1',
buildStepDisplayName: 'Install dependencies',
msg: 'ok',
}),
]);
expect(Array.from(logs.keys())).toEqual(['step-id-1']);
expect(logs.get('step-id-1')).toEqual({
key: 'step-id-1',
label: 'Install dependencies',
logLines: [
{ time: undefined, msg: 'npm ci', result: undefined, marker: undefined, err: undefined },
{ time: undefined, msg: 'ok', result: undefined, marker: undefined, err: undefined },
],
});
});
it('keeps steps in the order they first appear', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepId: 'first' }),
logLine({ buildStepId: 'second' }),
logLine({ buildStepId: 'first' }),
logLine({ buildStepId: 'third' }),
]);
expect(Array.from(logs.keys())).toEqual(['first', 'second', 'third']);
});
it('falls back to the display name, then the phase, when there is no step id', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepDisplayName: 'Run fastlane' }),
logLine({ phase: 'PREPARE_CREDENTIALS' }),
]);
expect(Array.from(logs.keys())).toEqual(['Run fastlane', 'PREPARE_CREDENTIALS']);
expect(logs.get('PREPARE_CREDENTIALS')?.label).toBe('PREPARE_CREDENTIALS');
});
it('drops lines that belong to no step', () => {
const logs = groupLogLinesIntoSteps([logLine({ msg: 'a line with no step' })]);
expect(logs.size).toBe(0);
});
it('drops lines that carry no message, keeping the rest of the step', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepId: 'install', msg: undefined, marker: 'start-step' }),
logLine({ buildStepId: 'install', msg: 'npm ci' }),
]);
expect(logs.get('install')?.logLines).toEqual([
{ time: undefined, msg: 'npm ci', result: undefined, marker: undefined, err: undefined },
]);
});
it('creates no step for a line that carries no message', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepId: 'install', msg: undefined, marker: 'end-step', result: 'success' }),
]);
expect(logs.size).toBe(0);
});
it('records the result of the first end marker and ignores a later one', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepId: 'install', msg: 'skipping', marker: 'end-step', result: 'skipped' }),
logLine({ buildStepId: 'install', msg: 'retried', marker: 'end-step', result: 'success' }),
]);
expect(logs.get('install')?.result).toBe('skipped');
});
it('leaves the result undefined for a step that has not ended', () => {
const logs = groupLogLinesIntoSteps([logLine({ buildStepId: 'install', msg: 'npm ci' })]);
expect(logs.get('install')?.result).toBeUndefined();
});
it('records an end marker that carries no result and does not let a later one replace it', () => {
const logs = groupLogLinesIntoSteps([
logLine({ buildStepId: 'install', msg: 'ended', marker: 'END_PHASE' }),
logLine({ buildStepId: 'install', msg: 'retried', marker: 'END_PHASE', result: 'success' }),
]);
expect(logs.get('install')?.result).toBe('');
});
});
@@ -0,0 +1,308 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../../__tests__/commands/utils';
import { RealtimeLogsTargetType, WorkflowJobStatus } from '../../../../graphql/generated';
import { RealtimeLogsClient } from '../../../../utils/centrifuge';
import { fetchAndParseLogsFromJobAsync } from '../parseLogs';
import { WorkflowJobLogsState, WorkflowRunLogsWatcher, realtimeLogsTargetForJob } from '../watcher';
import { WorkflowJobResult, WorkflowRawLogLine } from '../../types';
jest.mock('../parseLogs', () => ({
...jest.requireActual('../parseLogs'),
fetchAndParseLogsFromJobAsync: jest.fn(),
}));
function createFakeClient(): {
client: RealtimeLogsClient;
publish: (data: unknown) => void;
subscribeCalls: unknown[];
closeCount: () => number;
} {
const listeners: ((data: unknown) => void)[] = [];
const subscribeCalls: unknown[] = [];
let closed = 0;
return {
subscribeCalls,
closeCount: () => closed,
publish: data => {
listeners.forEach(listener => {
listener(data);
});
},
client: {
subscribeAsync: async (args, onPublication) => {
subscribeCalls.push(args);
listeners.push(onPublication);
return { close: () => closed++ };
},
close: () => closed++,
},
};
}
function inProgressJob(): WorkflowJobResult {
const job = getMockWorkflowRunWithJobsFragment().jobs[0];
return { ...job, status: WorkflowJobStatus.InProgress };
}
describe(realtimeLogsTargetForJob, () => {
it('targets the turtle job run when there is one', () => {
const job = { ...inProgressJob(), turtleJobRun: { id: 'job-run-id' } } as WorkflowJobResult;
expect(realtimeLogsTargetForJob(job)).toEqual({
type: RealtimeLogsTargetType.JobRun,
id: 'job-run-id',
});
});
it('falls back to the build when there is no turtle job run', () => {
const job = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: { id: 'build-id' },
} as WorkflowJobResult;
expect(realtimeLogsTargetForJob(job)).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'build-id',
});
});
it('has no target when neither is present', () => {
const job = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: null,
outputs: null,
} as WorkflowJobResult;
expect(realtimeLogsTargetForJob(job)).toBeNull();
});
});
describe(WorkflowRunLogsWatcher, () => {
beforeEach(() => {
jest
.mocked(fetchAndParseLogsFromJobAsync)
.mockResolvedValue([{ logId: '1', buildStepId: 'install', msg: 'from the file' }]);
});
afterEach(() => {
jest.clearAllMocks();
});
it('subscribes once per in-progress job', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
const job = inProgressJob();
await watcher.syncJobsAsync([job]);
await watcher.syncJobsAsync([job]);
expect(fake.subscribeCalls).toHaveLength(1);
});
it('reports realtime logs as they arrive', async () => {
const fake = createFakeClient();
const onRealtimeLogs = jest.fn();
const watcher = new WorkflowRunLogsWatcher({} as any, () => fake.client, onRealtimeLogs);
await watcher.syncJobsAsync([inProgressJob()]);
fake.publish([{ logId: '2', buildStepId: 'install', msg: 'pushed' }]);
expect(onRealtimeLogs).toHaveBeenCalledTimes(1);
});
it('does not report a publication that carries no usable log lines', async () => {
const fake = createFakeClient();
const onRealtimeLogs = jest.fn();
const watcher = new WorkflowRunLogsWatcher({} as any, () => fake.client, onRealtimeLogs);
await watcher.syncJobsAsync([inProgressJob()]);
fake.publish([{ msg: 'no logId' }]);
expect(onRealtimeLogs).not.toHaveBeenCalled();
});
it('still fetches logs when the realtime logs client is unavailable', async () => {
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => null,
() => {}
);
await watcher.syncJobsAsync([inProgressJob()]);
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(1);
});
it('fetches logs on every sync while in progress, and stops once the job completes', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
const job = inProgressJob();
await watcher.syncJobsAsync([job]);
await watcher.syncJobsAsync([job]);
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(2);
await watcher.syncJobsAsync([{ ...job, status: WorkflowJobStatus.Success }]);
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(2);
});
it('does not fetch logs for a job that has not started', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
await watcher.syncJobsAsync([{ ...inProgressJob(), status: WorkflowJobStatus.New }]);
expect(fetchAndParseLogsFromJobAsync).not.toHaveBeenCalled();
});
it('closes the subscription when a job leaves in-progress', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
const job = inProgressJob();
await watcher.syncJobsAsync([job]);
expect(fake.closeCount()).toBe(0);
await watcher.syncJobsAsync([{ ...job, status: WorkflowJobStatus.Failure }]);
expect(fake.closeCount()).toBe(1);
});
it('closes the client and its subscriptions', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
await watcher.syncJobsAsync([inProgressJob()]);
watcher.close();
expect(fake.closeCount()).toBe(2);
});
});
function fileLine(logId: string, msg: string, buildStepId = 'install'): WorkflowRawLogLine {
return { logId, buildStepId, msg };
}
function stepMessages(logsState: WorkflowJobLogsState): string[] {
return Array.from(logsState.getLogs().values()).flatMap(group =>
group.logLines.map(logLine => logLine.msg)
);
}
describe(WorkflowJobLogsState, () => {
it('hides realtime lines until the file logs reach them', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('5', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first']);
});
it('shows realtime lines once a published logId also appears in the file', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
});
it('shows realtime logs when a publication repeats a logId already in the file', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('shows buffered realtime lines once the job is completed', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('9', 'pushed')]);
logsState.markCompleted();
logsState.markCompleted();
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('folds later publications once completion showed them', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('9', 'buffered')]);
logsState.markCompleted();
logsState.ingestRealtimeLogLines([fileLine('10', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'buffered', 'pushed']);
});
it('replaces the file snapshot instead of accumulating it', () => {
const logsState = new WorkflowJobLogsState();
const keyless: WorkflowRawLogLine = { buildStepId: 'install', msg: 'submission log' };
logsState.ingestFileLogLines([keyless]);
logsState.ingestFileLogLines([keyless]);
expect(stepMessages(logsState)).toEqual(['submission log']);
});
it('drops a buffered realtime line once it lands in the file', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('ignores publications that are not arrays of log lines', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
expect(logsState.ingestRealtimeLogLines({ logId: '2', msg: 'not an array' })).toBe(false);
expect(logsState.ingestRealtimeLogLines(['a string', 42, null])).toBe(false);
expect(logsState.ingestRealtimeLogLines([{ msg: 'no logId' }])).toBe(false);
expect(stepMessages(logsState)).toEqual(['first']);
});
it('deduplicates a line republished on the same channel', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('deduplicates a line republished later', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
});
});
@@ -0,0 +1,28 @@
import { WorkflowJobResult } from '../types';
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
export async function fetchRawLogsForJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<string | null> {
const turtleLogFileUrl = job.turtleJobRun?.logFileUrls?.[0];
if (turtleLogFileUrl) {
const response = await fetch(turtleLogFileUrl);
return await response.text();
}
const buildId = job.outputs?.build_id;
if (!buildId) {
return null;
}
const buildFragment = await BuildQuery.byIdAsync(state.graphqlClient, buildId, {
useCache: false,
});
const firstLogFileUrl = buildFragment.logFiles?.[0];
if (!firstLogFileUrl) {
return null;
}
const response = await fetch(firstLogFileUrl);
return await response.text();
}
@@ -0,0 +1,83 @@
import { fetchRawLogsForJobAsync } from './fetchLogs';
import { WorkflowJobResult, WorkflowLogs, WorkflowRawLogLine } from '../types';
import Log from '../../../log';
import uniqBy from '../../../utils/expodash/uniqBy';
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
export function parseLogLines(rawLogs: string): {
logLines: WorkflowRawLogLine[];
errors: Error[];
} {
const logLines: WorkflowRawLogLine[] = [];
const errors: Error[] = [];
for (const rawLogLine of rawLogs.split('\n')) {
if (!rawLogLine) {
continue;
}
try {
logLines.push(JSON.parse(rawLogLine));
} catch (err) {
errors.push(err as Error);
}
}
return { logLines, errors };
}
export function mergeLogLines<T extends WorkflowRawLogLine>(...logLineGroups: T[][]): T[] {
return uniqBy(logLineGroups.flat().reverse(), logLine => logLine.logId ?? Symbol()).reverse();
}
export function groupLogLinesIntoSteps(
logLines: WorkflowRawLogLine[],
accumulator: WorkflowLogs = new Map()
): WorkflowLogs {
for (const logLine of logLines) {
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } = logLine;
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
if (!stepKey || !stepLabel || !msg) {
continue;
}
let logGroup = accumulator.get(stepKey);
if (!logGroup) {
logGroup = { key: stepKey, label: stepLabel, logLines: [] };
accumulator.set(stepKey, logGroup);
}
if (buildStepDisplayName) {
logGroup.label = buildStepDisplayName;
}
if (logGroup.result === undefined && (marker === 'end-step' || marker === 'END_PHASE')) {
logGroup.result = result ?? '';
}
logGroup.logLines.push({ time, msg, result, marker, err });
}
return accumulator;
}
export async function fetchAndParseLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<WorkflowRawLogLine[] | null> {
const rawLogs = await fetchRawLogsForJobAsync(state, job);
if (!rawLogs) {
return null;
}
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
const { logLines, errors } = parseLogLines(rawLogs);
for (const error of errors) {
Log.debug(`Failed to parse a log line: ${error.message}`);
}
return logLines;
}
export async function fetchAndProcessLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<WorkflowLogs | null> {
const logLines = await fetchAndParseLogsFromJobAsync(state, job);
return logLines && groupLogLinesIntoSteps(logLines);
}
@@ -0,0 +1,210 @@
import { fetchAndParseLogsFromJobAsync, groupLogLinesIntoSteps, mergeLogLines } from './parseLogs';
import { WorkflowJobResult, WorkflowLogs, WorkflowRawLogLine } from '../types';
import {
RealtimeLogsTargetInput,
RealtimeLogsTargetType,
WorkflowJobStatus,
} from '../../../graphql/generated';
import Log from '../../../log';
import { RealtimeLogsClient, RealtimeLogsSubscription } from '../../../utils/centrifuge';
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
import nullthrows from 'nullthrows';
type RealtimeLogLine = WorkflowRawLogLine & { logId: string };
function isRealtimeLogLine(entry: unknown): entry is RealtimeLogLine {
return (
typeof entry === 'object' &&
entry !== null &&
typeof (entry as Partial<WorkflowRawLogLine>).logId === 'string'
);
}
export class WorkflowJobLogsState {
private fileLogIds = new Set<string>();
private realtimeLogLines: RealtimeLogLine[] = [];
private realtimeLogIds = new Set<string>();
private haveFileLogsCaughtUp = false;
private realtimeLogsRevealed = false;
private groupedLogs: WorkflowLogs = new Map();
public ingestFileLogLines(logLines: WorkflowRawLogLine[]): void {
this.fileLogIds = new Set(logLines.flatMap(logLine => (logLine.logId ? [logLine.logId] : [])));
if (
!this.haveFileLogsCaughtUp &&
this.realtimeLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
) {
this.haveFileLogsCaughtUp = true;
}
this.realtimeLogLines = this.realtimeLogLines.filter(
logLine => !this.fileLogIds.has(logLine.logId)
);
this.realtimeLogIds = new Set(this.realtimeLogLines.map(logLine => logLine.logId));
if (this.haveFileLogsCaughtUp) {
this.realtimeLogsRevealed = true;
}
this.groupedLogs = groupLogLinesIntoSteps(
mergeLogLines(logLines, this.realtimeLogsRevealed ? this.realtimeLogLines : [])
);
}
public ingestRealtimeLogLines(data: unknown): boolean {
if (!Array.isArray(data)) {
return false;
}
const publishedLogLines = data.filter(isRealtimeLogLine);
if (publishedLogLines.length === 0) {
return false;
}
if (
!this.haveFileLogsCaughtUp &&
publishedLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
) {
this.haveFileLogsCaughtUp = true;
}
const newLogLines = publishedLogLines.filter(
logLine => !this.fileLogIds.has(logLine.logId) && !this.realtimeLogIds.has(logLine.logId)
);
for (const logLine of newLogLines) {
this.realtimeLogLines.push(logLine);
this.realtimeLogIds.add(logLine.logId);
}
if (!this.realtimeLogsRevealed) {
if (this.haveFileLogsCaughtUp) {
this.revealRealtimeLogs();
}
} else if (newLogLines.length > 0) {
groupLogLinesIntoSteps(newLogLines, this.groupedLogs);
}
return true;
}
public markCompleted(): void {
this.revealRealtimeLogs();
}
public getLogs(): WorkflowLogs {
return this.groupedLogs;
}
private revealRealtimeLogs(): void {
if (this.realtimeLogsRevealed) {
return;
}
this.realtimeLogsRevealed = true;
groupLogLinesIntoSteps(this.realtimeLogLines, this.groupedLogs);
}
}
type TrackedJob = {
logsState: WorkflowJobLogsState;
subscription: RealtimeLogsSubscription | null;
};
export function realtimeLogsTargetForJob(job: WorkflowJobResult): RealtimeLogsTargetInput | null {
if (job.turtleJobRun?.id) {
return { type: RealtimeLogsTargetType.JobRun, id: job.turtleJobRun.id };
}
const buildId = job.turtleBuild?.id ?? job.outputs?.build_id;
if (buildId) {
return { type: RealtimeLogsTargetType.Build, id: buildId };
}
return null;
}
export class WorkflowRunLogsWatcher {
private readonly trackedJobs = new Map<string, TrackedJob>();
private realtimeLogsClient?: RealtimeLogsClient | null;
constructor(
private readonly graphqlClient: ExpoGraphqlClient,
private readonly createRealtimeLogsClient: () => RealtimeLogsClient | null,
private readonly onRealtimeLogs: () => void
) {}
public async syncJobsAsync(
jobs: WorkflowJobResult[]
): Promise<Map<string, WorkflowJobLogsState>> {
await Promise.all(jobs.map(job => this.syncJobAsync(job)));
return new Map(
jobs.map(job => [
job.id,
nullthrows(
this.trackedJobs.get(job.id),
'job has to be in tracked jobs after it was synced'
).logsState,
])
);
}
public close(): void {
for (const trackedJob of this.trackedJobs.values()) {
trackedJob.subscription?.close();
trackedJob.subscription = null;
}
this.realtimeLogsClient?.close();
}
private async syncJobAsync(job: WorkflowJobResult): Promise<void> {
let trackedJob = this.trackedJobs.get(job.id);
if (!trackedJob) {
trackedJob = { logsState: new WorkflowJobLogsState(), subscription: null };
this.trackedJobs.set(job.id, trackedJob);
}
const workflowInProgress = job.status === WorkflowJobStatus.InProgress;
if (!workflowInProgress) {
trackedJob.subscription?.close();
trackedJob.subscription = null;
return;
}
await Promise.all([
trackedJob.subscription ? Promise.resolve() : this.subscribeAsync(job, trackedJob),
this.fetchLogsAsync(job, trackedJob),
]);
}
private async fetchLogsAsync(job: WorkflowJobResult, trackedJob: TrackedJob): Promise<void> {
try {
const logLines = await fetchAndParseLogsFromJobAsync(
{ graphqlClient: this.graphqlClient },
job
);
if (logLines) {
trackedJob.logsState.ingestFileLogLines(logLines);
}
} catch (err: any) {
Log.debug(`Failed to fetch logs for job ${job.id}: ${err.message}`);
}
}
private getRealtimeLogsClient(): RealtimeLogsClient | null {
if (this.realtimeLogsClient === undefined) {
this.realtimeLogsClient = this.createRealtimeLogsClient();
}
return this.realtimeLogsClient;
}
private async subscribeAsync(job: WorkflowJobResult, trackedJob: TrackedJob): Promise<void> {
const target = realtimeLogsTargetForJob(job);
if (!target) {
return;
}
const realtimeLogsClient = this.getRealtimeLogsClient();
if (!realtimeLogsClient) {
return;
}
trackedJob.subscription = await realtimeLogsClient.subscribeAsync({ target }, data => {
if (trackedJob.logsState.ingestRealtimeLogLines(data)) {
this.onRealtimeLogs();
}
});
}
}
@@ -1,11 +1,11 @@
import { Choice } from 'prompts';
import { WorkflowJobResult, WorkflowLogs } from './types';
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
import {
choiceFromWorkflowJob,
choiceFromWorkflowRun,
choicesFromWorkflowLogs,
fetchAndProcessLogsFromJobAsync,
processWorkflowRuns,
} from './utils';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -42,19 +42,26 @@ export type WorkflowRunWithJobsResult = WorkflowRunResult & {
logs?: string;
};
export type WorkflowLogLine = {
time: string;
msg: string;
export type WorkflowRawLogLine = {
logId?: string;
buildStepId?: string;
buildStepDisplayName?: string;
phase?: string;
time?: string;
msg?: string;
result?: string;
marker?: string;
err?: any;
};
export type WorkflowLogs = Map<
string,
{
key: string;
label: string;
logLines: WorkflowLogLine[];
}
>;
export type WorkflowLogLine = Pick<WorkflowRawLogLine, 'time' | 'result' | 'marker' | 'err'> &
Required<Pick<WorkflowRawLogLine, 'msg'>>;
export type StepLogs = {
key: string;
label: string;
result?: string;
logLines: WorkflowLogLine[];
};
export type WorkflowLogs = Map<string, StepLogs>;
@@ -1,7 +1,7 @@
import chalk from 'chalk';
import * as fs from 'node:fs';
import { fetchRawLogsForBuildJobAsync, fetchRawLogsForCustomJobAsync } from './fetchLogs';
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
import {
WorkflowJobResult,
WorkflowLogLine,
@@ -11,20 +11,22 @@ import {
} from './types';
import {
WorkflowJobStatus,
WorkflowJobType,
WorkflowRunByIdQuery,
WorkflowRunByIdWithJobsQuery,
WorkflowRunFragment,
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import { WorkflowRunLogsWatcher } from './logs/watcher';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { ora, updateSpinnerText } from '../../ora';
import { Choice } from '../../prompts';
import formatFields from '../../utils/formatFields';
import { createRealtimeLogsClient } from '../../utils/centrifuge';
import { sleepAsync } from '../../utils/promise';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
import nullthrows from 'nullthrows';
export function computeTriggerInfoForWorkflowRun(run: WorkflowRunFragment): {
triggerType: WorkflowTriggerType;
@@ -85,19 +87,13 @@ export function choicesFromWorkflowLogs(
logs: WorkflowLogs
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
return Array.from(logs.values())
.map(({ key, label, logLines }) => {
const stepStatus =
logLines?.filter(
(line: WorkflowLogLine) => line.marker === 'end-step' || line.marker === 'END_PHASE'
)[0]?.result ?? '';
return {
title: `${label} - ${stepStatus}`,
name: label,
status: stepStatus,
value: key,
logLines,
};
})
.map(({ key, label, result, logLines }) => ({
title: `${label} - ${result ?? ''}`,
name: label,
status: result ?? '',
value: key,
logLines,
}))
.filter(step => step.status !== 'skipped');
}
@@ -121,52 +117,6 @@ export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunRes
});
}
export async function fetchAndProcessLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<WorkflowLogs | null> {
let rawLogs: string | null;
switch (job.type) {
case WorkflowJobType.Build:
case WorkflowJobType.Repack:
rawLogs = await fetchRawLogsForBuildJobAsync(state, job);
break;
default:
rawLogs = await fetchRawLogsForCustomJobAsync(job);
break;
}
if (!rawLogs) {
return null;
}
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
const logs: WorkflowLogs = new Map();
rawLogs.split('\n').forEach((line, index) => {
Log.debug(`line ${index} = ${JSON.stringify(line, null, 2)}`);
try {
const parsedLine = JSON.parse(line);
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } =
parsedLine;
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
if (stepKey && stepLabel) {
if (!logs.has(stepKey)) {
logs.set(stepKey, {
key: stepKey,
label: stepLabel,
logLines: [],
});
}
const logGroup = logs.get(stepKey)!;
if (buildStepDisplayName) {
logGroup.label = buildStepDisplayName;
}
logGroup.logLines.push({ time, msg, result, marker, err });
}
} catch {}
});
return logs;
}
function descriptionForJobStatus(status: WorkflowJobStatus): string {
switch (status) {
case WorkflowJobStatus.New:
@@ -188,82 +138,106 @@ function descriptionForJobStatus(status: WorkflowJobStatus): string {
}
}
export async function infoForActiveWorkflowRunAsync(
function isJobCompleted(status: WorkflowJobStatus): boolean {
switch (status) {
case WorkflowJobStatus.Success:
case WorkflowJobStatus.Failure:
case WorkflowJobStatus.Canceled:
case WorkflowJobStatus.Skipped:
return true;
case WorkflowJobStatus.New:
case WorkflowJobStatus.InProgress:
case WorkflowJobStatus.ActionRequired:
case WorkflowJobStatus.PendingCancel:
return false;
}
}
type WorkflowRunWithJobs = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'];
type JobWithLogs = {
job: WorkflowRunWithJobs['jobs'][number];
logs: WorkflowLogs;
};
function stepLogTail(
step: { logLines?: WorkflowLogLine[] },
maxLogLines: number // -1 means no limit
): string[] {
const logLines = step.logLines ?? [];
const tail = maxLogLines === -1 ? logLines : logLines.slice(-maxLogLines);
return tail.map(line => line.msg);
}
export async function logsForFailedWorkflowRunAsync(
graphqlClient: ExpoGraphqlClient,
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
workflowRun: WorkflowRunWithJobs
): Promise<JobWithLogs[]> {
const jobs = workflowRun.jobs.filter(job => job.status === WorkflowJobStatus.Failure);
return await Promise.all(
jobs.map(async job => {
const maybeLogs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
return { job, logs: maybeLogs ?? new Map() };
})
);
}
export function formatActiveWorkflowRun(
jobLogs: JobWithLogs[],
maxLogLines: number = 5 // -1 means no limit
): Promise<string> {
const statusLines = [];
): string {
const statusValues = [];
for (const job of workflowRun.jobs) {
for (const { job, logs } of jobLogs) {
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Job', value: job.name });
statusValues.push({ label: ' Status', value: descriptionForJobStatus(job.status) });
if (job.status !== WorkflowJobStatus.InProgress) {
continue;
}
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
if (steps.length > 0) {
const currentStep = steps[steps.length - 1];
statusValues.push({ label: ' Current step', value: currentStep.name });
if (currentStep?.logLines?.length) {
statusValues.push({ label: ' Current logs', value: '' });
const currentLogs =
currentStep.logLines
?.map(line => line.msg)
.filter((_, index) => {
if (maxLogLines === -1) {
return true;
}
return index > (currentStep.logLines?.length ?? 0) - maxLogLines;
}) ?? [];
for (const log of currentLogs) {
statusValues.push({ label: '', value: log });
}
}
}
}
statusValues.push({ label: '', value: '' });
statusLines.push(formatFields(statusValues));
return statusLines.join('\n');
}
export async function infoForFailedWorkflowRunAsync(
graphqlClient: ExpoGraphqlClient,
workflowRun: WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'],
maxLogLines: number = -1 // -1 means no limit
): Promise<string> {
const statusLines = [];
const statusValues = [];
const logLinesToKeep = maxLogLines === -1 ? Infinity : maxLogLines;
for (const job of workflowRun.jobs) {
if (job.status !== WorkflowJobStatus.Failure) {
const steps = choicesFromWorkflowLogs(logs);
if (steps.length === 0) {
continue;
}
const logs = await fetchAndProcessLogsFromJobAsync({ graphqlClient }, job);
const steps = logs ? choicesFromWorkflowLogs(logs) : [];
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Failed job', value: job.name });
if (steps.length > 0) {
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
if (failedStep) {
const logs = failedStep.logLines?.map(line => line.msg).slice(-logLinesToKeep) ?? [];
statusValues.push({ label: ' Failed step', value: failedStep.name });
statusValues.push({
label: ' Logs for failed step',
value: '',
});
for (const log of logs) {
statusValues.push({ label: '', value: log });
}
const currentStep = steps[steps.length - 1];
statusValues.push({ label: ' Current step', value: currentStep.name });
if (currentStep.logLines?.length) {
statusValues.push({ label: ' Current logs', value: '' });
for (const log of stepLogTail(currentStep, maxLogLines)) {
statusValues.push({ label: '', value: log });
}
}
}
statusValues.push({ label: '', value: '' });
statusLines.push(formatFields(statusValues));
return statusLines.join('\n');
return formatFields(statusValues);
}
export function formatFailedWorkflowRun(
jobLogs: JobWithLogs[],
maxLogLines: number = -1 // -1 means no limit
): string {
const statusValues = [];
for (const { job, logs } of jobLogs) {
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Failed job', value: job.name });
const steps = choicesFromWorkflowLogs(logs);
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
if (!failedStep) {
continue;
}
statusValues.push({ label: ' Failed step', value: failedStep.name });
statusValues.push({ label: ' Logs for failed step', value: '' });
for (const log of stepLogTail(failedStep, maxLogLines)) {
statusValues.push({ label: '', value: log });
}
}
statusValues.push({ label: '', value: '' });
return formatFields(statusValues);
}
export async function fileExistsAsync(filePath: string): Promise<boolean> {
return await fs.promises
.access(filePath, fs.constants.F_OK)
@@ -312,65 +286,111 @@ export async function showWorkflowStatusAsync(
stream: spinnerUsesStdErr ? process.stderr : process.stdout,
text: '',
}).start();
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting to start:}`;
updateSpinnerText(spinner, {
prefixText: chalk`{bold.yellow Workflow run is waiting to start:}`,
});
let failedFetchesCount = 0;
while (true) {
try {
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
failedFetchesCount = 0;
switch (workflowRun.status) {
case WorkflowRunStatus.New:
break;
case WorkflowRunStatus.InProgress: {
spinner.prefixText = chalk`{bold.green Workflow run is in progress:}`;
spinner.text = await infoForActiveWorkflowRunAsync(graphqlClient, workflowRun, 5);
break;
}
case WorkflowRunStatus.ActionRequired:
spinner.prefixText = chalk`{bold.yellow Workflow run is waiting for action:}`;
break;
case WorkflowRunStatus.Canceled:
spinner.prefixText = chalk`{bold.yellow Workflow has been canceled.}`;
spinner.stopAndPersist();
return workflowRun;
case WorkflowRunStatus.Failure: {
spinner.prefixText = chalk`{bold.red Workflow has failed.}`;
const failedInfo = await infoForFailedWorkflowRunAsync(graphqlClient, workflowRun, 30);
spinner.fail(failedInfo);
return workflowRun;
}
case WorkflowRunStatus.Success:
spinner.prefixText = chalk`{bold.green Workflow has completed successfully.}`;
spinner.text = '';
spinner.succeed('');
return workflowRun;
}
if (!waitForCompletion) {
if (spinner.isSpinning) {
spinner.stopAndPersist();
}
return workflowRun;
}
} catch {
spinner.text = '⚠ Failed to fetch the workflow run status. Check your network connection.';
failedFetchesCount += 1;
if (failedFetchesCount > 6) {
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
process.exit(workflowRunExitCodes.WAIT_ABORTED);
}
const watcher = new WorkflowRunLogsWatcher(
graphqlClient,
() => createRealtimeLogsClient(graphqlClient),
() => {
renderActiveWorkflowRun();
}
);
let failedFetchesCount = 0;
let renderActiveWorkflowRun = (): void => {};
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
try {
while (true) {
try {
const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, {
useCache: false,
});
failedFetchesCount = 0;
switch (workflowRun.status) {
case WorkflowRunStatus.New:
break;
case WorkflowRunStatus.InProgress: {
updateSpinnerText(spinner, {
prefixText: chalk`{bold.green Workflow run is in progress:}`,
});
const logsStates = await watcher.syncJobsAsync(workflowRun.jobs);
for (const job of workflowRun.jobs) {
if (isJobCompleted(job.status)) {
nullthrows(
logsStates.get(job.id),
'syncJobsAsync must have been called before markCompleted'
).markCompleted();
}
}
renderActiveWorkflowRun = () => {
const text = formatActiveWorkflowRun(
workflowRun.jobs.map(job => ({
job,
logs: nullthrows(
logsStates.get(job.id),
'syncJobsAsync must have been called before getLogs'
).getLogs(),
}))
);
updateSpinnerText(spinner, { text });
};
renderActiveWorkflowRun();
break;
}
case WorkflowRunStatus.ActionRequired:
updateSpinnerText(spinner, {
prefixText: chalk`{bold.yellow Workflow run is waiting for action:}`,
});
break;
case WorkflowRunStatus.Canceled:
updateSpinnerText(spinner, {
prefixText: chalk`{bold.yellow Workflow has been canceled.}`,
});
spinner.stopAndPersist();
return workflowRun;
case WorkflowRunStatus.Failure: {
updateSpinnerText(spinner, {
prefixText: chalk`{bold.red Workflow has failed.}`,
});
const jobLogs = await logsForFailedWorkflowRunAsync(graphqlClient, workflowRun);
spinner.fail(formatFailedWorkflowRun(jobLogs, 30));
return workflowRun;
}
case WorkflowRunStatus.Success:
updateSpinnerText(spinner, {
prefixText: chalk`{bold.green Workflow has completed successfully.}`,
});
spinner.succeed('');
return workflowRun;
}
if (!waitForCompletion) {
if (spinner.isSpinning) {
spinner.stopAndPersist();
}
return workflowRun;
}
} catch {
updateSpinnerText(spinner, {
text: '⚠ Failed to fetch the workflow run status. Check your network connection.',
});
failedFetchesCount += 1;
if (failedFetchesCount > 6) {
spinner.fail('Failed to fetch the workflow run status 6 times in a row. Aborting wait.');
process.exit(workflowRunExitCodes.WAIT_ABORTED);
}
}
await sleepAsync(10 /* seconds */ * 1000 /* milliseconds */);
}
} finally {
watcher.close();
}
}
@@ -48,6 +48,7 @@ function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): De
return {
id: 'session-123',
name: null,
tags: [],
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
platform: AppPlatform.Ios,
@@ -143,6 +144,7 @@ describe(SimulatorGet, () => {
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
id: 'session-123',
name: undefined,
tags: [],
type: 'web-preview-only',
status: DeviceRunSessionStatus.InProgress,
platform: AppPlatform.Ios,
@@ -110,6 +110,7 @@ function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): De
return {
id: 'session-123',
name: null,
tags: [],
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
platform: AppPlatform.Ios,
@@ -332,12 +333,12 @@ describe(Simulator, () => {
);
});
it('creates a ServeSim session for --type web-preview-only', async () => {
it('creates a WebPreviewOnly session for --type web-preview-only', async () => {
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({
type: DeviceRunSessionType.ServeSim,
type: DeviceRunSessionType.WebPreviewOnly,
remoteConfig: {
__typename: 'ServeSimRunSessionRemoteConfig',
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
previewUrl: 'https://preview.example.test',
},
})
@@ -354,7 +355,7 @@ describe(Simulator, () => {
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ type: DeviceRunSessionType.ServeSim })
expect.objectContaining({ type: DeviceRunSessionType.WebPreviewOnly })
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('https://preview.example.test'));
});
@@ -613,6 +614,19 @@ describe(Simulator, () => {
);
});
it('warns that Android emulator support is still in development before creating a session', async () => {
const { command } = createCommand(['--platform', 'android', '--non-interactive']);
await command.runAsync();
expect(Log.warn).toHaveBeenCalledWith(
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
);
expect(jest.mocked(Log.warn).mock.invocationCallOrder[0]).toBeLessThan(
mockCreateDeviceRunSessionAsync.mock.invocationCallOrder[0]
);
});
it.each([
['ios', AppPlatform.Ios],
['android', AppPlatform.Android],
@@ -696,6 +710,36 @@ describe(Simulator, () => {
);
});
it('forwards repeated tags', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--tag',
'variant:pro',
'--tag',
' nightly ',
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ tags: ['variant:pro', 'nightly'] })
);
});
it('omits tags when every tag is blank', async () => {
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--tag', ' ']);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.not.objectContaining({ tags: expect.anything() })
);
});
it.each([
['--launch-arg', '--uitesting'],
['--open-url', 'exp://example.test'],
@@ -42,6 +42,7 @@ function makeSession(overrides: Partial<DeviceRunSessionNode> = {}): DeviceRunSe
return {
id: 'session-123',
name: null,
tags: [],
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
platform: AppPlatform.Ios,
@@ -132,6 +133,7 @@ describe(SimulatorList, () => {
{
id: 'session-123',
name: undefined,
tags: [],
type: 'web-preview-only',
status: DeviceRunSessionStatus.InProgress,
platform: AppPlatform.Ios,
@@ -168,6 +170,10 @@ describe(SimulatorList, () => {
'ios',
'--name',
'checkout',
'--tag',
'variant:pro',
'--tag',
'nightly',
'--limit',
'25',
'--after',
@@ -181,13 +187,33 @@ describe(SimulatorList, () => {
after: 'page-cursor',
filter: {
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
types: [DeviceRunSessionType.Appium, DeviceRunSessionType.ServeSim],
types: [DeviceRunSessionType.Appium, DeviceRunSessionType.WebPreviewOnly],
platforms: [AppPlatform.Ios],
name: 'checkout',
tags: ['variant:pro', 'nightly'],
},
});
});
it('prints a tags row for every session, tagged or not', async () => {
mockListByAppIdAsync.mockResolvedValue(
makeConnection([
makeSession({ id: 'session-1', tags: ['variant:pro', 'nightly'] }),
makeSession({ id: 'session-2' }),
])
);
const { command } = createCommand([]);
await command.runAsync();
const printed = jest
.mocked(Log.log)
.mock.calls.map(([entry]) => String(entry))
.join('\n');
expect(printed).toContain('Tags: variant:pro, nightly');
expect(printed).toContain('Tags: none');
});
it('prints a name row for every session, named or not', async () => {
mockListByAppIdAsync.mockResolvedValue(
makeConnection([
@@ -87,6 +87,7 @@ export default class SimulatorGet extends EasCommand {
printJsonOnlyOutput({
id: session.id,
name: session.name ?? undefined,
tags: session.tags,
type: deviceRunSessionTypeToFlagValue(session.type),
status: session.status,
platform: session.platform,
@@ -123,6 +124,7 @@ function formatSessionFields(session: DeviceRunSessionById, deviceRunSessionUrl:
return formatFields([
{ label: 'ID', value: session.id },
{ label: 'Name', value: formatNullable(session.name) },
{ label: 'Tags', value: session.tags.length > 0 ? session.tags.join(', ') : 'none' },
{ label: 'Type', value: session.type },
{ label: 'Status', value: session.status },
{ label: 'Platform', value: session.platform },
@@ -71,6 +71,11 @@ export default class Simulator extends EasCommand {
description:
'Human-readable name for the simulator session, shown in eas simulator:list and on expo.dev. Defaults to unnamed.',
}),
tag: Flags.string({
description:
'Label used to group simulator sessions, for example one per app variant. Repeat for multiple tags. Stored lowercased.',
multiple: true,
}),
device: Flags.string({
description:
'Virtual device to start for the session. On iOS, a Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android, an AVD hardware profile id (e.g. "pixel_7"). Defaults to a device chosen by the runner.',
@@ -179,6 +184,7 @@ export default class Simulator extends EasCommand {
// The server rejects blank names, so trim here and treat a whitespace-only
// --name as if it had been omitted rather than surfacing a validation error.
const name = flags.name?.trim() || undefined;
const tags = flags.tag?.map(tag => tag.trim()).filter(tag => tag.length > 0);
const deviceIdentifier = flags.device?.trim() || undefined;
const buildId = flags['build-id']?.trim() || undefined;
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
@@ -212,6 +218,12 @@ export default class Simulator extends EasCommand {
}
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
if (platform === AppPlatform.Android) {
Log.warn(
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
);
Log.newLine();
}
const expoGoSdkVersion = flags['expo-go']
? await resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: sdkVersionFromFlag })
: undefined;
@@ -233,6 +245,7 @@ export default class Simulator extends EasCommand {
const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
appId: projectId,
name,
...(tags?.length ? { tags } : {}),
platform,
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
packageVersion: flags['package-version'],
@@ -74,6 +74,10 @@ export default class SimulatorList extends EasCommand {
name: Flags.string({
description: 'Filter by session name (case-insensitive prefix match)',
}),
tag: Flags.string({
description: 'Filter by tag (repeatable). A session must carry every tag listed.',
multiple: true,
}),
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
after: Flags.string({
description:
@@ -115,6 +119,9 @@ export default class SimulatorList extends EasCommand {
if (flags.name) {
filter.name = flags.name;
}
if (flags.tag && flags.tag.length > 0) {
filter.tags = flags.tag;
}
const limit = flags.limit ?? DEFAULT_LIMIT;
@@ -140,6 +147,7 @@ export default class SimulatorList extends EasCommand {
sessions: sessions.map(session => ({
id: session.id,
name: session.name ?? undefined,
tags: session.tags,
type: deviceRunSessionTypeToFlagValue(session.type),
status: session.status,
platform: session.platform,
@@ -173,6 +181,7 @@ export default class SimulatorList extends EasCommand {
const lines = [
`ID: ${session.id}`,
`Name: ${session.name ?? 'null'}`,
`Tags: ${session.tags.length > 0 ? session.tags.join(', ') : 'none'}`,
`Type: ${session.type}`,
`Status: ${session.status}`,
`Platform: ${session.platform}`,
@@ -0,0 +1,171 @@
import spawnAsync from '@expo/spawn-async';
import { Config } from '@oclif/core';
import { fileExistsAsync, maybeReadStdinAsync } from '../../../commandUtils/workflow/utils';
import { WorkflowRevisionMutation } from '../../../graphql/mutations/WorkflowRevisionMutation';
import { WorkflowRunMutation } from '../../../graphql/mutations/WorkflowRunMutation';
import Log from '../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
import { uploadAccountScopedFileAsync } from '../../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../../project/uploadAccountScopedProjectSourceAsync';
import { WorkflowFile } from '../../../utils/workflowFile';
import WorkflowRun from '../run';
jest.mock('@expo/spawn-async', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../graphql/mutations/WorkflowRevisionMutation');
jest.mock('../../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../../project/projectUtils');
jest.mock('../../../project/uploadAccountScopedFileAsync');
jest.mock('../../../project/uploadAccountScopedProjectSourceAsync');
jest.mock('../../../utils/workflowFile');
jest.mock('../../../commandUtils/workflow/utils', () => ({
...jest.requireActual('../../../commandUtils/workflow/utils'),
fileExistsAsync: jest.fn(),
maybeReadStdinAsync: jest.fn(),
}));
jest.mock('../../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
newLine: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
link: jest.fn((url: string) => url),
}));
const mockSpawn = jest.mocked(spawnAsync);
const mockValidate = jest.mocked(WorkflowRevisionMutation.validateWorkflowYamlConfigAsync);
const mockGetOrCreateRevision = jest.mocked(
WorkflowRevisionMutation.getOrCreateWorkflowRevisionFromGitRefAsync
);
const mockCreateFromGitRef = jest.mocked(WorkflowRunMutation.createWorkflowRunFromGitRefAsync);
const mockCreateRun = jest.mocked(WorkflowRunMutation.createWorkflowRunAsync);
const mockGetOwner = jest.mocked(getOwnerAccountForProjectIdAsync);
const mockUploadProject = jest.mocked(uploadAccountScopedProjectSourceAsync);
const mockUploadFile = jest.mocked(uploadAccountScopedFileAsync);
const mockReadWorkflow = jest.mocked(WorkflowFile.readWorkflowFileContentsAsync);
const mockFileExists = jest.mocked(fileExistsAsync);
const mockReadStdin = jest.mocked(maybeReadStdinAsync);
describe('WorkflowRun --ssh', () => {
let mockConfig: Config;
let exitSpy: jest.SpyInstance;
beforeAll(async () => {
mockConfig = new Config({ root: __dirname });
mockConfig.runHook = async () => ({ failures: [], successes: [] });
});
beforeEach(() => {
jest.clearAllMocks();
exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
mockReadStdin.mockResolvedValue(null);
mockGetOwner.mockResolvedValue({ id: 'acct-1', name: 'testuser' } as never);
mockValidate.mockResolvedValue(undefined as never);
mockReadWorkflow.mockResolvedValue({
filePath: '.eas/workflows/test.yml',
yamlConfig: 'jobs:\n a:\n steps: []\n',
} as never);
mockFileExists.mockResolvedValue(false);
mockUploadProject.mockResolvedValue({ projectArchiveBucketKey: 'archive-key' } as never);
mockUploadFile.mockResolvedValue({ fileBucketKey: 'file-key' } as never);
mockCreateRun.mockResolvedValue({ id: 'run-1' } as never);
mockCreateFromGitRef.mockResolvedValue({ id: 'run-git-1' } as never);
mockSpawn.mockResolvedValue({ output: ['abc123\n'] } as never);
mockGetOrCreateRevision.mockResolvedValue({
id: 'rev-1',
yamlConfig: 'jobs:\n a:\n steps: []\n',
} as never);
});
afterEach(() => {
exitSpy.mockRestore();
});
function createCommand(flags: {
ssh?: boolean;
'ssh-idle-timeout'?: number;
ref?: string;
}): WorkflowRun {
const command = new WorkflowRun(['.eas/workflows/test.yml'], mockConfig);
// @ts-expect-error getContextAsync/parse are protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
getDynamicPrivateProjectConfigAsync: async () => ({
projectId: 'app-1',
exp: { slug: 'testapp' },
}),
loggedIn: { graphqlClient: {} },
vcsClient: { getRootPathAsync: async () => '/test/project' },
projectDir: '/test/project',
});
// @ts-expect-error parse is protected on the oclif base
jest.spyOn(command, 'parse').mockResolvedValue({
flags: {
json: false,
wait: false,
'non-interactive': true,
input: undefined,
ref: flags.ref,
ssh: flags.ssh ?? false,
'ssh-idle-timeout': flags['ssh-idle-timeout'],
},
args: { file: '.eas/workflows/test.yml' },
});
return command;
}
it('passes ssh input into createWorkflowRunAsync and logs that SSH is enabled', async () => {
await expect(createCommand({ ssh: true, 'ssh-idle-timeout': 120 }).runAsync()).rejects.toThrow(
'process.exit:0'
);
expect(mockCreateRun).toHaveBeenCalledWith(
{},
expect.objectContaining({
workflowRunInput: expect.objectContaining({
ssh: { idleTimeoutSeconds: 120 },
}),
})
);
expect(Log.log).toHaveBeenCalledWith(
'SSH enabled. Each VM job logs the `eas workflow:ssh` command to connect.'
);
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('does not log the SSH banner when --ssh is unset', async () => {
await expect(createCommand({ ssh: false }).runAsync()).rejects.toThrow('process.exit:0');
expect(mockCreateRun).toHaveBeenCalledWith(
{},
expect.objectContaining({
workflowRunInput: expect.objectContaining({
ssh: null,
}),
})
);
expect(Log.log).not.toHaveBeenCalledWith(
'SSH enabled. Each VM job logs the `eas workflow:ssh` command to connect.'
);
});
it('passes ssh input into createWorkflowRunFromGitRefAsync when --ref is set', async () => {
await expect(createCommand({ ssh: true, ref: 'main' }).runAsync()).rejects.toThrow(
'process.exit:0'
);
expect(mockCreateFromGitRef).toHaveBeenCalledWith(
{},
expect.objectContaining({
ssh: { idleTimeoutSeconds: undefined },
})
);
expect(Log.log).toHaveBeenCalledWith(
'SSH enabled. Each VM job logs the `eas workflow:ssh` command to connect.'
);
});
});
@@ -3,6 +3,31 @@ import {
parseJsonInputs,
parseWorkflowInputsFromYaml,
} from '../../../commandUtils/workflow/inputs';
import { resolveWorkflowRunSshInput } from '../run';
describe(resolveWorkflowRunSshInput, () => {
it('is null when --ssh is not set', () => {
expect(resolveWorkflowRunSshInput({ ssh: false, idleTimeoutSeconds: undefined })).toBeNull();
});
it('forwards ssh with an undefined timeout when --ssh is set alone', () => {
expect(resolveWorkflowRunSshInput({ ssh: true, idleTimeoutSeconds: undefined })).toEqual({
idleTimeoutSeconds: undefined,
});
});
it('forwards the requested idle timeout when both flags are set', () => {
expect(resolveWorkflowRunSshInput({ ssh: true, idleTimeoutSeconds: 60 })).toEqual({
idleTimeoutSeconds: 60,
});
});
it('rejects --ssh-idle-timeout without --ssh', () => {
expect(() => resolveWorkflowRunSshInput({ ssh: false, idleTimeoutSeconds: 60 })).toThrow(
'--ssh-idle-timeout requires --ssh.'
);
});
});
describe('parseInputs', () => {
it('should parse single key=value pair', () => {
@@ -0,0 +1,387 @@
import spawnAsync from '@expo/spawn-async';
import { Config } from '@oclif/core';
import fs from 'node:fs';
import { WorkflowJobSshQuery } from '../../../graphql/queries/WorkflowJobSshQuery';
import Log from '../../../log';
import { sleepAsync } from '../../../utils/promise';
import WorkflowSsh, {
parseSshArgv,
resolveSshConnectStatus,
splitConnectionHost,
sshHostAliasForResource,
terminalSshStatusMessage,
} from '../ssh';
jest.mock('@expo/spawn-async', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../../graphql/queries/WorkflowJobSshQuery');
jest.mock('../../../log', () => ({
__esModule: true,
default: { log: jest.fn(), error: jest.fn(), newLine: jest.fn() },
}));
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = { start: jest.fn(), fail: jest.fn(), succeed: jest.fn(), stop: jest.fn() };
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../utils/promise', () => ({ sleepAsync: jest.fn() }));
jest.mock('node:fs', () => {
const actual = jest.requireActual('node:fs');
return {
...actual,
promises: { ...actual.promises, mkdtemp: jest.fn(), writeFile: jest.fn(), rm: jest.fn() },
};
});
describe(parseSshArgv, () => {
it('takes the first token as the resource id and the rest as the passthrough command', () => {
expect(parseSshArgv(['job-id', 'ls', '-la'])).toEqual({
showConnect: false,
resourceId: 'job-id',
command: ['ls', '-la'],
});
});
it('detects --show-connect before the id and strips it', () => {
expect(parseSshArgv(['--show-connect', 'job-id'])).toEqual({
showConnect: true,
resourceId: 'job-id',
command: [],
});
});
it('detects --show-connect=true before the id', () => {
expect(parseSshArgv(['--show-connect=true', 'job-id'])).toEqual({
showConnect: true,
resourceId: 'job-id',
command: [],
});
});
it('leaves --show-connect after the id in the passthrough command', () => {
expect(parseSshArgv(['job-id', '--show-connect'])).toEqual({
showConnect: false,
resourceId: 'job-id',
command: ['--show-connect'],
});
});
it('throws on an unknown flag before the id', () => {
expect(() => parseSshArgv(['--show-conect', 'job-id'])).toThrow('Unknown flag');
});
it('reports an undefined resource id when none is given', () => {
expect(parseSshArgv([])).toEqual({ showConnect: false, resourceId: undefined, command: [] });
expect(parseSshArgv(['--show-connect'])).toEqual({
showConnect: true,
resourceId: undefined,
command: [],
});
});
});
describe(resolveSshConnectStatus, () => {
it('is unknown when the resource did not resolve', () => {
expect(resolveSshConnectStatus(null)).toBe('unknown');
});
it('is not-enabled when the job did not request ssh', () => {
expect(
resolveSshConnectStatus({ sshRequested: false, jobCompleted: false, session: null })
).toBe('not-enabled');
});
it('is ended when ssh was requested, the job finished, and no session remains', () => {
expect(resolveSshConnectStatus({ sshRequested: true, jobCompleted: true, session: null })).toBe(
'ended'
);
});
it('is pending when ssh was requested and the job is still running without a session', () => {
expect(
resolveSshConnectStatus({ sshRequested: true, jobCompleted: false, session: null })
).toBe('pending');
});
it('is pending while the worker is reconnecting', () => {
expect(
resolveSshConnectStatus({
sshRequested: true,
jobCompleted: false,
session: {
connectionConfig: { host: 'uptermd.upterm.dev', secret: 'tok', reconnecting: true },
},
})
).toBe('pending');
});
it('is ready when the session has a connection config', () => {
expect(
resolveSshConnectStatus({
sshRequested: true,
jobCompleted: false,
session: {
connectionConfig: { host: 'uptermd.upterm.dev', secret: 'tok', reconnecting: false },
},
})
).toBe('ready');
});
});
describe(splitConnectionHost, () => {
it('returns the host with no port for a plain hostname', () => {
expect(splitConnectionHost('relay.expo.dev')).toEqual({ hostname: 'relay.expo.dev' });
});
it('splits a host:port into host and numeric port', () => {
expect(splitConnectionHost('relay.expo.dev:8022')).toEqual({
hostname: 'relay.expo.dev',
port: 8022,
});
});
});
describe(terminalSshStatusMessage, () => {
it('has no message while the session can still open', () => {
expect(terminalSshStatusMessage('pending', 'job-1')).toBeNull();
expect(terminalSshStatusMessage('ready', 'job-1')).toBeNull();
});
it('distinguishes not-enabled from ended', () => {
expect(terminalSshStatusMessage('not-enabled', 'job-1')).toMatch(/SSH was not enabled/);
expect(terminalSshStatusMessage('ended', 'job-1')).toMatch(/has ended/);
expect(terminalSshStatusMessage('unknown', 'job-1')).toMatch(/No workflow job found/);
});
});
describe(sshHostAliasForResource, () => {
it('suffixes the alias with the start of the resource id', () => {
expect(sshHostAliasForResource('job-1')).toBe('eas-workflow-ssh-job1');
});
it('drops characters that are not safe in an ssh config host alias', () => {
expect(sshHostAliasForResource('a b\nHost evil')).toBe('eas-workflow-ssh-abHostev');
});
it('falls back to the bare alias when nothing usable remains', () => {
expect(sshHostAliasForResource('---')).toBe('eas-workflow-ssh');
});
});
describe(WorkflowSsh, () => {
const graphqlClient = {} as never;
const mockConnectInfo = jest.mocked(WorkflowJobSshQuery.connectInfoForResourceIdAsync);
const mockSpawn = jest.mocked(spawnAsync);
const mockSleep = jest.mocked(sleepAsync);
const mockMkdtemp = jest.mocked(fs.promises.mkdtemp);
const mockWriteFile = jest.mocked(fs.promises.writeFile);
const mockRm = jest.mocked(fs.promises.rm);
let mockConfig: Config;
let previousExitCode: typeof process.exitCode;
const readyInfo = {
sshRequested: true,
jobCompleted: false,
session: {
id: 'ts-1',
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
},
};
const pendingInfo = { sshRequested: true, jobCompleted: false, session: null };
const endedInfo = { sshRequested: true, jobCompleted: true, session: null };
const notEnabledInfo = { sshRequested: false, jobCompleted: false, session: null };
beforeAll(async () => {
mockConfig = new Config({ root: __dirname });
mockConfig.runHook = async () => ({ failures: [], successes: [] });
});
beforeEach(() => {
jest.clearAllMocks();
previousExitCode = process.exitCode;
process.exitCode = undefined;
mockMkdtemp.mockResolvedValue('/tmp/eas-ssh-1' as never);
mockWriteFile.mockResolvedValue(undefined as never);
mockRm.mockResolvedValue(undefined as never);
mockSpawn.mockResolvedValue({ stdout: '', stderr: '' } as never);
mockSleep.mockResolvedValue(undefined);
});
afterEach(() => {
process.exitCode = previousExitCode;
});
function createCommand(argv: string[]): WorkflowSsh {
const command = new WorkflowSsh(argv, mockConfig);
// @ts-expect-error getContextAsync/parse are protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({ loggedIn: { graphqlClient } });
// @ts-expect-error parse is protected on the oclif base
jest.spyOn(command, 'parse').mockResolvedValue({});
return command;
}
it('throws when no workflow job id is given', async () => {
await expect(createCommand([]).runAsync()).rejects.toThrow('Provide a workflow job id');
});
it('reports unknown and exits 1 when the id does not resolve', async () => {
mockConnectInfo.mockResolvedValue(null);
await createCommand(['job-1']).runAsync();
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('No workflow job found'));
expect(process.exitCode).toBe(1);
});
it('reports not-enabled and exits 1 when the job did not request ssh', async () => {
mockConnectInfo.mockResolvedValue(notEnabledInfo as never);
await createCommand(['job-1']).runAsync();
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('was not enabled'));
expect(process.exitCode).toBe(1);
});
it('reports ended and exits 1 when the job finished without a live session', async () => {
mockConnectInfo.mockResolvedValue(endedInfo as never);
await createCommand(['job-1']).runAsync();
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('has ended'));
expect(process.exitCode).toBe(1);
});
it('prints the connect command for --show-connect without spawning ssh', async () => {
mockConnectInfo.mockResolvedValue(readyInfo as never);
await createCommand(['--show-connect', 'job-1']).runAsync();
expect(Log.log).toHaveBeenCalledWith(
'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null [email protected]'
);
expect(mockSpawn).not.toHaveBeenCalled();
});
it('prints --show-connect with -p and a hostname-only wss proxy for non-default SSH ports', async () => {
mockConnectInfo.mockResolvedValue({
...readyInfo,
session: {
id: 'ts-1',
connectionConfig: {
host: 'relay.expo.dev:8022',
secret: 'TOKENx',
reconnecting: false,
},
},
} as never);
await createCommand(['--show-connect', 'job-1']).runAsync();
expect(Log.log).toHaveBeenCalledWith(
'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 8022 [email protected]'
);
expect(Log.log).toHaveBeenCalledWith(
' ssh -o ProxyCommand="upterm proxy wss://[email protected]" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 8022 [email protected]'
);
});
it('writes a 0600 config and opens ssh, then removes the config dir', async () => {
mockConnectInfo.mockResolvedValue(readyInfo as never);
await createCommand(['job-1', 'ls', '-la']).runAsync();
expect(mockWriteFile).toHaveBeenCalledWith(
'/tmp/eas-ssh-1/config',
expect.stringContaining('HostName relay.expo.dev'),
{ mode: 0o600 }
);
expect(mockWriteFile).toHaveBeenCalledWith(
'/tmp/eas-ssh-1/config',
expect.stringContaining('Host eas-workflow-ssh-job1'),
{ mode: 0o600 }
);
expect(mockSpawn).toHaveBeenCalledWith(
'ssh',
['-F', '/tmp/eas-ssh-1/config', 'eas-workflow-ssh-job1', 'ls', '-la'],
{ stdio: 'inherit' }
);
expect(mockRm).toHaveBeenCalledWith('/tmp/eas-ssh-1', { recursive: true, force: true });
});
it('waits for a pending session to open, then connects', async () => {
mockConnectInfo
.mockResolvedValueOnce(pendingInfo as never) // initial status check
.mockResolvedValueOnce(pendingInfo as never) // first wait poll
.mockResolvedValue(readyInfo as never);
await createCommand(['job-1']).runAsync();
expect(mockSleep).toHaveBeenCalled();
expect(mockSpawn).toHaveBeenCalled();
});
it('exits 1 when a pending session ends before it opens', async () => {
mockConnectInfo
.mockResolvedValueOnce(pendingInfo as never)
.mockResolvedValue(endedInfo as never);
await createCommand(['job-1']).runAsync();
expect(mockSpawn).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it('exits 1 when waiting for the session to open times out', async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
try {
mockConnectInfo.mockResolvedValue(pendingInfo as never);
mockSleep.mockImplementation(async () => {
jest.setSystemTime(Date.now() + 6 * 60 * 1000);
});
await createCommand(['job-1']).runAsync();
expect(mockSpawn).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
} finally {
jest.useRealTimers();
}
});
it('throws on an unexpected connection host', async () => {
mockConnectInfo.mockResolvedValue({
sshRequested: true,
jobCompleted: false,
session: {
connectionConfig: { host: 'bad host', secret: 'TOKENx', reconnecting: false },
},
} as never);
await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('connection host');
});
it('propagates the ssh exit code from the catch handler', async () => {
const command = createCommand(['job-1']);
// @ts-expect-error isRunningSubprocess is private
command.isRunningSubprocess = true;
const err = Object.assign(new Error('ssh exited'), { status: 42 });
// @ts-expect-error catch is protected
await command.catch(err);
expect(process.exitCode).toBe(42);
});
it('reports a missing ssh client (ENOENT) from the catch handler', async () => {
const command = createCommand(['job-1']);
// @ts-expect-error isRunningSubprocess is private
command.isRunningSubprocess = true;
const err = Object.assign(new Error('spawn ssh ENOENT'), { code: 'ENOENT' });
// @ts-expect-error catch is protected
await command.catch(err);
expect(process.exitCode).toBe(1);
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('Install an OpenSSH client'));
});
it('delegates non-subprocess errors to the base handler', async () => {
const command = createCommand(['job-1']);
let threw = false;
try {
// @ts-expect-error catch is protected
await command.catch(new Error('boom'));
} catch {
threw = true;
}
expect(threw).toBe(true);
});
it('stops the spinner and rethrows when polling fails while waiting', async () => {
mockConnectInfo
.mockResolvedValueOnce(pendingInfo as never)
.mockRejectedValue(new Error('network blip'));
await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('network blip');
});
});
@@ -78,6 +78,19 @@ import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccou
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { WorkflowFile } from '../../utils/workflowFile';
export function resolveWorkflowRunSshInput({
ssh,
idleTimeoutSeconds,
}: {
ssh: boolean;
idleTimeoutSeconds: number | undefined;
}): { idleTimeoutSeconds: number | undefined } | null {
if (idleTimeoutSeconds !== undefined && !ssh) {
throw new Error('--ssh-idle-timeout requires --ssh.');
}
return ssh ? { idleTimeoutSeconds } : null;
}
export default class WorkflowRun extends EasCommand {
static override description =
'run an EAS workflow. The entire local project directory will be packaged and uploaded to EAS servers for the workflow run, unless the --ref flag is used.';
@@ -110,6 +123,18 @@ export default class WorkflowRun extends EasCommand {
"The git reference must exist in the project's git repository, and the workflow file must exist at that reference. When this flag is used, the local project is not uploaded; instead, the workflow is run from the exact state of the project at the chosen reference.",
summary: 'Git reference to run the workflow on',
}),
ssh: Flags.boolean({
default: false,
description: 'Open an SSH session on each VM job for live debugging with `eas workflow:ssh`.',
summary: '[EXPERIMENTAL] Enable SSH on the run',
}),
'ssh-idle-timeout': Flags.integer({
description:
'Seconds to keep an SSH session open after its job finishes, while no client is connected. The session is always available for the whole job. Requires --ssh. Must be between 0 and 3600; defaults to 0 (close as soon as the job finishes and nobody is connected).',
summary: '[EXPERIMENTAL] SSH idle timeout in seconds',
min: 0,
max: 3600,
}),
...EasJsonOnlyFlag,
};
@@ -127,6 +152,11 @@ export default class WorkflowRun extends EasCommand {
enableJsonOutput();
}
const sshInput = resolveWorkflowRunSshInput({
ssh: flags.ssh,
idleTimeoutSeconds: flags['ssh-idle-timeout'],
});
const {
getDynamicPrivateProjectConfigAsync,
loggedIn: { graphqlClient },
@@ -272,6 +302,7 @@ export default class WorkflowRun extends EasCommand {
workflowRevisionId: workflowRevisionId ?? '',
gitRef,
inputs,
ssh: sshInput,
});
} catch (err) {
throw new Error(`Failed to create workflow run: ${err}`);
@@ -341,6 +372,7 @@ export default class WorkflowRun extends EasCommand {
packageJsonBucketKey,
projectRootDirectory,
},
ssh: sshInput,
},
}));
} catch (err) {
@@ -353,6 +385,10 @@ export default class WorkflowRun extends EasCommand {
Log.newLine();
Log.log(`See logs: ${link(getWorkflowRunUrl(account.name, projectName, workflowRunId))}`);
if (sshInput) {
Log.log('SSH enabled. Each VM job logs the `eas workflow:ssh` command to connect.');
}
if (!flags.wait) {
if (flags.json) {
printJsonOnlyOutput({
@@ -0,0 +1,252 @@
import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
WorkflowJobSshConnectInfo,
WorkflowJobSshQuery,
WorkflowJobSshSession,
} from '../../graphql/queries/WorkflowJobSshQuery';
import Log from '../../log';
import { ora } from '../../ora';
import { sleepAsync } from '../../utils/promise';
export function splitConnectionHost(connectionHost: string): { hostname: string; port?: number } {
try {
const { hostname, port: stringPort } = new URL(`ssh://${connectionHost}`);
const port = stringPort === '' ? undefined : Number(stringPort);
return { hostname, port };
} catch (err) {
throw new Error(
'Unexpected connection host reported for this ssh session. Update eas-cli and try again, or contact support if it persists.',
{ cause: err }
);
}
}
const SSH_INSECURE_OPTS = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
const SESSION_OPEN_TIMEOUT_MS = 5 * 60 * 1000;
const SESSION_OPEN_POLL_INTERVAL_MS = 3000;
export type SshConnectStatus = 'unknown' | 'not-enabled' | 'ended' | 'pending' | 'ready';
export function resolveSshConnectStatus(
connectInfo:
| (Pick<WorkflowJobSshConnectInfo, 'sshRequested' | 'jobCompleted'> & {
session?: Pick<WorkflowJobSshSession, 'connectionConfig'> | null;
})
| null
): SshConnectStatus {
if (!connectInfo) {
return 'unknown';
}
const { sshRequested, jobCompleted, session } = connectInfo;
if (session?.connectionConfig && !session.connectionConfig.reconnecting) {
return 'ready';
}
if (session?.connectionConfig?.reconnecting) {
return 'pending';
}
if (!sshRequested) {
return 'not-enabled';
}
return jobCompleted ? 'ended' : 'pending';
}
export function terminalSshStatusMessage(
status: SshConnectStatus,
resourceId: string
): string | null {
switch (status) {
case 'unknown':
return `No workflow job found for "${resourceId}". Pass a workflow job id from a run started with \`eas workflow:run --ssh\`.`;
case 'not-enabled':
return `SSH was not enabled for "${resourceId}". Start the run with \`eas workflow:run --ssh\` to enable it.`;
case 'ended':
return 'This ssh session has ended.';
case 'pending':
case 'ready':
return null;
}
}
export function sshHostAliasForResource(resourceId: string): string {
const suffix = resourceId.replace(/[^A-Za-z0-9]/g, '').slice(0, 8);
return suffix ? `eas-workflow-ssh-${suffix}` : 'eas-workflow-ssh';
}
export function parseSshArgv(rawArgv: readonly string[]): {
showConnect: boolean;
resourceId: string | undefined;
command: string[];
} {
let showConnect = false;
let index = 0;
while (index < rawArgv.length && rawArgv[index].startsWith('-')) {
if (rawArgv[index] === '--show-connect' || rawArgv[index] === '--show-connect=true') {
showConnect = true;
} else {
throw new Error(
`Unknown flag "${rawArgv[index]}" before the id. The only supported flag is --show-connect; everything after the id is passed to the remote shell.`
);
}
index += 1;
}
const [resourceId, ...command] = rawArgv.slice(index);
return { showConnect, resourceId, command };
}
export default class WorkflowSsh extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] open an ssh session on the worker running a workflow job';
static override strict = false;
static override flags = {
'show-connect': Flags.boolean({
description: 'Print the ssh connection command (host and token) instead of opening a session',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
private isRunningSubprocess = false;
async runAsync(): Promise<void> {
const rawArgv = [...this.argv];
await this.parse(WorkflowSsh, []);
const { showConnect, resourceId, command } = parseSshArgv(rawArgv);
if (typeof resourceId !== 'string' || resourceId.length === 0) {
throw new Error('Provide a workflow job id: eas workflow:ssh <workflow-job-id> [command...]');
}
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(WorkflowSsh, { nonInteractive: true });
const connectInfo = await WorkflowJobSshQuery.connectInfoForResourceIdAsync(
graphqlClient,
resourceId
);
const status = resolveSshConnectStatus(connectInfo);
const statusMessage = terminalSshStatusMessage(status, resourceId);
if (statusMessage) {
Log.error(statusMessage);
process.exitCode = 1;
return;
}
const connectionConfig =
(status === 'ready' ? connectInfo?.session?.connectionConfig : null) ??
(await waitForSessionToOpenAsync(graphqlClient, resourceId));
if (!connectionConfig) {
process.exitCode = 1;
return;
}
const { host: connectionHost, secret } = connectionConfig;
const { hostname, port } = splitConnectionHost(connectionHost);
const portOption = port !== undefined ? ` -p ${port}` : '';
if (showConnect) {
Log.log(`ssh ${SSH_INSECURE_OPTS}${portOption} ${secret}@${hostname}`);
Log.newLine();
Log.log(
'If your network blocks the direct SSH connection, reach the session through the WebSocket relay with upterm (https://upterm.dev):'
);
// WSS terminates on the relay hostname (default 443). Do not paste an SSH
// :port into the wss:// URL — that port is only for the ssh destination.
Log.log(
` ssh -o ProxyCommand="upterm proxy wss://${secret}@${hostname}" ${SSH_INSECURE_OPTS}${portOption} ${secret}@${hostname}`
);
return;
}
const configDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-ssh-'));
try {
const configPath = path.join(configDir, 'config');
const hostAlias = sshHostAliasForResource(resourceId);
await fs.promises.writeFile(
configPath,
[
`Host ${hostAlias}`,
` HostName ${hostname}`,
...(port !== undefined ? [` Port ${port}`] : []),
` User ${secret}`,
' StrictHostKeyChecking no',
' UserKnownHostsFile /dev/null',
'',
].join('\n'),
{ mode: 0o600 }
);
this.isRunningSubprocess = true;
await spawnAsync('ssh', ['-F', configPath, hostAlias, ...command], {
stdio: 'inherit',
});
} finally {
await fs.promises.rm(configDir, { recursive: true, force: true });
}
}
protected override catch(err: Error): Promise<void> {
if (this.isRunningSubprocess) {
if ((err as Error & { code?: string }).code === 'ENOENT') {
Log.error(
'Could not run `ssh`. Install an OpenSSH client and make sure `ssh` is on your PATH, then try again.'
);
process.exitCode = 1;
return Promise.resolve();
}
const status = (err as Error & { status?: number | null }).status;
process.exitCode = process.exitCode ?? status ?? 1;
return Promise.resolve();
}
return super.catch(err);
}
}
async function waitForSessionToOpenAsync(
graphqlClient: ExpoGraphqlClient,
workflowJobId: string
): Promise<NonNullable<WorkflowJobSshSession['connectionConfig']> | null> {
const spinner = ora('Waiting for the worker to open the ssh session').start();
const deadline = Date.now() + SESSION_OPEN_TIMEOUT_MS;
try {
while (Date.now() < deadline) {
const connectInfo = await WorkflowJobSshQuery.connectInfoForResourceIdAsync(
graphqlClient,
workflowJobId
);
const status = resolveSshConnectStatus(connectInfo);
const statusMessage = terminalSshStatusMessage(status, workflowJobId);
if (statusMessage) {
spinner.fail(statusMessage);
return null;
}
if (status === 'ready') {
spinner.succeed('The ssh session is ready.');
return connectInfo?.session?.connectionConfig ?? null;
}
await sleepAsync(SESSION_OPEN_POLL_INTERVAL_MS);
}
spinner.fail(
'Timed out waiting for the ssh session to open. The worker may still be starting up; try again in a moment.'
);
return null;
} catch (err) {
spinner.stop();
throw err;
}
}
+191 -5
View File
@@ -83,6 +83,8 @@ export type Account = {
accountFeatureGates: Scalars['JSONObject']['output'];
/** Coalesced project activity for all apps belonging to this account. */
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
/** AI provider connections available to agent sandbox sessions. */
agentProviderConnections: Array<AgentProviderConnection>;
aiChatEnabled: Scalars['Boolean']['output'];
appCount: Scalars['Int']['output'];
/** @deprecated Use appStoreConnectApiKeysPaginated */
@@ -1071,6 +1073,8 @@ export type AgentDeviceRunSessionRemoteConfig = {
__typename?: 'AgentDeviceRunSessionRemoteConfig';
agentDeviceRemoteSessionToken: Scalars['String']['output'];
agentDeviceRemoteSessionUrl: Scalars['String']['output'];
/** Session token gating the web preview. Null when the preview runs ungated. */
webPreviewToken?: Maybe<Scalars['String']['output']>;
/**
* URL of the web preview surface for the session. Null when web previews are
* not available for the platform (e.g. Android).
@@ -1078,6 +1082,80 @@ export type AgentDeviceRunSessionRemoteConfig = {
webPreviewUrl?: Maybe<Scalars['String']['output']>;
};
export enum AgentProvider {
Anthropic = 'ANTHROPIC',
Openai = 'OPENAI'
}
export type AgentProviderConnection = {
__typename?: 'AgentProviderConnection';
account: Account;
createdAt: Scalars['DateTime']['output'];
createdByActor?: Maybe<Actor>;
expiresAt?: Maybe<Scalars['DateTime']['output']>;
id: Scalars['ID']['output'];
name?: Maybe<Scalars['String']['output']>;
provider: AgentProvider;
providerAccountEmail?: Maybe<Scalars['String']['output']>;
providerAccountId?: Maybe<Scalars['String']['output']>;
providerPlan?: Maybe<Scalars['String']['output']>;
status: AgentProviderConnectionStatus;
updatedAt: Scalars['DateTime']['output'];
};
export enum AgentProviderConnectionAuthorizationStatus {
Expired = 'EXPIRED',
Pending = 'PENDING',
Succeeded = 'SUCCEEDED'
}
export type AgentProviderConnectionMutation = {
__typename?: 'AgentProviderConnectionMutation';
completeClaudeConnection: AgentProviderConnection;
deleteAgentProviderConnection: AgentProviderConnection;
pollCodexConnection: CodexConnectionPollResult;
startClaudeConnection: ClaudeAuthorization;
startCodexConnection: CodexDeviceAuthorization;
updateAgentProviderConnection: AgentProviderConnection;
};
export type AgentProviderConnectionMutationCompleteClaudeConnectionArgs = {
input: CompleteClaudeConnectionInput;
};
export type AgentProviderConnectionMutationDeleteAgentProviderConnectionArgs = {
id: Scalars['ID']['input'];
};
export type AgentProviderConnectionMutationPollCodexConnectionArgs = {
requestId: Scalars['ID']['input'];
};
export type AgentProviderConnectionMutationStartClaudeConnectionArgs = {
input: StartClaudeConnectionInput;
};
export type AgentProviderConnectionMutationStartCodexConnectionArgs = {
accountId: Scalars['ID']['input'];
};
export type AgentProviderConnectionMutationUpdateAgentProviderConnectionArgs = {
id: Scalars['ID']['input'];
input: UpdateAgentProviderConnectionInput;
};
export enum AgentProviderConnectionStatus {
Invalid = 'INVALID',
UnableToVerify = 'UNABLE_TO_VERIFY',
Valid = 'VALID'
}
export type AndroidAppBuildCredentials = {
__typename?: 'AndroidAppBuildCredentials';
androidKeystore?: Maybe<AndroidKeystore>;
@@ -1444,6 +1522,8 @@ export type App = Project & {
/** @deprecated Classic updates have been deprecated. */
description: Scalars['String']['output'];
devDomainName?: Maybe<AppDevDomainName>;
/** Every tag used by this app's device run sessions, sorted alphabetically. */
deviceRunSessionTags: Array<Scalars['String']['output']>;
deviceRunSessionsPaginated: AppDeviceRunSessionsConnection;
embeddedUpdatesPaginated: AppEmbeddedUpdatesConnection;
/** Environment secrets for an app */
@@ -1472,6 +1552,8 @@ export type App = Project & {
internalDistributionBuildPrivacy: AppInternalDistributionBuildPrivacy;
/** iOS app credentials for the project */
iosAppCredentials: Array<IosAppCredentials>;
/** Whether this app qualifies for the observe-notification-lapsed-paid experiment */
isEligibleForObserveLapsedPaidNotification: Scalars['Boolean']['output'];
/**
* Tells you if the project can show the Observe promotional notification. This field is part of
* the observe-notification experiment.
@@ -3730,6 +3812,8 @@ export type AppiumRunSessionRemoteConfig = {
appiumUrl: Scalars['String']['output'];
/** W3C capabilities for the device that backs this session. */
capabilities: Scalars['JSONObject']['output'];
/** Session token gating the web preview. Null when the preview runs ungated. */
webPreviewToken?: Maybe<Scalars['String']['output']>;
/**
* URL of the web preview surface for the session. Null when web previews are
* not available for the platform (e.g. Android).
@@ -4138,6 +4222,8 @@ export type ArgentRunSessionRemoteConfig = {
__typename?: 'ArgentRunSessionRemoteConfig';
toolsAuthToken?: Maybe<Scalars['String']['output']>;
toolsUrl: Scalars['String']['output'];
/** Session token gating the web preview. Null when the preview runs ungated. */
webPreviewToken?: Maybe<Scalars['String']['output']>;
/**
* URL of the web preview surface for the session. Null when web previews are
* not available for the platform (e.g. Android).
@@ -5087,6 +5173,13 @@ export type ChatTokenUsage = {
usedTokens: Scalars['Int']['output'];
};
export type ClaudeAuthorization = {
__typename?: 'ClaudeAuthorization';
authorizationUrl: Scalars['String']['output'];
expiresAt: Scalars['DateTime']['output'];
requestId: Scalars['ID']['output'];
};
export type CodeSigningInfo = {
__typename?: 'CodeSigningInfo';
alg: Scalars['String']['output'];
@@ -5100,6 +5193,26 @@ export type CodeSigningInfoInput = {
sig: Scalars['String']['input'];
};
export type CodexConnectionPollResult = {
__typename?: 'CodexConnectionPollResult';
connection?: Maybe<AgentProviderConnection>;
status: AgentProviderConnectionAuthorizationStatus;
};
export type CodexDeviceAuthorization = {
__typename?: 'CodexDeviceAuthorization';
expiresAt: Scalars['DateTime']['output'];
pollingIntervalSeconds: Scalars['Int']['output'];
requestId: Scalars['ID']['output'];
userCode: Scalars['String']['output'];
verificationUrl: Scalars['String']['output'];
};
export type CompleteClaudeConnectionInput = {
authorizationCode: Scalars['String']['input'];
requestId: Scalars['ID']['input'];
};
export type CompletePostHogConnectionInput = {
code: Scalars['String']['input'];
state: Scalars['ID']['input'];
@@ -5321,7 +5434,7 @@ export type CreateDeviceRunSessionInput = {
/**
* Stop the session automatically after this many minutes without observed
* session activity. Must be positive and smaller than the session's maximum
* duration (2 hours, or maxRunTimeMinutes when set). Only supported for
* duration (maxRunTimeMinutes when set). Only supported for
* agent-device, argent, and Appium sessions. If omitted, the session has no idle
* timeout.
*/
@@ -5355,6 +5468,8 @@ export type CreateDeviceRunSessionInput = {
* true. If omitted, the current Expo Go archive for the platform is used.
*/
sdkVersion?: InputMaybe<Scalars['String']['input']>;
/** Free-form labels for grouping sessions, for example one per app variant. */
tags?: InputMaybe<Array<Scalars['String']['input']>>;
type: DeviceRunSessionType;
};
@@ -6134,6 +6249,8 @@ export type DeviceRunSession = {
remoteConfig?: Maybe<DeviceRunSessionRemoteConfig>;
startedAt?: Maybe<Scalars['DateTime']['output']>;
status: DeviceRunSessionStatus;
/** Free-form labels for grouping sessions, for example one per app variant. */
tags: Array<Scalars['String']['output']>;
turtleJobRun?: Maybe<JobRun>;
type: DeviceRunSessionType;
updatedAt: Scalars['DateTime']['output'];
@@ -6180,6 +6297,8 @@ export type DeviceRunSessionFilterInput = {
name?: InputMaybe<Scalars['String']['input']>;
platforms?: InputMaybe<Array<AppPlatform>>;
statuses?: InputMaybe<Array<DeviceRunSessionStatus>>;
/** Matches sessions carrying every listed tag. Case-insensitive. */
tags?: InputMaybe<Array<Scalars['String']['input']>>;
types?: InputMaybe<Array<DeviceRunSessionType>>;
};
@@ -6241,7 +6360,7 @@ export type DeviceRunSessionQueryByIdArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | AppiumRunSessionRemoteConfig | ArgentRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig;
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | AppiumRunSessionRemoteConfig | ArgentRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig | WebPreviewOnlyRunSessionRemoteConfig;
export enum DeviceRunSessionResourceClass {
Large = 'LARGE',
@@ -6259,7 +6378,10 @@ export enum DeviceRunSessionType {
AgentDevice = 'AGENT_DEVICE',
Appium = 'APPIUM',
Argent = 'ARGENT',
ServeSim = 'SERVE_SIM'
/** @deprecated Use WEB_PREVIEW_ONLY instead. */
ServeSim = 'SERVE_SIM',
/** A session accessed only through its web preview. Currently supported on iOS. */
WebPreviewOnly = 'WEB_PREVIEW_ONLY'
}
export type DiscordUser = {
@@ -7126,6 +7248,7 @@ export type EmbeddedUpdateQueryByIdArgs = {
export enum EntityTypeName {
AccountEntity = 'AccountEntity',
AccountSsoConfigurationEntity = 'AccountSSOConfigurationEntity',
AgentProviderConnectionEntity = 'AgentProviderConnectionEntity',
AndroidAppCredentialsEntity = 'AndroidAppCredentialsEntity',
AndroidKeystoreEntity = 'AndroidKeystoreEntity',
AppEntity = 'AppEntity',
@@ -9509,6 +9632,7 @@ export type RootMutation = {
accountSSOConfiguration: AccountSsoConfigurationMutation;
/** Mutations for Actor experiments */
actorExperiment: ActorExperimentMutation;
agentProviderConnection: AgentProviderConnectionMutation;
/** Mutations that modify the build credentials for an Android app */
androidAppBuildCredentials: AndroidAppBuildCredentialsMutation;
/** Mutations that modify the credentials for an Android app */
@@ -10111,8 +10235,14 @@ export type SentryProjectMutationDeleteSentryProjectArgs = {
sentryProjectId: Scalars['ID']['input'];
};
/**
* Legacy remote config for SERVE_SIM sessions. Use WEB_PREVIEW_ONLY sessions and
* WebPreviewOnlyRunSessionRemoteConfig instead.
*/
export type ServeSimRunSessionRemoteConfig = {
__typename?: 'ServeSimRunSessionRemoteConfig';
/** Session token gating the preview. Null when the preview runs ungated. */
previewToken?: Maybe<Scalars['String']['output']>;
previewUrl: Scalars['String']['output'];
/** @deprecated Use previewUrl instead. */
streamUrl?: Maybe<Scalars['String']['output']>;
@@ -10201,6 +10331,10 @@ export enum StandardOffer {
YearlySub = 'YEARLY_SUB'
}
export type StartClaudeConnectionInput = {
accountId: Scalars['ID']['input'];
};
export type StartPostHogConnectionResult = PostHogOrganizationConnection | PostHogPendingConnection;
/** Incident for a given component from Expo status page API. */
@@ -10809,6 +10943,10 @@ export type UpdateDiffReceiptsArgs = {
orderBy?: InputMaybe<Array<UpdateDiffReceiptOrderByClause>>;
};
export type UpdateAgentProviderConnectionInput = {
name?: InputMaybe<Scalars['String']['input']>;
};
export type UpdateBranch = {
__typename?: 'UpdateBranch';
app: App;
@@ -12134,6 +12272,11 @@ export type WebNotificationUpdateReadStateInput = {
isRead: Scalars['Boolean']['input'];
};
export type WebPreviewOnlyRunSessionRemoteConfig = {
__typename?: 'WebPreviewOnlyRunSessionRemoteConfig';
webPreviewUrl: Scalars['String']['output'];
};
export type Webhook = {
__typename?: 'Webhook';
appId: Scalars['ID']['output'];
@@ -13425,6 +13568,7 @@ export type WorkflowRunGitBranchFilterInput = {
export type WorkflowRunInput = {
inputs?: InputMaybe<Scalars['JSONObject']['input']>;
projectSource: WorkflowProjectSourceInput;
ssh?: InputMaybe<WorkflowRunSshInput>;
};
export type WorkflowRunMutation = {
@@ -13459,12 +13603,14 @@ export type WorkflowRunMutationCreateWorkflowRunArgs = {
export type WorkflowRunMutationCreateWorkflowRunFromGitRefArgs = {
gitRef: Scalars['String']['input'];
inputs?: InputMaybe<Scalars['JSONObject']['input']>;
ssh?: InputMaybe<WorkflowRunSshInput>;
workflowRevisionId: Scalars['ID']['input'];
};
export type WorkflowRunMutationRetryWorkflowRunArgs = {
fromFailedJobs?: InputMaybe<Scalars['Boolean']['input']>;
ssh?: InputMaybe<WorkflowRunSshInput>;
workflowRunId: Scalars['ID']['input'];
};
@@ -13478,6 +13624,14 @@ export type WorkflowRunQueryByIdArgs = {
workflowRunId: Scalars['ID']['input'];
};
/**
* Enables ssh on the run's VM jobs. Presence turns ssh on; idleTimeoutSeconds is optional,
* defaults server-side, and is validated against a supported range.
*/
export type WorkflowRunSshInput = {
idleTimeoutSeconds?: InputMaybe<Scalars['Int']['input']>;
};
export type WorkflowRunSshSettings = {
__typename?: 'WorkflowRunSshSettings';
idleTimeoutSeconds: Scalars['Int']['output'];
@@ -13506,6 +13660,9 @@ export enum WorkflowRunTriggerEventType {
EasSubmit = 'EAS_SUBMIT',
ExpoLaunch = 'EXPO_LAUNCH',
GithubPullRequestBaseRefChanged = 'GITHUB_PULL_REQUEST_BASE_REF_CHANGED',
GithubPullRequestCommentCreated = 'GITHUB_PULL_REQUEST_COMMENT_CREATED',
GithubPullRequestCommentDeleted = 'GITHUB_PULL_REQUEST_COMMENT_DELETED',
GithubPullRequestCommentEdited = 'GITHUB_PULL_REQUEST_COMMENT_EDITED',
GithubPullRequestEdited = 'GITHUB_PULL_REQUEST_EDITED',
GithubPullRequestLabeled = 'GITHUB_PULL_REQUEST_LABELED',
GithubPullRequestOpened = 'GITHUB_PULL_REQUEST_OPENED',
@@ -14576,6 +14733,19 @@ export type SetRolloutPercentageMutation = { __typename?: 'RootMutation', update
| { __typename: 'User', username: string, id: string }
| null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null, rolloutControlUpdate?: { __typename?: 'Update', id: string, group: string } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, source?: { __typename?: 'FingerprintSource', type: FingerprintSourceType, bucketKey: string, isDebugFingerprint?: boolean | null } | null } | null } } };
export type GenerateRealtimeLogsCentrifugoConnectionTokenMutationVariables = Exact<{ [key: string]: never; }>;
export type GenerateRealtimeLogsCentrifugoConnectionTokenMutation = { __typename?: 'RootMutation', realtimeLogs: { __typename?: 'RealtimeLogsMutation', generateCentrifugoConnectionToken: { __typename?: 'RealtimeLogsCentrifugoConnectionToken', token: string } } };
export type GenerateRealtimeLogsCentrifugoSubscriptionTokenMutationVariables = Exact<{
target: RealtimeLogsTargetInput;
thread?: InputMaybe<Scalars['String']['input']>;
}>;
export type GenerateRealtimeLogsCentrifugoSubscriptionTokenMutation = { __typename?: 'RootMutation', realtimeLogs: { __typename?: 'RealtimeLogsMutation', generateCentrifugoSubscriptionToken: { __typename?: 'RealtimeLogsCentrifugoSubscriptionToken', channel: string, token: string } } };
export type CreateAndroidSubmissionMutationVariables = Exact<{
appId: Scalars['ID']['input'];
config: AndroidSubmissionConfigInput;
@@ -14750,6 +14920,7 @@ export type CreateWorkflowRunFromGitRefMutationVariables = Exact<{
workflowRevisionId: Scalars['ID']['input'];
gitRef: Scalars['String']['input'];
inputs?: InputMaybe<Scalars['JSONObject']['input']>;
ssh?: InputMaybe<WorkflowRunSshInput>;
}>;
@@ -15126,11 +15297,12 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
}>;
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, name?: string | null, 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?:
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, name?: string | null, tags: Array<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: 'AppiumRunSessionRemoteConfig', appiumUrl: string, capabilities: any, webPreviewUrl?: string | null }
| { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null }
| { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string }
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', previewUrl: string }
| null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
@@ -15141,7 +15313,7 @@ export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
}>;
export type DeviceRunSessionsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, deviceRunSessionsPaginated: { __typename?: 'AppDeviceRunSessionsConnection', edges: Array<{ __typename?: 'AppDeviceRunSessionEdge', cursor: string, node: { __typename?: 'DeviceRunSession', id: string, name?: string | null, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type DeviceRunSessionsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, deviceRunSessionsPaginated: { __typename?: 'AppDeviceRunSessionsConnection', edges: Array<{ __typename?: 'AppDeviceRunSessionEdge', cursor: string, node: { __typename?: 'DeviceRunSession', id: string, name?: string | null, tags: Array<string>, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type ViewEmbeddedUpdateByIdQueryVariables = Exact<{
embeddedUpdateId: Scalars['ID']['input'];
@@ -15527,6 +15699,20 @@ export type WorkflowJobByIdQuery = { __typename?: 'RootQuery', workflowJobs: { _
| { __typename: 'User', id: string, displayName: string }
| null, app: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, updateChannel?: { __typename?: 'UpdateChannel', id: string, name: string } | null, runtime?: { __typename?: 'Runtime', id: string, version: string } | null, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> } } };
export type WorkflowJobSshPollQueryVariables = Exact<{
workflowJobId: Scalars['ID']['input'];
}>;
export type WorkflowJobSshPollQuery = { __typename?: 'RootQuery', workflowJobs: { __typename?: 'WorkflowJobQuery', byId: { __typename?: 'WorkflowJob', id: string, status: WorkflowJobStatus, workflowRun: { __typename?: 'WorkflowRun', id: string, sshSettings?: { __typename?: 'WorkflowRunSshSettings', idleTimeoutSeconds: number } | null }, turtleJobRun?: { __typename?: 'JobRun', id: string, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } | null, turtleBuild?: { __typename?: 'Build', id: string, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } | null } } };
export type JobRunSshPollQueryVariables = Exact<{
jobRunId: Scalars['ID']['input'];
}>;
export type JobRunSshPollQuery = { __typename?: 'RootQuery', jobRun: { __typename?: 'JobRunQuery', byId: { __typename?: 'JobRun', id: string, status: JobRunStatus, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } } };
export type ExpoGoSupportedSdkVersionsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -0,0 +1,69 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
GenerateRealtimeLogsCentrifugoConnectionTokenMutation,
GenerateRealtimeLogsCentrifugoConnectionTokenMutationVariables,
GenerateRealtimeLogsCentrifugoSubscriptionTokenMutation,
GenerateRealtimeLogsCentrifugoSubscriptionTokenMutationVariables,
RealtimeLogsCentrifugoConnectionToken,
RealtimeLogsCentrifugoSubscriptionToken,
RealtimeLogsMutationGenerateCentrifugoSubscriptionTokenArgs,
} from '../generated';
export const RealtimeLogsMutation = {
async generateCentrifugoConnectionTokenAsync(
graphqlClient: ExpoGraphqlClient
): Promise<RealtimeLogsCentrifugoConnectionToken> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
GenerateRealtimeLogsCentrifugoConnectionTokenMutation,
GenerateRealtimeLogsCentrifugoConnectionTokenMutationVariables
>(
gql`
mutation GenerateRealtimeLogsCentrifugoConnectionToken {
realtimeLogs {
generateCentrifugoConnectionToken {
token
}
}
}
`,
{}
)
.toPromise()
);
return data.realtimeLogs.generateCentrifugoConnectionToken;
},
async generateCentrifugoSubscriptionTokenAsync(
graphqlClient: ExpoGraphqlClient,
{ target, thread }: RealtimeLogsMutationGenerateCentrifugoSubscriptionTokenArgs
): Promise<RealtimeLogsCentrifugoSubscriptionToken> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
GenerateRealtimeLogsCentrifugoSubscriptionTokenMutation,
GenerateRealtimeLogsCentrifugoSubscriptionTokenMutationVariables
>(
gql`
mutation GenerateRealtimeLogsCentrifugoSubscriptionToken(
$target: RealtimeLogsTargetInput!
$thread: String
) {
realtimeLogs {
generateCentrifugoSubscriptionToken(target: $target, thread: $thread) {
channel
token
}
}
}
`,
{ target, thread }
)
.toPromise()
);
return data.realtimeLogs.generateCentrifugoSubscriptionToken;
},
};
@@ -12,6 +12,7 @@ import {
WorkflowProjectSourceInput,
WorkflowRevisionInput,
WorkflowRunInput,
WorkflowRunSshInput,
} from '../generated';
export namespace WorkflowRunMutation {
@@ -64,10 +65,12 @@ export namespace WorkflowRunMutation {
workflowRevisionId,
gitRef,
inputs,
ssh,
}: {
workflowRevisionId: string;
gitRef: string;
inputs?: Record<string, any>;
ssh?: WorkflowRunSshInput | null;
}
): Promise<{ id: string }> {
const data = await withErrorHandlingAsync(
@@ -81,12 +84,14 @@ export namespace WorkflowRunMutation {
$workflowRevisionId: ID!
$gitRef: String!
$inputs: JSONObject
$ssh: WorkflowRunSshInput
) {
workflowRun {
createWorkflowRunFromGitRef(
workflowRevisionId: $workflowRevisionId
gitRef: $gitRef
inputs: $inputs
ssh: $ssh
) {
id
}
@@ -97,6 +102,7 @@ export namespace WorkflowRunMutation {
workflowRevisionId,
gitRef,
inputs,
ssh,
}
)
.toPromise()
@@ -55,6 +55,7 @@ export const DeviceRunSessionQuery = {
byId(deviceRunSessionId: $deviceRunSessionId) {
id
name
tags
status
type
platform
@@ -100,6 +101,9 @@ export const DeviceRunSessionQuery = {
... on ServeSimRunSessionRemoteConfig {
previewUrl
}
... on WebPreviewOnlyRunSessionRemoteConfig {
previewUrl: webPreviewUrl
}
}
turtleJobRun {
id
@@ -149,6 +153,7 @@ export const DeviceRunSessionQuery = {
node {
id
name
tags
status
type
platform
@@ -0,0 +1,213 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { GraphqlError, withErrorHandlingAsync } from '../client';
import { JobRunStatus, WorkflowJobStatus } from '../generated';
const FINAL_WORKFLOW_JOB_STATUSES = new Set<WorkflowJobStatus>([
WorkflowJobStatus.Success,
WorkflowJobStatus.Failure,
WorkflowJobStatus.Canceled,
WorkflowJobStatus.Skipped,
]);
const FINAL_JOB_RUN_STATUSES = new Set<JobRunStatus>([
JobRunStatus.Errored,
JobRunStatus.Finished,
JobRunStatus.Canceled,
]);
export type WorkflowJobSshConnectionConfig = {
host: string;
secret: string;
reconnecting: boolean;
};
export type WorkflowJobSshSession = {
id: string;
connectionConfig: WorkflowJobSshConnectionConfig;
};
export type WorkflowJobSshConnectInfo = {
sshRequested: boolean;
jobCompleted: boolean;
session: WorkflowJobSshSession | null;
};
type WorkflowJobSshPollQuery = {
workflowJobs: {
byId: {
id: string;
status: WorkflowJobStatus;
workflowRun: {
id: string;
sshSettings: { idleTimeoutSeconds: number } | null;
};
turtleJobRun: { id: string; sshSession: WorkflowJobSshSession | null } | null;
turtleBuild: { id: string; sshSession: WorkflowJobSshSession | null } | null;
};
};
};
type WorkflowJobSshPollQueryVariables = {
workflowJobId: string;
};
type JobRunSshPollQuery = {
jobRun: {
byId: {
id: string;
status: JobRunStatus;
sshSession: WorkflowJobSshSession | null;
};
};
};
type JobRunSshPollQueryVariables = {
jobRunId: string;
};
function toConnectInfo(
job: WorkflowJobSshPollQuery['workflowJobs']['byId']
): WorkflowJobSshConnectInfo {
const hasTurtleTarget = job.turtleJobRun != null || job.turtleBuild != null;
return {
sshRequested: hasTurtleTarget && job.workflowRun.sshSettings != null,
jobCompleted: FINAL_WORKFLOW_JOB_STATUSES.has(job.status),
session: job.turtleJobRun?.sshSession ?? job.turtleBuild?.sshSession ?? null,
};
}
function isNotFoundError(error: unknown): boolean {
if (!(error instanceof GraphqlError)) {
return false;
}
return error.graphQLErrors.some(e => {
const code = e?.extensions?.errorCode ?? e?.extensions?.code;
return (
code === 'ENTITY_NOT_FOUND' ||
/Entity not found/i.test(e?.message ?? '') ||
/not found/i.test(e?.message ?? '')
);
});
}
export const WorkflowJobSshQuery = {
async connectInfoForWorkflowJobAsync(
graphqlClient: ExpoGraphqlClient,
workflowJobId: string
): Promise<WorkflowJobSshConnectInfo | null> {
let data: WorkflowJobSshPollQuery;
try {
data = await withErrorHandlingAsync(
graphqlClient
.query<WorkflowJobSshPollQuery, WorkflowJobSshPollQueryVariables>(
gql`
query WorkflowJobSshPoll($workflowJobId: ID!) {
workflowJobs {
byId(workflowJobId: $workflowJobId) {
id
status
workflowRun {
id
sshSettings {
idleTimeoutSeconds
}
}
turtleJobRun {
id
sshSession {
id
connectionConfig {
host
secret
reconnecting
}
}
}
turtleBuild {
id
sshSession {
id
connectionConfig {
host
secret
reconnecting
}
}
}
}
}
}
`,
{ workflowJobId },
{ requestPolicy: 'network-only' }
)
.toPromise()
);
} catch (error) {
if (isNotFoundError(error)) {
return null;
}
throw error;
}
return toConnectInfo(data.workflowJobs.byId);
},
async connectInfoForJobRunAsync(
graphqlClient: ExpoGraphqlClient,
jobRunId: string
): Promise<WorkflowJobSshConnectInfo | null> {
let data: JobRunSshPollQuery;
try {
data = await withErrorHandlingAsync(
graphqlClient
.query<JobRunSshPollQuery, JobRunSshPollQueryVariables>(
gql`
query JobRunSshPoll($jobRunId: ID!) {
jobRun {
byId(jobRunId: $jobRunId) {
id
status
sshSession {
id
connectionConfig {
host
secret
reconnecting
}
}
}
}
}
`,
{ jobRunId },
{ requestPolicy: 'network-only' }
)
.toPromise()
);
} catch (error) {
if (isNotFoundError(error)) {
return null;
}
throw error;
}
const jobRun = data.jobRun.byId;
const jobCompleted = FINAL_JOB_RUN_STATUSES.has(jobRun.status);
return {
sshRequested: jobRun.sshSession != null || jobCompleted,
jobCompleted,
session: jobRun.sshSession,
};
},
async connectInfoForResourceIdAsync(
graphqlClient: ExpoGraphqlClient,
resourceId: string
): Promise<WorkflowJobSshConnectInfo | null> {
return (
(await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, resourceId)) ??
(await WorkflowJobSshQuery.connectInfoForJobRunAsync(graphqlClient, resourceId))
);
},
};
@@ -0,0 +1,155 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { GraphqlError } from '../../client';
import { WorkflowJobStatus } from '../../generated';
import { WorkflowJobSshQuery } from '../WorkflowJobSshQuery';
describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => {
function makeClient(byId: unknown): {
graphqlClient: ExpoGraphqlClient;
query: jest.Mock;
} {
const query = jest.fn().mockReturnValue({
toPromise: async () => ({ data: { workflowJobs: { byId } } }),
});
return { graphqlClient: { query } as unknown as ExpoGraphqlClient, query };
}
it('maps workflowJobs.byId into connect info', async () => {
const { graphqlClient, query } = makeClient({
id: 'job-1',
status: WorkflowJobStatus.InProgress,
workflowRun: { sshSettings: { idleTimeoutSeconds: 0 } },
turtleJobRun: {
sshSession: {
id: 'ts-1',
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
},
},
turtleBuild: null,
});
expect(
await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1')
).toEqual({
sshRequested: true,
jobCompleted: false,
session: {
id: 'ts-1',
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
},
});
expect(query).toHaveBeenCalledWith(
expect.anything(),
{ workflowJobId: 'job-1' },
{ requestPolicy: 'network-only' }
);
});
it('is not ssh-requested when the run has no sshSettings', async () => {
const { graphqlClient } = makeClient({
id: 'job-1',
status: WorkflowJobStatus.InProgress,
workflowRun: { sshSettings: null },
turtleJobRun: null,
turtleBuild: { sshSession: null },
});
expect(
await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1')
).toMatchObject({ sshRequested: false });
});
it('returns null when the job is not found', async () => {
const query = jest.fn().mockReturnValue({
toPromise: async () => ({
data: undefined,
error: new GraphqlError({
graphQLErrors: [
{ message: 'Entity not found', extensions: { errorCode: 'ENTITY_NOT_FOUND' } },
],
networkError: undefined,
response: undefined,
}),
}),
});
const graphqlClient = { query } as unknown as ExpoGraphqlClient;
expect(
await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'missing')
).toBeNull();
});
it('returns null for not-found errors matched by message or code', async () => {
for (const graphQLErrors of [
[{ message: 'Workflow job not found', extensions: {} }],
[{ message: 'missing', extensions: { code: 'ENTITY_NOT_FOUND' } }],
]) {
const query = jest.fn().mockReturnValue({
toPromise: async () => ({
data: undefined,
error: new GraphqlError({
graphQLErrors,
networkError: undefined,
response: undefined,
}),
}),
});
const graphqlClient = { query } as unknown as ExpoGraphqlClient;
expect(
await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'missing')
).toBeNull();
}
});
it('uses the turtleBuild session when there is no turtleJobRun', async () => {
const { graphqlClient } = makeClient({
id: 'job-1',
status: WorkflowJobStatus.Success,
workflowRun: { sshSettings: { idleTimeoutSeconds: 60 } },
turtleJobRun: null,
turtleBuild: {
sshSession: {
id: 'ts-build',
connectionConfig: { host: 'relay.expo.dev', secret: 'TOK', reconnecting: true },
},
},
});
expect(
await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1')
).toEqual({
sshRequested: true,
jobCompleted: true,
session: {
id: 'ts-build',
connectionConfig: { host: 'relay.expo.dev', secret: 'TOK', reconnecting: true },
},
});
});
it('rethrows GraphQL errors that are not not-found', async () => {
const error = new GraphqlError({
graphQLErrors: [{ message: 'boom', extensions: { errorCode: 'INTERNAL' } }],
networkError: undefined,
response: undefined,
});
const query = jest.fn().mockReturnValue({
toPromise: async () => ({ data: undefined, error }),
});
const graphqlClient = { query } as unknown as ExpoGraphqlClient;
await expect(
WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1')
).rejects.toBe(error);
});
it('rethrows unexpected non-GraphQL errors', async () => {
const query = jest.fn().mockReturnValue({
toPromise: async () => {
throw new Error('network down');
},
});
const graphqlClient = { query } as unknown as ExpoGraphqlClient;
await expect(
WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1')
).rejects.toThrow('network down');
});
});
+15
View File
@@ -110,3 +110,18 @@ export function ora(options?: Options | string): Ora {
return spinner;
}
/**
* Updates the text of a spinner. Does nothing if new text is the same as old text to prevent flickering.
*/
export function updateSpinnerText(
spinner: Ora,
{ prefixText, text }: { prefixText?: string; text?: string }
): void {
if (prefixText !== undefined && spinner.prefixText !== prefixText) {
spinner.prefixText = prefixText;
}
if (text !== undefined && spinner.text !== text) {
spinner.text = text;
}
}
@@ -37,19 +37,27 @@ describe('Appium simulator configuration', () => {
expect(instructions).toContain('eas simulator:exec <appium-client> [args...]');
expect(instructions).toContain('https://preview.example.test');
expect(instructions).toContain('Open the simulator preview:');
expect(instructions).not.toContain('iOS simulator preview');
expect(instructions).not.toContain('https://appium.example.test');
});
});
describe('simulator session type flags', () => {
it('maps web-preview-only to the ServeSim GraphQL enum', () => {
it('maps web-preview-only to the WebPreviewOnly GraphQL enum', () => {
expect(DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE['web-preview-only']).toBe(
DeviceRunSessionType.ServeSim
DeviceRunSessionType.WebPreviewOnly
);
expect(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.ServeSim]).toBe(
'web-preview-only'
);
expect(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.WebPreviewOnly]).toBe(
'web-preview-only'
);
expect(deviceRunSessionTypeToFlagValue(DeviceRunSessionType.ServeSim)).toBe('web-preview-only');
expect(deviceRunSessionTypeToFlagValue(DeviceRunSessionType.WebPreviewOnly)).toBe(
'web-preview-only'
);
expect(DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE['serve-sim']).toBeUndefined();
expect(DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE['web-preview']).toBeUndefined();
});
+9 -1
View File
@@ -29,6 +29,7 @@ export const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, s
[DeviceRunSessionType.Appium]: 'appium',
[DeviceRunSessionType.Argent]: 'argent',
[DeviceRunSessionType.ServeSim]: 'web-preview-only',
[DeviceRunSessionType.WebPreviewOnly]: 'web-preview-only',
};
export const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
@@ -78,6 +79,7 @@ export function getRemoteSessionEnvironmentVariables(
APPIUM_CAPS: JSON.stringify(remoteConfig.capabilities),
};
case 'ServeSimRunSessionRemoteConfig':
case 'WebPreviewOnlyRunSessionRemoteConfig':
return {};
}
}
@@ -172,7 +174,7 @@ export function formatRemoteSessionInstructions(
'<appium-client> [args...]',
];
if (remoteConfig.webPreviewUrl) {
lines.push('', 'Open the iOS simulator preview:', '', remoteConfig.webPreviewUrl);
lines.push('', 'Open the simulator preview:', '', remoteConfig.webPreviewUrl);
}
return lines.join('\n');
}
@@ -182,5 +184,11 @@ export function formatRemoteSessionInstructions(
'',
remoteConfig.previewUrl,
].join('\n');
case 'WebPreviewOnlyRunSessionRemoteConfig':
return [
'🌐 Open the following URL in your browser to access the simulator:',
'',
remoteConfig.previewUrl,
].join('\n');
}
}
@@ -0,0 +1,26 @@
import { Centrifuge } from 'centrifuge';
import { createRealtimeLogsClient } from '../centrifuge';
jest.mock('centrifuge');
describe(createRealtimeLogsClient, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null instead of throwing when the connection cannot be created', () => {
jest.mocked(Centrifuge).mockImplementationOnce(() => {
throw new Error('unsupported url scheme');
});
expect(createRealtimeLogsClient({} as any)).toBeNull();
});
it('connects and returns a client when the connection can be created', () => {
expect(createRealtimeLogsClient({} as any)).not.toBeNull();
const centrifuge = jest.mocked(Centrifuge).mock.instances[0];
expect(jest.mocked(centrifuge.connect)).toHaveBeenCalled();
});
});
+117
View File
@@ -0,0 +1,117 @@
import { Centrifuge, Subscription } from 'centrifuge';
import { Agent } from 'https';
import WebSocket from 'ws';
import { getEASLogsWebsocketUrl } from '../api';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { RealtimeLogsMutationGenerateCentrifugoSubscriptionTokenArgs } from '../graphql/generated';
import { RealtimeLogsMutation } from '../graphql/mutations/RealtimeLogsMutation';
import { httpsProxyAgent } from '../fetch';
import Log from '../log';
export type RealtimeLogsSubscription = {
close: () => void;
};
export type RealtimeLogsClient = {
subscribeAsync: (
args: RealtimeLogsMutationGenerateCentrifugoSubscriptionTokenArgs,
onPublication: (data: unknown) => void
) => Promise<RealtimeLogsSubscription | null>;
close: () => void;
};
type WebSocketConstructor = new (address: string, protocols?: string | string[]) => WebSocket;
function createProxiedWebSocketConstructor(agent: Agent): WebSocketConstructor {
return class ProxiedWebSocket extends WebSocket {
constructor(address: string | URL, protocols?: string | string[]) {
super(address, protocols, { agent });
}
};
}
function createWebSocketConstructor(): WebSocketConstructor {
return httpsProxyAgent ? createProxiedWebSocketConstructor(httpsProxyAgent) : WebSocket;
}
export function createRealtimeLogsClient(
graphqlClient: ExpoGraphqlClient
): RealtimeLogsClient | null {
const getTokenAsync = async (): Promise<string> => {
const { token } =
await RealtimeLogsMutation.generateCentrifugoConnectionTokenAsync(graphqlClient);
return token;
};
let client: Centrifuge;
try {
client = new Centrifuge(getEASLogsWebsocketUrl(), {
websocket: createWebSocketConstructor(),
getToken: getTokenAsync,
});
client.on('error', ({ error }) => {
Log.debug(`Realtime logs connection error: ${error.message}`);
});
client.connect();
} catch (err: any) {
Log.debug(`Failed to connect to realtime logs: ${err.message}`);
return null;
}
return {
subscribeAsync: async (args, onPublication) => {
const getSubscriptionTokenAsync = async (): Promise<string> => {
const { token } = await RealtimeLogsMutation.generateCentrifugoSubscriptionTokenAsync(
graphqlClient,
args
);
return token;
};
try {
const { channel, token } =
await RealtimeLogsMutation.generateCentrifugoSubscriptionTokenAsync(graphqlClient, args);
const subscription = client.newSubscription(channel, {
token,
getToken: getSubscriptionTokenAsync,
});
subscription.on('error', ({ error }) => {
Log.debug(`Realtime logs subscription error on ${channel}: ${error.message}`);
});
subscription.on('publication', ({ data }) => {
onPublication(data);
});
subscription.subscribe();
return {
close: () => {
closeSubscription(client, subscription);
},
};
} catch (err: any) {
Log.debug(`Failed to subscribe to realtime logs: ${err.message}`);
return null;
}
},
close: () => {
for (const subscription of Object.values(client.subscriptions())) {
closeSubscription(client, subscription);
}
client.removeAllListeners();
client.disconnect();
},
};
}
function closeSubscription(client: Centrifuge, subscription: Subscription): void {
try {
subscription.unsubscribe();
subscription.removeAllListeners();
client.removeSubscription(subscription);
} catch (err: any) {
Log.debug(`Failed to close realtime logs subscription: ${err.message}`);
}
}
@@ -18,4 +18,12 @@ describe(uniqBy, () => {
{ a: 4, b: 12 },
]);
});
it('calls getKey once per item', () => {
const getKey = jest.fn(({ a }: { a: number }) => a);
uniqBy([{ a: 1 }, { a: 2 }, { a: 2 }], getKey);
expect(getKey).toHaveBeenCalledTimes(3);
});
});
@@ -1,10 +1,11 @@
export default function uniqBy<T, K = any>(list: T[], getKey: (item: T) => K): T[] {
const uniqueValues = new Set();
const uniqueValues = new Set<K>();
const result: T[] = [];
for (const i of list) {
if (!uniqueValues.has(getKey(i))) {
const key = getKey(i);
if (!uniqueValues.has(key)) {
result.push(i);
uniqueValues.add(getKey(i));
uniqueValues.add(key);
}
}
return result;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "23.1.0",
"version": "23.2.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,7 +26,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "23.1.0",
"@expo/build-tools": "23.2.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "23.1.0",
"@expo/logger": "23.1.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "23.1.0",
"version": "23.2.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,7 +19,7 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "23.1.0",
"@expo/build-tools": "23.2.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "23.1.0",
Generated
+130 -3
View File
@@ -2291,7 +2291,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:23.1.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:23.2.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
@@ -2969,7 +2969,7 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:23.1.0"
"@expo/build-tools": "npm:23.2.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:23.1.0"
@@ -6031,6 +6031,71 @@ __metadata:
languageName: node
linkType: hard
"@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2":
version: 1.1.2
resolution: "@protobufjs/aspromise@npm:1.1.2"
checksum: 10c0/a83343a468ff5b5ec6bff36fd788a64c839e48a07ff9f4f813564f58caf44d011cd6504ed2147bf34835bd7a7dd2107052af755961c6b098fd8902b4f6500d0f
languageName: node
linkType: hard
"@protobufjs/base64@npm:^1.1.2":
version: 1.1.2
resolution: "@protobufjs/base64@npm:1.1.2"
checksum: 10c0/eec925e681081af190b8ee231f9bad3101e189abbc182ff279da6b531e7dbd2a56f1f306f37a80b1be9e00aa2d271690d08dcc5f326f71c9eed8546675c8caf6
languageName: node
linkType: hard
"@protobufjs/codegen@npm:^2.0.5":
version: 2.0.5
resolution: "@protobufjs/codegen@npm:2.0.5"
checksum: 10c0/1b8a2ae56ee60a56e9d205cd4b6072a1503c5069b8ebb905710f974ff0098a0d0700641c137e0a8d98dedf14423156a106a9433695cbf52574810f55000fdcab
languageName: node
linkType: hard
"@protobufjs/eventemitter@npm:^1.1.1":
version: 1.1.1
resolution: "@protobufjs/eventemitter@npm:1.1.1"
checksum: 10c0/8e06193d4629c5e7c09d4f8c2ddba8fc4dfa739f0149f33a1d901568d35bb7b8b5277a4e8452baf3bdd0b302fd599cf255d193267aa93a0a4747e23cd073c4ac
languageName: node
linkType: hard
"@protobufjs/fetch@npm:^1.1.1":
version: 1.1.1
resolution: "@protobufjs/fetch@npm:1.1.1"
dependencies:
"@protobufjs/aspromise": "npm:^1.1.1"
checksum: 10c0/a497ff5433854e8577f0427983ea39b9113b49a8120f94515291d763327061d2c3013e60e24ea436d091dafae01a0f6eb1867e3b1616045d96a31d8b3c646ed4
languageName: node
linkType: hard
"@protobufjs/float@npm:^1.0.2":
version: 1.0.2
resolution: "@protobufjs/float@npm:1.0.2"
checksum: 10c0/18f2bdede76ffcf0170708af15c9c9db6259b771e6b84c51b06df34a9c339dbbeec267d14ce0bddd20acc142b1d980d983d31434398df7f98eb0c94a0eb79069
languageName: node
linkType: hard
"@protobufjs/path@npm:^1.1.2":
version: 1.1.2
resolution: "@protobufjs/path@npm:1.1.2"
checksum: 10c0/cece0a938e7f5dfd2fa03f8c14f2f1cf8b0d6e13ac7326ff4c96ea311effd5fb7ae0bba754fbf505312af2e38500250c90e68506b97c02360a43793d88a0d8b4
languageName: node
linkType: hard
"@protobufjs/pool@npm:^1.1.0":
version: 1.1.0
resolution: "@protobufjs/pool@npm:1.1.0"
checksum: 10c0/eda2718b7f222ac6e6ad36f758a92ef90d26526026a19f4f17f668f45e0306a5bd734def3f48f51f8134ae0978b6262a5c517c08b115a551756d1a3aadfcf038
languageName: node
linkType: hard
"@protobufjs/utf8@npm:^1.1.1":
version: 1.1.2
resolution: "@protobufjs/utf8@npm:1.1.2"
checksum: 10c0/975c6e2c0319bd50c17531d186b537a88cb4d3205e3ca9297cd842b631d341da0ee3ff16be8656cd2660756dc753b29c8a6a14c0ad8ed602c1f91ddf22e7b3f3
languageName: node
linkType: hard
"@react-native/normalize-colors@npm:0.79.6":
version: 0.79.6
resolution: "@react-native/normalize-colors@npm:0.79.6"
@@ -7530,6 +7595,15 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:>=13.7.0":
version: 26.2.0
resolution: "@types/node@npm:26.2.0"
dependencies:
undici-types: "npm:~8.3.0"
checksum: 10c0/f8566d88162241eb55a46f7e4ea097188eab0aaafefea7a58e63adab8c4144eb98c08b7911452c846104c339a0f82282f5e1ed4b26b578d60709614fe2843f4d
languageName: node
linkType: hard
"@types/node@npm:^22.5.5":
version: 22.19.15
resolution: "@types/node@npm:22.19.15"
@@ -9015,6 +9089,16 @@ __metadata:
languageName: node
linkType: hard
"centrifuge@npm:5.7.1":
version: 5.7.1
resolution: "centrifuge@npm:5.7.1"
dependencies:
events: "npm:^3.3.0"
protobufjs: "npm:^7.6.0"
checksum: 10c0/fb5547b7f30383a0086bd47d3e27f2d1442fc7c6c89c0e48e76621e5c3bd17cf362d53d4bfb1d2d3c4461e48c95b3cc90654da3b0958152d7afdbf6318996357
languageName: node
linkType: hard
"chalk@npm:4.1.0, chalk@npm:^4.0.0, chalk@npm:^4.1.0":
version: 4.1.0
resolution: "chalk@npm:4.1.0"
@@ -10328,7 +10412,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:23.1.0"
"@expo/build-tools": "npm:23.2.0"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:23.1.0"
"@expo/logger": "npm:23.1.0"
@@ -10431,6 +10515,7 @@ __metadata:
"@types/tar-stream": "npm:3.1.3"
"@types/tough-cookie": "npm:4.0.2"
"@types/wrap-ansi": "npm:3.0.0"
"@types/ws": "npm:8.5.10"
"@urql/core": "npm:4.0.11"
"@urql/exchange-retry": "npm:1.2.0"
agent-cli-detector: "npm:0.1.7"
@@ -10439,6 +10524,7 @@ __metadata:
axios: "npm:1.18.1"
better-opn: "npm:3.0.2"
bplist-parser: "npm:^0.3.0"
centrifuge: "npm:5.7.1"
chalk: "npm:4.1.2"
cli-progress: "npm:3.12.0"
dateformat: "npm:4.6.3"
@@ -10503,6 +10589,7 @@ __metadata:
untildify: "npm:4.0.0"
uuid: "npm:11.1.1"
wrap-ansi: "npm:7.0.0"
ws: "npm:8.21.1"
yaml: "npm:2.6.0"
zod: "npm:^4.1.3"
bin:
@@ -10875,6 +10962,13 @@ __metadata:
languageName: node
linkType: hard
"events@npm:^3.3.0":
version: 3.3.0
resolution: "events@npm:3.3.0"
checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6
languageName: node
linkType: hard
"exec-async@npm:^2.2.0":
version: 2.2.0
resolution: "exec-async@npm:2.2.0"
@@ -14339,6 +14433,13 @@ __metadata:
languageName: node
linkType: hard
"long@npm:^5.3.2":
version: 5.3.2
resolution: "long@npm:5.3.2"
checksum: 10c0/7130fe1cbce2dca06734b35b70d380ca3f70271c7f8852c922a7c62c86c4e35f0c39290565eca7133c625908d40e126ac57c02b1b1a4636b9457d77e1e60b981
languageName: node
linkType: hard
"loose-envify@npm:^1.0.0":
version: 1.4.0
resolution: "loose-envify@npm:1.4.0"
@@ -16871,6 +16972,25 @@ __metadata:
languageName: node
linkType: hard
"protobufjs@npm:^7.6.0":
version: 7.6.5
resolution: "protobufjs@npm:7.6.5"
dependencies:
"@protobufjs/aspromise": "npm:^1.1.2"
"@protobufjs/base64": "npm:^1.1.2"
"@protobufjs/codegen": "npm:^2.0.5"
"@protobufjs/eventemitter": "npm:^1.1.1"
"@protobufjs/fetch": "npm:^1.1.1"
"@protobufjs/float": "npm:^1.0.2"
"@protobufjs/path": "npm:^1.1.2"
"@protobufjs/pool": "npm:^1.1.0"
"@protobufjs/utf8": "npm:^1.1.1"
"@types/node": "npm:>=13.7.0"
long: "npm:^5.3.2"
checksum: 10c0/863eaca9c6f45bfcfb8787c545f53e9c6696507e328e3981b4643c12d35df7f2826021c10e3fd30bef342c13a8e9d306547bac9c0849ee3bf50f770a12f01dc5
languageName: node
linkType: hard
"protocols@npm:^2.0.0, protocols@npm:^2.0.1":
version: 2.0.1
resolution: "protocols@npm:2.0.1"
@@ -19228,6 +19348,13 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~8.3.0":
version: 8.3.0
resolution: "undici-types@npm:8.3.0"
checksum: 10c0/c8aa7e2fbebfce519654dafadc0ece59be888d2ccaf180fb4495da875e7b536d2456345c384069c7e6f3e9c9ab7435f074957da306f142343eee86ff8048855a
languageName: node
linkType: hard
"unique-filename@npm:^4.0.0":
version: 4.0.0
resolution: "unique-filename@npm:4.0.0"