Compare commits

...
18 Commits
Author SHA1 Message Date
Expo CI 4a109648a6 v24.7.0 2026-09-16 22:13:30 +00:00
Gabe Debes ea139e207c [eas-cli] point preview links at the expo.dev page and expose the preview api url (#4352) 2026-09-16 15:07:49 -07:00
Gabe Debes 7b3c8cf245 [build-tools] report the expo.dev preview page url and the preview api url (#4353) 2026-09-16 14:31:37 -07:00
Gabe Debes efbe0bbdff [build-tools] Launch simulator packages with the resolved package manager (#4343)
* [build-tools] Launch simulator packages with the resolved package manager

Serve-sim, device-hub, and Argent were hardcoded to npx or bunx. Resolve the manager the same way other steps do so EAS_FALLBACK_PACKAGE_MANAGER can switch them off the npm audit path.

* [build-tools] Override the simulator package manager instead of using the lockfile fallback

EAS_FALLBACK_PACKAGE_MANAGER only applies when no lockfile is found, so a worker yarn.lock would keep npx. EAS_OVERRIDE_PACKAGE_MANAGER wins when set. Preview still defaults to npx and Argent to bunx.

* [build-tools] Honor fallback as well as override for simulator package exec

Override still wins when set. If it is unset, EAS_FALLBACK_PACKAGE_MANAGER selects the runner so serve-sim can use bunx without a lockfile-based resolve.

* [build-tools] Add changelog for simulator package manager env

* [build-tools] Format package manager changes

* [build-tools] Honor the simulator package manager for agent-device and Appium

Install these session packages through the same env override/fallback as serve-sim, instead of hardcoding bun add --global and npm install.

* [build-tools] Sort Appium session test imports for lint

* [build-tools] Keep simulator process comments concrete

* [build-tools] Cover simulator package install and agent-device fallback
2026-09-15 16:19:51 -07:00
Gabe Debes 8f645d8870 [build-tools] Default serve-sim iOS previews to H.264 (#4416)
* fix(build-tools): default serve-sim iOS previews to H.264

* changelog
2026-09-15 13:34:00 -07:00
Stanisław Chmiela 97007d17cc [build-tools] Use maestro-runner --parallel execution for shards (#4407)
`maestro-runner` supports sharding!

Runner parallel execution: https://github.com/devicelab-dev/maestro-runner/blob/main/pkg/executor/parallel.go
2026-09-15 19:09:52 +02:00
Expo CI 0dbaa34a8b bump STAGING and PRODUCTION to 24.6.0 in cli-versions.json 2026-09-15 16:21:45 +00:00
Expo CI 2fc537f9a7 update CHANGELOG.md for v24.6.0 2026-09-15 16:17:00 +00:00
Expo CI 71cf0aea74 v24.6.0 2026-09-15 16:14:36 +00:00
Will Schurman 🦦 f6a3028356 [eas-cli] Remove broad primaryAccount querying to support scoped token access (#4380) 2026-09-15 08:38:14 -07:00
Will Schurman 🦦 3182070f4c [eas-cli] Regenerate graphql and fix types (#4379) 2026-09-15 08:14:01 -07:00
Tomasz Mazur 097b58c7a9 [eas-cli] Display build logs when --wait flag is set (#4303)
* [eas-cli] Display build logs when --wait flag is set

* [eas-cli] use custom fetch

* [eas-cli] add timeouts to builds logs watch
2026-09-15 16:57:22 +02:00
Expo CI 9584d05708 bump STAGING and PRODUCTION to 24.5.0 in cli-versions.json 2026-09-15 09:48:19 +00:00
Expo CI b9ac59b336 update CHANGELOG.md for v24.5.0 2026-09-15 09:44:37 +00:00
Expo CI 43068db22a v24.5.0 2026-09-15 09:41:35 +00:00
Stanisław Chmiela 2b7f1954fc [build-tools] Execute sandbox commands over MCP connection (#4347)
## Why

Clients are telling sandboxes through MCP server what to do. Let's handle the requests!

## How

The endeavour starts from a (relatively, after all) complex task of starting commands and reading/writing to them.

Since commands may live longer than a tool call (request followed by a response), we need some kind of manager of running commands — Shell Session Manager. (Session is the running command.)

This is where most of the complexity lays, the rest is mostly a wrapper.

## Test plan

CI should pass. Tested manually.
2026-09-15 11:34:05 +02:00
Expo CI 3b64599aea bump STAGING and PRODUCTION to 24.4.2 in cli-versions.json 2026-09-15 05:07:36 +00:00
Expo CI 96fe9a239e update CHANGELOG.md for v24.4.2 2026-09-15 05:05:39 +00:00
94 changed files with 4530 additions and 1303 deletions

No files matched your search

+33 -2
View File
@@ -8,12 +8,43 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [build-tools] Stream iOS Simulator previews with H.264. ([#4416](https://github.com/expo/eas-cli/pull/4416) by [@gwdp](https://github.com/gwdp))
- [build-tools] Report the expo.dev preview page as a simulator session's preview URL, and the preview server behind it as `previewApiUrl`. ([#4353](https://github.com/expo/eas-cli/pull/4353) by [@gwdp](https://github.com/gwdp))
- [eas-cli] `eas simulator:start` and `eas simulator:get` now report the expo.dev preview page instead of the raw tunnel URL, in both the printed instructions and `--json`. ([#4352](https://github.com/expo/eas-cli/pull/4352) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Honor `EAS_OVERRIDE_PACKAGE_MANAGER` and `EAS_FALLBACK_PACKAGE_MANAGER` when installing and launching simulator packages (serve-sim, device-hub, Argent, agent-device, Appium) so they can use bun instead of stalling on npm audit. ([#4343](https://github.com/expo/eas-cli/pull/4343) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
## [24.6.0](https://github.com/expo/eas-cli/releases/tag/v24.6.0) - 2026-09-15
### 🎉 New features
- [eas-cli] Display logs in `eas build --wait`. ([#4303](https://github.com/expo/eas-cli/pull/4303) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [build-tools] Skip project source refresh for jobs with no sources. ([#4389](https://github.com/expo/eas-cli/pull/4389) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Stop fetching the primary account in the login query. Commands that need it now fetch it on demand, so scoped access tokens without primary account access no longer fail at login. ([#4380](https://github.com/expo/eas-cli/pull/4380) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- [eas-cli] Regenerate GraphQL types and update Observe tests for the new `firstSeenAt` and `lastSeenAt` event-name fields. ([#4379](https://github.com/expo/eas-cli/pull/4379) by [@wschurman](https://github.com/wschurman))
## [24.5.0](https://github.com/expo/eas-cli/releases/tag/v24.5.0) - 2026-09-15
### 🎉 New features
- [build-tools] Let connected sandboxes run shell commands through the Expo MCP server. ([#4347](https://github.com/expo/eas-cli/pull/4347) by [@sjchmiela](https://github.com/sjchmiela))
## [24.4.2](https://github.com/expo/eas-cli/releases/tag/v24.4.2) - 2026-09-15
### 🐛 Bug fixes
- [eas-cli] Support Xcode 27, which replaces Simulator.app with Device Hub, in `build:dev`, `build:run`, and `run` for iOS simulator builds. ([#4405](https://github.com/expo/eas-cli/pull/4405) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
## [24.4.1](https://github.com/expo/eas-cli/releases/tag/v24.4.1) - 2026-09-15
### 🧹 Chores
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.4.1",
"PRODUCTION": "24.4.1"
"STAGING": "24.6.0",
"PRODUCTION": "24.6.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.4.2",
"version": "24.7.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.4.0",
"version": "24.7.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,14 +42,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "24.0.0",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.3.0",
"@expo/steps": "24.5.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -67,6 +67,7 @@
"lodash": "^4.18.1",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-pty": "1.2.0-beta.15",
"node-stream-zip": "1.15.0",
"nullthrows": "^1.1.1",
"plist": "^3.1.0",
@@ -256,7 +256,9 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('rejects sharding with maestro-runner', async () => {
it('passes shards to maestro-runner as parallel devices', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
@@ -264,10 +266,9 @@ describe('createMaestroTestsBuildFunction', () => {
shards: 2,
});
await expect(step.executeAsync()).rejects.toThrow(
'maestro-runner does not support EAS Maestro test sharding'
);
expect(mockedSpawn).not.toHaveBeenCalled();
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
});
it('rejects a non-junit output_format with maestro-runner', async () => {
@@ -52,7 +52,11 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
ensureFfmpegInstalledOnceAsync: jest.fn(),
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
startNgrokTunnelAsync: async () => ({ url: 'https://controller.test', stopAsync: jest.fn() }),
startNgrokTunnelAsync: async () => ({
url: 'https://controller.test',
subdomainId: 'controller-id',
stopAsync: jest.fn(),
}),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
@@ -115,7 +119,8 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://preview.test',
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.test',
previewToken: 'preview-secret',
stopAsync: stopPreview,
});
@@ -1,9 +1,38 @@
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { Sentry } from '../../../sentry';
import { stopAgentDeviceEventCollectionSafelyAsync } from '../startAgentDeviceRemoteSession';
import { spawnDetached } from '../../utils/remoteDeviceRunSession';
import {
startAgentDeviceDaemonAsync,
stopAgentDeviceEventCollectionSafelyAsync,
} from '../startAgentDeviceRemoteSession';
jest.mock('../../../sentry');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
spawnDetached: jest.fn(),
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
async function writeDaemonEntry(cwd: string): Promise<string> {
const daemonPath = path.join(
cwd,
'node_modules',
'agent-device',
'dist',
'src',
'internal',
'daemon.js'
);
await fs.promises.mkdir(path.dirname(daemonPath), { recursive: true });
await fs.promises.writeFile(daemonPath, '');
return daemonPath;
}
describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
beforeEach(() => {
@@ -12,7 +41,6 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
it('reports an unexpected stop failure without rejecting', async () => {
const error = new Error('stop failed');
const logger = { warn: jest.fn() } as unknown as bunyan;
await expect(
stopAgentDeviceEventCollectionSafelyAsync({
@@ -37,3 +65,161 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
);
});
});
describe(startAgentDeviceDaemonAsync, () => {
const stopAsync = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
jest.clearAllMocks();
stopAsync.mockResolvedValue(undefined);
jest.mocked(spawnDetached).mockReturnValue({
pid: 1234,
getOutput: () => '',
stopAsync,
});
jest.mocked(spawn).mockImplementation((async (
command: string,
args: string[],
options?: { cwd?: string }
) => {
if (command === 'bun' && args[0] === '--version') {
return { stdout: '1.3.11\n' };
}
if (typeof options?.cwd === 'string' && command !== 'git') {
await writeDaemonEntry(options.cwd);
}
return { stdout: '' };
}) as never);
});
it('installs with bun add by default and launches the published daemon', async () => {
const handle = await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
expect(spawn).toHaveBeenCalledWith(
'bun',
['add', '[email protected]'],
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
);
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
expect(spawnDetached).toHaveBeenCalledWith({
command: 'node',
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
});
await handle.stopAsync();
await expect(fs.promises.access(addCwd)).rejects.toThrow();
});
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
logger,
});
expect(spawn).toHaveBeenCalledWith(
'npm',
['install', '--no-audit', 'agent-device@latest'],
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
);
});
it('falls back to git clone when the published daemon is missing', async () => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (command === 'bun' && args[0] === '--version') {
return { stdout: '1.3.11\n' };
}
return { stdout: '' };
}) as never);
await startAgentDeviceDaemonAsync({
packageVersion: '1.2.3',
env: {},
logger,
});
expect(spawn).toHaveBeenCalledWith(
'git',
[
'clone',
'--depth',
'1',
'--branch',
'v1.2.3',
'https://github.com/callstack/agent-device.git',
'/tmp/agent-device-src',
],
expect.objectContaining({ env: {}, logger })
);
expect(spawn).toHaveBeenCalledWith(
'bun',
['install', '--production'],
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
);
expect(spawnDetached).toHaveBeenCalledWith({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: '/tmp/agent-device-src',
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
});
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
expect.any(Error),
expect.objectContaining({
extras: expect.objectContaining({
packageSpec: '[email protected]',
packageManager: 'bun',
bunVersion: '1.3.11',
}),
})
);
});
it('clones latest and reports unknown bun when diagnostics fail', async () => {
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
if (command === 'bun' && args[0] === '--version') {
throw new Error('bun missing');
}
return { stdout: '' };
}) as never);
await startAgentDeviceDaemonAsync({
packageVersion: undefined,
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
logger,
});
expect(spawn).toHaveBeenCalledWith(
'git',
[
'clone',
'--depth',
'1',
'https://github.com/callstack/agent-device.git',
'/tmp/agent-device-src',
],
expect.objectContaining({ logger })
);
expect(spawn).toHaveBeenCalledWith(
'npm',
['install', '--no-audit', '--omit=dev'],
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
expect.any(Error),
expect.objectContaining({
extras: expect.objectContaining({
packageVersion: 'latest',
packageManager: 'npm',
bunVersion: 'unknown',
}),
})
);
});
});
@@ -1,10 +1,16 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
import { resolveAppium3VersionSpec, resolveAppiumDeviceAsync } from '../startAppiumRemoteSession';
import {
installAppiumAsync,
resolveAppium3VersionSpec,
resolveAppiumDeviceAsync,
} from '../startAppiumRemoteSession';
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
@@ -15,6 +21,7 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
jest.mock('../../utils/remoteDeviceRunSession', () => ({
selectXcodeDeveloperDirectoryAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const logger = { info: jest.fn(), warn: jest.fn() } as never;
@@ -84,3 +91,63 @@ describe(resolveAppiumDeviceAsync, () => {
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
});
});
describe(installAppiumAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
if (args.includes('--json')) {
return { stdout: JSON.stringify({}) };
}
return { stdout: '' };
}) as never);
});
it('installs Appium with npm by default', async () => {
const result = await installAppiumAsync({
versionSpec: '^3',
driverName: 'xcuitest',
env: { EXISTING: 'value' },
logger,
});
try {
expect(spawn).toHaveBeenNthCalledWith(
1,
'npm',
['install', '--no-audit', 'appium@^3'],
expect.objectContaining({
cwd: result.appiumHome,
env: expect.objectContaining({ APPIUM_HOME: result.appiumHome, EXISTING: 'value' }),
})
);
expect(spawn).toHaveBeenCalledWith(
result.appiumBinPath,
['driver', 'install', 'xcuitest'],
expect.objectContaining({ env: result.appiumEnv, logger })
);
} finally {
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
}
});
it('installs Appium with bun add when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
const result = await installAppiumAsync({
versionSpec: '3.5.0',
driverName: 'uiautomator2',
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' },
logger,
});
try {
expect(spawn).toHaveBeenNthCalledWith(
1,
'bun',
['add', '[email protected]'],
expect.objectContaining({ cwd: result.appiumHome })
);
} finally {
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
}
});
});
@@ -88,10 +88,12 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://argent-abc.tunnel.example.com',
subdomainId: 'argent-abc',
stopAsync: mockTunnelStopAsync,
});
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.tunnel.example.com',
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.tunnel.example.com',
stopAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -179,7 +181,8 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://web-preview.tunnel.example.com',
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.tunnel.example.com',
}),
})
);
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting bun x resolve it.')
expect.stringContaining('Continuing and letting the package manager resolve it.')
);
});
});
@@ -32,7 +32,11 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
const tunnel = { url: 'https://egress.example.com', stopAsync: jest.fn() };
const tunnel = {
url: 'https://egress.example.com',
subdomainId: 'egress-id',
stopAsync: jest.fn(),
};
async function start(signal?: AbortSignal): Promise<void> {
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
@@ -59,19 +59,47 @@ describe('sandbox build functions', () => {
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
})
).rejects.toThrow();
).rejects.toMatchObject({ name: 'AbortError' });
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
} finally {
start.mockRestore();
}
});
it('provides one long-running step', () => {
const ctx = { job: {} } as any;
expect(createStartSandboxBuildFunction(ctx).getFullId()).toBe('eas/start_sandbox');
it('excludes the MCP token from the command environment', async () => {
const env = {
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
__EAS_SANDBOX_MCP_TOKEN: 'token',
};
const startupError = new Error('stop after checking options');
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
mcpServerUrl: 'ws://localhost:8787',
} as any);
try {
await expect(
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env,
})
).rejects.toBe(startupError);
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'token' });
expect(start.mock.calls[0][0].env).toEqual({
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
});
expect(env.__EAS_SANDBOX_MCP_TOKEN).toBe('token');
} finally {
start.mockRestore();
}
});
it('throws a system error when the sandbox token is missing', async () => {
@@ -100,9 +128,9 @@ describe('sandbox build functions', () => {
fn.fn!({} as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
})
).rejects.toBeInstanceOf(SystemError);
).rejects.toThrow('MCP server URL is required to start the sandbox daemon.');
});
it('marks the sandbox as ready', async () => {
@@ -44,7 +44,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
@@ -54,7 +55,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://web-preview.example.test',
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
stopAsync,
});
@@ -64,7 +66,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: {
previewUrl: 'https://web-preview.example.test',
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
previewToken: 'tok-1',
},
})
@@ -89,7 +92,10 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'device-run-session-id',
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
remoteConfig: {
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://web-preview.example.test',
},
logger,
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
@@ -108,6 +108,9 @@ function buildMaestroArgs({
}
case 'maestro-runner': {
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
if (shards !== undefined && shards > 1) {
args.push(`--parallel=${shards}`);
}
if (includeTags) {
args.push(`--include-tags=${includeTags}`);
}
@@ -259,12 +262,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
undefined,
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
);
if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.'
);
}
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
@@ -18,6 +18,12 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { Sentry } from '../../sentry';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageAdd,
resolvePackageInstall,
} from '../../utils/packageManager';
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
import {
@@ -122,7 +128,9 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -132,7 +140,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
remoteConfig: {
agentDeviceRemoteSessionUrl,
agentDeviceRemoteSessionToken: daemonToken,
webPreviewUrl: webPreview.previewUrl,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
@@ -183,6 +192,74 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
}
export async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
await fs.promises.writeFile(
path.join(installDir, 'package.json'),
`${JSON.stringify({ name: 'eas-agent-device', private: true })}\n`
);
try {
const add = resolvePackageAdd(packageManager, packageSpec);
logger.info(`Installing ${packageSpec} with ${add.command}.`);
await spawn(add.command, add.args, { cwd: installDir, env, logger });
const daemonPath = getInstalledAgentDeviceDaemonPath(installDir);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath} after ${add.command} install.`);
const daemonProcess = spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
});
return {
...daemonProcess,
stopAsync: async () => {
await daemonProcess.stopAsync();
await fs.promises.rm(installDir, { recursive: true, force: true });
},
};
} catch (err) {
await fs.promises.rm(installDir, { recursive: true, force: true });
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
packageManager,
bunVersion,
},
}
);
logger.warn(
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
}
export async function stopAgentDeviceEventCollectionSafelyAsync({
eventCollection,
deviceRunSessionId,
@@ -205,61 +282,6 @@ export async function stopAgentDeviceEventCollectionSafelyAsync({
}
}
async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
try {
logger.info(`Installing ${packageSpec} globally with Bun.`);
await spawn('bun', ['add', '--global', packageSpec], {
env,
logger,
});
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath}.`);
return spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
bunVersion,
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
},
}
);
logger.warn(
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
}
async function startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
@@ -276,14 +298,17 @@ async function startAgentDeviceDaemonFromGitAsync({
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const install = resolvePackageInstall(packageManager, { production: true });
logger.info(`Installing agent-device dependencies with ${install.command}.`);
await spawn(install.command, install.args, {
cwd: SRC_DIR,
env,
logger,
});
logger.info('Launching daemon from cloned agent-device source.');
// Git fallback is TypeScript source. The published path runs node on dist JS.
return spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
@@ -346,11 +371,9 @@ function createAgentDevicePackageSpec(packageVersion: string | undefined): strin
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
}
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
function getInstalledAgentDeviceDaemonPath(installDir: string): string {
return path.join(
getBunInstallDirectory(env),
'install',
'global',
installDir,
'node_modules',
AGENT_DEVICE_PACKAGE_NAME,
'dist',
@@ -360,11 +383,6 @@ function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
);
}
function getBunInstallDirectory(env: BuildStepEnv): string {
const bunInstall = env.BUN_INSTALL?.trim();
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
}
function parseDaemonInfo(raw: string): { port: number; token: string } {
const parsed = JSON.parse(raw) as unknown;
if (
@@ -21,6 +21,11 @@ import {
} from '../utils/localEgressSession';
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageAdd,
} from '../../utils/packageManager';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
@@ -153,7 +158,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
'appium:automationName': device.automationName,
'appium:udid': device.udid,
},
webPreviewUrl: webPreview.previewUrl,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
@@ -198,7 +204,7 @@ export function resolveAppium3VersionSpec(packageVersion: string | undefined): s
return versionSpec;
}
type AppiumDevice = {
export type AppiumDevice = {
platformName: 'iOS' | 'Android';
automationName: 'XCUITest' | 'UiAutomator2';
driverName: 'xcuitest' | 'uiautomator2';
@@ -247,7 +253,7 @@ export async function resolveAppiumDeviceAsync({
}
}
async function installAppiumAsync({
export async function installAppiumAsync({
versionSpec,
driverName,
env,
@@ -265,9 +271,14 @@ async function installAppiumAsync({
);
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
const add = resolvePackageAdd(
resolveConfiguredPackageManager(env, PackageManager.NPM),
`appium@${versionSpec}`
);
logger.info(`Installing appium@${versionSpec}.`);
await spawn('npm', ['install', '--prefix', appiumHome, `appium@${versionSpec}`], {
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
await spawn(add.command, add.args, {
cwd: appiumHome,
env: appiumEnv,
logger,
});
@@ -19,6 +19,11 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import { Sentry } from '../../sentry';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { isProcessDescendantOfAsync } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
@@ -113,41 +118,44 @@ export function createStartArgentRemoteSessionBuildFunction(
// Never rejects, so `void` is safe.
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
const argentExec = (args: string[]): { command: string; args: string[] } =>
resolvePackageExec(packageManager, args);
logger.info('Enabling the Argent artifacts list endpoint flag.');
await spawn(
'bun',
[
'x',
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
],
{ env, logger }
);
const enableArtifacts = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
]);
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
logger.info('Enabling the Argent tool-server event log flag.');
await spawn(
'bun',
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
{ env, logger }
);
const enableEventLog = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'enable',
ARGENT_EVENT_LOG_FLAG,
]);
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bun x.`);
// Keep Argent itself in foreground mode under the detached bun process. This preserves
// the bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const startServer = argentExec([
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
]);
logger.info(
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
);
// Keep Argent itself in foreground mode under the detached process. This preserves
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const argentServer = spawnDetached({
command: 'bun',
args: [
'x',
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
],
command: startServer.command,
args: startServer.args,
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH },
});
if (argentServer.pid === undefined) {
@@ -216,7 +224,9 @@ export function createStartArgentRemoteSessionBuildFunction(
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
logger.info(
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
@@ -226,7 +236,8 @@ export function createStartArgentRemoteSessionBuildFunction(
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
webPreviewUrl: webPreview.previewUrl,
webPreviewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
},
logger,
@@ -306,7 +317,7 @@ export function warnIfArgentPackageVersionCannotBeVerified({
logger.warn(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
`Continuing and letting bun x resolve it.`
`Continuing and letting the package manager resolve it.`
);
return;
}
@@ -32,8 +32,9 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs, signal }) => {
const sandboxToken = ctx.env.__EAS_SANDBOX_MCP_TOKEN;
fn: async (stepCtx, { inputs, signal, env }) => {
// The daemon needs this credential, but shell commands do not.
const { __EAS_SANDBOX_MCP_TOKEN: sandboxToken, ...commandEnv } = env;
if (!sandboxToken) {
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
}
@@ -48,6 +49,8 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
reconnectDelayMs: RECONNECT_DELAY_MS,
logger: stepCtx.logger,
signal,
workingDirectory: stepCtx.workingDirectory,
env: commandEnv,
});
try {
await daemon.ready;
@@ -61,7 +61,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
timeoutMs: STARTUP_TIMEOUT_MS,
packageVersion,
});
logger.info(`Preview URL: ${webPreview.previewUrl}`);
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
try {
await uploadRemoteSessionConfigWithLocalEgressAsync({
@@ -70,7 +70,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
ctx,
deviceRunSessionId,
remoteConfig: {
previewUrl: webPreview.previewUrl,
previewUrl: webPreview.previewPageUrl,
previewApiUrl: webPreview.apiUrl,
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
},
logger,
@@ -25,6 +25,7 @@ import {
turnIceServersToWebPreviewArgs,
waitForDeviceRunSessionStoppedAsync,
waitForWebPreviewReadyAsync,
websiteOrigin,
} from '../remoteDeviceRunSession';
jest.mock('@ngrok/ngrok');
@@ -130,7 +131,6 @@ describe(createServeSimArgs, () => {
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
})
).toEqual([
'--yes',
'@expo/serve-sim@latest',
'--port',
'4321',
@@ -140,7 +140,7 @@ describe(createServeSimArgs, () => {
'--transport',
'webrtc',
'--webrtc-codec',
'vp8',
'h264',
'--max-dimension',
'960',
'--mjpeg-quality',
@@ -169,17 +169,15 @@ describe(createServeSimArgs, () => {
});
it('pins the requested package version', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' }).slice(0, 2)).toEqual([
'--yes',
'@expo/[email protected]',
]);
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' })[0]).toBe(
'@expo/[email protected]'
);
});
it('pins a dist-tag', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' }).slice(0, 2)).toEqual([
'--yes',
'@expo/serve-sim@next',
]);
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' })[0]).toBe(
'@expo/serve-sim@next'
);
});
});
@@ -191,7 +189,6 @@ describe(createExpoDeviceHubArgs, () => {
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
})
).toEqual([
'--yes',
'expo-device-hub@latest',
'--port',
'4321',
@@ -219,10 +216,9 @@ describe(createExpoDeviceHubArgs, () => {
});
it('pins the requested package version', () => {
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
'--yes',
'[email protected]',
]);
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' })[0]).toBe(
'[email protected]'
);
});
});
@@ -309,6 +305,11 @@ describe(startNgrokTunnelAsync, () => {
})
);
expect(tunnel.url).toBe('https://web-preview.example.test');
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({
domain: `web-preview-${tunnel.subdomainId}.eas-simulator.ngrok.dev`,
})
);
await tunnel.stopAsync();
await tunnel.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
@@ -419,9 +420,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
const port = Number(args[args.indexOf('--port') + 1]);
expect(port).toBeGreaterThan(0);
expect(command).toBe('npx');
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
expect(args).toEqual(['--yes', ...createExpoDeviceHubArgs({ port, turnArgs, packageVersion })]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://android-preview.example.test');
expect(preview.apiUrl).toBe('https://android-preview.example.test');
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
@@ -442,7 +443,38 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
});
expect(preview.previewToken).toBe('tok-1');
expect(preview.previewUrl).toBe('https://preview.example.test');
expect(preview.apiUrl).toBe('https://preview.example.test');
});
it.each([
[{}, 'https://expo.dev'],
[{ EXPO_STAGING: '1' }, 'https://staging.expo.dev'],
[{ EXPO_LOCAL: '1' }, 'https://expo.test'],
])('points the preview page at the website for the stage the worker runs in', (stage, origin) => {
expect(websiteOrigin({ ...env, ...stage })).toBe(origin);
});
it('points the preview URL at the website page for the tunnel', async () => {
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://preview.example.test',
close: jest.fn().mockResolvedValue(undefined),
} as never);
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env: { ...env, EXPO_STAGING: '1' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
expect(preview.previewPageUrl).toMatch(
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
);
const previewId = preview.previewPageUrl.split('/').at(-1);
expect(ngrok.forward).toHaveBeenCalledWith(
expect.objectContaining({ domain: `web-preview-${previewId}.${baseDomain}` })
);
});
// serve-sim is always launched with --require-token, so a missing token means it is running
@@ -505,9 +537,18 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
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(args).toEqual([
'--yes',
...createServeSimArgs({
port,
turnArgs,
metricsCorsArgs,
frameAncestorArgs: ['--frame-ancestor', 'https://expo.dev'],
packageVersion,
}),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
@@ -538,6 +579,73 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('launches serve-sim with bun x when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
const close = jest.fn().mockResolvedValue(undefined);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://ios-preview.example.test',
close,
} as never);
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
runtimePlatform: BuildRuntimePlatform.DARWIN,
baseDomain,
env: bunEnv,
logger: createLoggerMock(),
timeoutMs: 10_000,
packageVersion: '4.5.6',
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(command).toBe('bun');
expect(args).toEqual([
'x',
...createServeSimArgs({
port,
turnArgs,
metricsCorsArgs,
frameAncestorArgs: ['--frame-ancestor', 'https://expo.dev'],
packageVersion: '4.5.6',
}),
]);
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
it('launches serve-sim with bun x when EAS_FALLBACK_PACKAGE_MANAGER is bun', async () => {
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: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
logger: createLoggerMock(),
timeoutMs: 10_000,
});
const [command, args] = jest.mocked(spawn).mock.calls[0];
const port = Number(args[args.indexOf('--port') + 1]);
expect(command).toBe('bun');
expect(args).toEqual([
'x',
...createServeSimArgs({
port,
turnArgs,
metricsCorsArgs,
frameAncestorArgs: ['--frame-ancestor', 'https://expo.dev'],
}),
]);
await preview.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
});
});
describe(turnIceServersToWebPreviewArgs, () => {
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SandboxDaemonResponseZ, SystemError } from '@expo/eas-build-job';
import Log from '@expo/logger';
import http from 'node:http';
import net, { AddressInfo } from 'node:net';
@@ -7,6 +7,10 @@ import WebSocket, { WebSocketServer } from 'ws';
import { startSandboxDaemonAsync } from '../sandboxDaemon';
jest.unmock('@expo/logger');
jest.unmock('fs');
jest.unmock('fs/promises');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
describe(startSandboxDaemonAsync.name, () => {
const logger = Log.child({ buildStepId: 'sandbox-test' });
@@ -17,6 +21,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
@@ -40,6 +46,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${(server.address() as AddressInfo).port}`,
reconnectDelayMs: 60_000,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
try {
const socket = await connection;
@@ -72,6 +80,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
@@ -100,6 +110,8 @@ describe(startSandboxDaemonAsync.name, () => {
reconnectDelayMs: 60_000,
logger,
signal: controller.signal,
workingDirectory: process.cwd(),
env: process.env,
});
const connectionError = daemon.ready.catch(error => error);
@@ -126,6 +138,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 60_000,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
@@ -157,6 +171,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
const daemon = await daemonPromise;
@@ -170,6 +186,117 @@ describe(startSandboxDaemonAsync.name, () => {
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('executes command messages', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (request, socket, head) => {
mcpServer.handleUpgrade(request, socket, head, client =>
mcpServer.emit('connection', client)
);
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address() as AddressInfo;
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
await daemon.ready;
const commandResponse = new Promise<string>(resolve =>
socket.once('message', data => resolve(`${data}`))
);
socket.send(
JSON.stringify({
jsonrpc: '2.0',
id: 'command-1',
method: 'execCommand',
params: { cmd: 'printf hello' },
})
);
expect(JSON.parse(await commandResponse)).toMatchObject({
jsonrpc: '2.0',
id: 'command-1',
result: { output: 'hello', exitCode: 0 },
});
await daemon.stopAsync();
mcpServer.close();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('returns JSON-RPC errors for invalid messages', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (request, socket, head) => {
mcpServer.handleUpgrade(request, socket, head, client =>
mcpServer.emit('connection', client)
);
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address() as AddressInfo;
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
await daemon.ready;
const sendAsync = async (message: string): Promise<unknown> => {
const response = new Promise<string>(resolve =>
socket.once('message', data => resolve(`${data}`))
);
socket.send(message);
return SandboxDaemonResponseZ.parse(JSON.parse(await response));
};
await expect(sendAsync('{')).resolves.toMatchObject({
id: null,
error: { code: -32700, message: 'Parse error' },
});
await expect(sendAsync('{}')).resolves.toMatchObject({
id: null,
error: { code: -32600, message: 'Invalid request' },
});
await expect(
sendAsync(JSON.stringify({ jsonrpc: '2.0', id: '1', method: 'unknown' }))
).resolves.toMatchObject({
id: '1',
error: { code: -32601, message: 'Method not found' },
});
await expect(
sendAsync(JSON.stringify({ jsonrpc: '2.0', id: '2', method: 'execCommand', params: {} }))
).resolves.toMatchObject({
id: '2',
error: { code: -32602, message: 'Invalid params' },
});
await expect(
sendAsync(
JSON.stringify({
jsonrpc: '2.0',
id: '3',
method: 'writeStdin',
params: { sessionId: 999 },
})
)
).resolves.toMatchObject({
id: '3',
error: { code: -32603, message: 'Command session 999 does not exist.' },
});
await daemon.stopAsync();
mcpServer.close();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('cancels the reconnect delay when stopped', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
@@ -186,6 +313,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 60_000,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
const daemon = await daemonPromise;
@@ -0,0 +1,286 @@
jest.unmock('fs');
jest.unmock('fs/promises');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
import { type SandboxDaemonCommandResult, type SandboxDaemonMethod } from '@expo/eas-build-job';
import fs from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import {
type SandboxDaemonCommandImplementations,
createSandboxCommandImplementations,
} from '../sandboxCommandImplementations';
describe('sandbox daemon commands', () => {
let commandImplementations: SandboxDaemonCommandImplementations;
let stopAsync: () => Promise<void>;
let workingDirectory: string;
beforeEach(async () => {
workingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sandbox-command-executor-'));
const controller = new AbortController();
const commands = createSandboxCommandImplementations({
workingDirectory,
env: process.env,
signal: controller.signal,
});
commandImplementations = commands.commandImplementations;
stopAsync = async () => {
controller.abort();
await commands.stoppedPromise;
};
});
afterEach(async () => {
await stopAsync();
await fs.chmod(workingDirectory, 0o700);
await fs.rm(workingDirectory, { recursive: true, force: true });
});
it('returns output and the exit code for a completed command', async () => {
const result = await commandImplementations.execCommand({ cmd: 'printf hello' });
expect(result).toMatchObject({ output: 'hello', exitCode: 0 });
expect('sessionId' in result).toBe(false);
});
it('rejects a command canceled during directory validation', async () => {
const controller = new AbortController();
const commands = createSandboxCommandImplementations({
workingDirectory,
env: process.env,
signal: controller.signal,
});
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
controller.abort();
await commands.stoppedPromise;
await expect(command).rejects.toMatchObject({ name: 'AbortError' });
await expect(
commands.commandImplementations.writeStdin({ sessionId: 1 })
).rejects.toMatchObject({ name: 'AbortError' });
});
it('returns a session id and accepts stdin for a running command', async () => {
const started = await commandImplementations.execCommand({
cmd: `node -e "process.stdin.once('data', data => process.stdout.write(data.toString().toUpperCase(), () => process.exit(0)))"`,
yieldTimeMs: 10,
});
const sessionId = getSessionId(started);
const completed = await commandImplementations.writeStdin({
sessionId,
chars: 'hello',
yieldTimeMs: 1_000,
});
expect(completed).toMatchObject({ output: 'HELLO', exitCode: 0 });
expect('sessionId' in completed).toBe(false);
});
it('returns only output produced since the previous call', async () => {
const started = await commandImplementations.execCommand({
cmd: `node -e "process.stdin.once('data', () => process.stdout.write('second\\n', () => process.exit(0))); console.log('first')"`,
yieldTimeMs: 50,
});
const sessionId = getSessionId(started);
expect(await readUntilAsync(started, output => output.includes('first\n'))).toMatchObject({
output: 'first\n',
});
const completed = await readUntilAsync(
await commandImplementations.writeStdin({ sessionId, chars: 'continue', yieldTimeMs: 100 }),
(_, result) => !('sessionId' in result)
);
expect(completed).toMatchObject({ output: 'second\n', exitCode: 0 });
});
it('provides a TTY when requested', async () => {
const result = await commandImplementations.execCommand({
cmd: 'test -t 0 && test -t 1 && test -t 2',
tty: true,
});
expect(result).toMatchObject({ exitCode: 0 });
});
it('uses pipes by default', async () => {
const result = await commandImplementations.execCommand({ cmd: 'test -t 0' });
expect(result).toMatchObject({ exitCode: 1 });
});
it('sends Ctrl+C to the foreground PTY process', async () => {
const started = await commandImplementations.execCommand({
cmd: `exec node -e "process.on('SIGINT', () => process.stdout.write('interrupted\\n', () => process.exit(0))); console.log('ready'); setInterval(() => {}, 1000)"`,
tty: true,
yieldTimeMs: 50,
});
await readUntilAsync(started, output => output.includes('ready'));
const completed = await readUntilAsync(
await commandImplementations.writeStdin({
sessionId: getSessionId(started),
chars: '\u0003',
yieldTimeMs: 1_000,
}),
(_, result) => !('sessionId' in result)
);
expect(completed.output).toContain('interrupted');
expect(completed).toMatchObject({ exitCode: 0 });
});
it('writes U+0003 as literal input in pipe mode', async () => {
const started = await commandImplementations.execCommand({
cmd: `node -e "process.stdin.once('data', data => process.stdout.write(data[0] + '\\n', () => process.exit(0)))"`,
yieldTimeMs: 10,
});
const completed = await readUntilAsync(
await commandImplementations.writeStdin({
sessionId: getSessionId(started),
chars: '\u0003',
yieldTimeMs: 1_000,
}),
(_, result) => !('sessionId' in result)
);
expect(completed).toMatchObject({ output: '3\n', exitCode: 0 });
});
it('reports a termination signal without an exit code', async () => {
const result = await commandImplementations.execCommand({ cmd: 'kill -TERM $$' });
expect(result).toMatchObject({ terminationSignal: 'SIGTERM' });
expect('exitCode' in result).toBe(false);
});
it('measures each call separately', async () => {
const now = jest.spyOn(performance, 'now');
try {
now.mockReturnValueOnce(1_000).mockReturnValueOnce(1_250);
const started = await commandImplementations.execCommand({
cmd: 'read value',
yieldTimeMs: 0,
});
expect(started.wallTimeSeconds).toBe(0.25);
now.mockReturnValueOnce(5_000).mockReturnValueOnce(5_100);
const polled = await commandImplementations.writeStdin({
sessionId: getSessionId(started),
yieldTimeMs: 0,
});
expect(polled.wallTimeSeconds).toBe(0.1);
} finally {
now.mockRestore();
}
});
it('uses the requested working directory', async () => {
const childDirectory = path.join(workingDirectory, 'child');
await fs.mkdir(childDirectory);
const result = await commandImplementations.execCommand({ cmd: 'pwd', workdir: 'child' });
expect(result).toMatchObject({ output: `${await fs.realpath(childDirectory)}\n`, exitCode: 0 });
});
it('reports a missing working directory', async () => {
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'missing' })
).rejects.toThrow(
`Working directory does not exist: ${path.join(workingDirectory, 'missing')}`
);
});
it('reports a working directory that is not a directory', async () => {
const file = path.join(workingDirectory, 'file');
await fs.writeFile(file, 'content');
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'file' })
).rejects.toThrow(`Working directory is not a directory: ${file}`);
});
it('reports an inaccessible working directory', async () => {
if (process.platform === 'win32') {
return;
}
const directory = path.join(workingDirectory, 'inaccessible');
await fs.mkdir(directory, 0o000);
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'inaccessible' })
).rejects.toThrow(`Working directory is not accessible: ${directory}`);
await fs.chmod(directory, 0o700);
});
it.each([false, true])('stops command descendants when tty is %s', async tty => {
const started = await commandImplementations.execCommand({
cmd: `node -e "const { spawn } = require('node:child_process'); const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); process.stdout.write(String(child.pid) + '\\n'); setInterval(() => {}, 1000)"`,
tty,
yieldTimeMs: 100,
});
const ready = await readUntilAsync(started, output => /\d+\r?\n/.test(output));
const childPid = Number(/\d+/.exec(ready.output)?.[0]);
expect(childPid).toBeGreaterThan(0);
await stopAsync();
await setTimeoutAsync(50);
expect(isProcessRunning(childPid)).toBe(false);
});
async function readUntilAsync(
initial: SandboxDaemonCommandResult<SandboxDaemonMethod>,
isReady: (output: string, result: SandboxDaemonCommandResult<SandboxDaemonMethod>) => boolean
): Promise<SandboxDaemonCommandResult<SandboxDaemonMethod>> {
let result = initial;
let output = result.output;
const deadline = Date.now() + 10_000;
while (!isReady(output, result)) {
if (!('sessionId' in result) || Date.now() >= deadline) {
throw new Error(
`Command did not reach the expected state: ${JSON.stringify({ ...result, output })}`
);
}
result = await commandImplementations.writeStdin({
sessionId: result.sessionId,
yieldTimeMs: 100,
});
output += result.output;
}
return { ...result, output };
}
});
function getSessionId(result: unknown): number {
if (
typeof result !== 'object' ||
result === null ||
!('sessionId' in result) ||
typeof result.sessionId !== 'number'
) {
throw new Error('Expected the command to still be running.');
}
return result.sessionId;
}
function isProcessRunning(pid: number): boolean {
const result = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8' });
if (result.error) {
throw result.error;
}
if (result.status === 1 && !result.stdout.trim()) {
return false;
}
expect(result.status).toBe(0);
// A zombie has exited, but its parent has not yet collected its exit status.
return !result.stdout.trim().startsWith('Z');
}
@@ -0,0 +1,105 @@
jest.unmock('node:fs/promises');
import { ShellSessionManager } from '../shellSessionManager';
import * as processes from '../../../utils/processes';
describe('ShellSessionManager', () => {
it.each([false, true])('uses only the supplied environment (tty: %s)', async tty => {
const controller = new AbortController();
const previous = process.env.SANDBOX_WORKER_ONLY;
process.env.SANDBOX_WORKER_ONLY = 'worker-secret';
const manager = new ShellSessionManager({
workingDirectory: process.cwd(),
env: { SHELL: '/bin/sh', SANDBOX_PREPARED: 'prepared-value' },
signal: controller.signal,
});
try {
const sessionId = await manager.startAsync({
cmd: 'printf "%s:%s" "$SANDBOX_PREPARED" "${SANDBOX_WORKER_ONLY-unset}"',
tty,
});
expect(await manager.readAsync(sessionId, 10_000)).toEqual({
output: 'prepared-value:unset',
exitCode: 0,
});
} finally {
controller.abort();
await manager.stoppedPromise;
if (previous === undefined) {
delete process.env.SANDBOX_WORKER_ONLY;
} else {
process.env.SANDBOX_WORKER_ONLY = previous;
}
}
});
it.each([false, true])('does not signal an exited leader (tty: %s)', async tty => {
const controller = new AbortController();
const manager = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: controller.signal,
});
const kill = jest.spyOn(processes, 'killProcessGroup');
try {
const sessionId = await manager.startAsync({ cmd: 'exit 0', tty });
expect(await manager.readAsync(sessionId, 10_000)).toMatchObject({ exitCode: 0 });
controller.abort();
await manager.stoppedPromise;
expect(kill).not.toHaveBeenCalled();
} finally {
controller.abort();
await manager.stoppedPromise;
kill.mockRestore();
}
});
it('keeps completed sessions without repeating their output', async () => {
const controller = new AbortController();
const manager = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: controller.signal,
});
try {
const sessionId = await manager.startAsync({ cmd: 'printf hello' });
expect(await manager.readAsync(sessionId, 1_000)).toEqual({ output: 'hello', exitCode: 0 });
expect(await manager.readAsync(sessionId, 1_000)).toEqual({ output: '', exitCode: 0 });
} finally {
controller.abort();
await manager.stoppedPromise;
}
});
it('keeps sessions separate and stops only its own manager', async () => {
const firstController = new AbortController();
const secondController = new AbortController();
const first = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: firstController.signal,
});
const second = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: secondController.signal,
});
try {
const firstId = await first.startAsync({ cmd: 'printf first' });
const secondId = await second.startAsync({ cmd: 'read value; printf "%s" "$value"' });
expect(await first.readAsync(firstId, 1_000)).toEqual({ output: 'first', exitCode: 0 });
firstController.abort();
await first.stoppedPromise;
await expect(first.startAsync({ cmd: 'printf stopped' })).rejects.toMatchObject({
name: 'AbortError',
});
expect(await second.readAsync(secondId, 0)).toEqual({ output: '', sessionId: secondId });
second.write(secondId, 'second\n');
expect(await second.readAsync(secondId, 1_000)).toEqual({ output: 'second', exitCode: 0 });
} finally {
firstController.abort();
secondController.abort();
await Promise.all([first.stoppedPromise, second.stoppedPromise]);
}
});
});
@@ -15,6 +15,11 @@ import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import {
PackageManager,
resolveConfiguredPackageManager,
resolvePackageExec,
} from '../../utils/packageManager';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
@@ -22,6 +27,9 @@ import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetrics
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';
// Pinned while H.264 is known to stall at larger encode sizes without recovering.
// Passing this explicitly also overrides serve-sim's own default H.264 cap, so raising it
// loses that guard too.
const SERVE_SIM_MAX_DIMENSION = '960';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
const SERVE_SIM_VIDEO_BITRATE = '6000000';
@@ -549,7 +557,7 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
}
try {
// spawnDetached creates a dedicated process group. Signaling the group also
// terminates npx/bunx descendants instead of leaving the actual daemon alive.
// terminates npx/bun descendants instead of leaving the actual daemon alive.
process.kill(-pid, 'SIGTERM');
} catch {
try {
@@ -612,6 +620,14 @@ export function spawnDetached({
};
}
export function websiteOrigin(env: BuildStepEnv): string {
return env.EXPO_LOCAL
? 'https://expo.test'
: env.EXPO_STAGING
? 'https://staging.expo.dev'
: 'https://expo.dev';
}
export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
const origin = env.EAS_SIMULATOR_METRICS_CORS_ORIGIN;
if (!origin) {
@@ -639,15 +655,16 @@ export function createServeSimArgs({
port,
turnArgs = [],
metricsCorsArgs = [],
frameAncestorArgs = [],
packageVersion,
}: {
port: number;
turnArgs?: string[];
metricsCorsArgs?: string[];
frameAncestorArgs?: string[];
packageVersion?: string;
}): string[] {
return [
'--yes',
createServeSimPackageSpec(packageVersion),
'--port',
String(port),
@@ -657,7 +674,7 @@ export function createServeSimArgs({
'--transport',
'webrtc',
'--webrtc-codec',
'vp8',
'h264',
'--max-dimension',
SERVE_SIM_MAX_DIMENSION,
'--mjpeg-quality',
@@ -668,6 +685,7 @@ export function createServeSimArgs({
SERVE_SIM_VIDEO_FPS,
...turnArgs,
...metricsCorsArgs,
...frameAncestorArgs,
];
}
@@ -681,7 +699,6 @@ export function createExpoDeviceHubArgs({
packageVersion?: string;
}): string[] {
return [
'--yes',
createExpoDeviceHubPackageSpec(packageVersion),
'--port',
String(port),
@@ -771,7 +788,8 @@ export async function waitForWebPreviewReadyAsync({
}
export type DeviceWebPreviewHandle = {
previewUrl: string;
previewPageUrl: string;
apiUrl: string;
/** Session token gating the preview. Only serve-sim mints one. */
previewToken?: string;
stopAsync: () => Promise<void>;
@@ -802,11 +820,17 @@ async function startWebPreviewWithTunnelAsync(
}
): Promise<DeviceWebPreviewHandle> {
const port = await findAvailablePortAsync();
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const previewExec = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.NPM),
createArgs(port, turnArgs)
);
logger.info(
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
);
const previewServer = spawnDetached({
command: 'npx',
args: createArgs(port, turnArgs),
command: previewExec.command,
args: previewExec.args,
env,
});
@@ -827,7 +851,11 @@ async function startWebPreviewWithTunnelAsync(
logger,
});
return {
previewUrl: tunnel.url,
previewPageUrl: new URL(
`/simulator-preview/${tunnel.subdomainId}`,
websiteOrigin(env)
).toString(),
apiUrl: tunnel.url,
previewToken,
stopAsync: async () => {
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
@@ -869,6 +897,7 @@ export async function startServeSimWithTunnelAsync(
}
): Promise<ServeSimPreviewHandle> {
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
const frameAncestorArgs = ['--frame-ancestor', websiteOrigin(env)];
return await startWebPreviewWithTunnelAsync(ctx, {
baseDomain,
env,
@@ -877,7 +906,7 @@ export async function startServeSimWithTunnelAsync(
serverName: 'serve-sim',
packageSpec: createServeSimPackageSpec(packageVersion),
createArgs: (port, turnArgs) =>
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
createServeSimArgs({ port, turnArgs, metricsCorsArgs, frameAncestorArgs, packageVersion }),
readPreviewTokenAsync: async device => {
const previewToken = await readServeSimPreviewTokenAsync(device);
if (!previewToken) {
@@ -951,6 +980,7 @@ export async function startDeviceWebPreviewWithTunnelAsync(
export type NgrokTunnelHandle = {
url: string;
subdomainId: string;
stopAsync: () => Promise<void>;
};
@@ -969,7 +999,8 @@ export async function startNgrokTunnelAsync({
rewriteHostHeader?: boolean;
logger: bunyan;
}): Promise<NgrokTunnelHandle> {
const domain = `${subdomainPrefix}-${randomBytes(16).toString('hex')}.${baseDomain}`;
const subdomainId = randomBytes(16).toString('hex');
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
// the process exits, and the step blocks forever to hold it open.
@@ -987,6 +1018,7 @@ export async function startNgrokTunnelAsync({
let stopped = false;
return {
url,
subdomainId,
stopAsync: async () => {
if (stopped) {
return;
@@ -0,0 +1,61 @@
import {
type SandboxDaemonCommandParams,
type SandboxDaemonCommandResult,
type SandboxDaemonMethod,
} from '@expo/eas-build-job';
import { performance } from 'node:perf_hooks';
import { ShellSessionManager } from './shellSessionManager';
const DEFAULT_EXEC_YIELD_TIME_MS = 10_000;
const DEFAULT_WRITE_YIELD_TIME_MS = 250;
export type SandboxDaemonCommandImplementations = {
[Method in SandboxDaemonMethod]: (
params: SandboxDaemonCommandParams<Method>
) => Promise<SandboxDaemonCommandResult<Method>>;
};
export function createSandboxCommandImplementations({
workingDirectory,
env,
signal,
}: {
workingDirectory: string;
env: NodeJS.ProcessEnv;
signal: AbortSignal;
}): {
commandImplementations: SandboxDaemonCommandImplementations;
stoppedPromise: Promise<void>;
} {
const sessions = new ShellSessionManager({ workingDirectory, env, signal });
return {
commandImplementations: {
async execCommand(params) {
const callStartedAt = performance.now();
const sessionId = await sessions.startAsync({
cmd: params.cmd,
workdir: params.workdir,
tty: params.tty,
});
const result = await sessions.readAsync(
sessionId,
params.yieldTimeMs ?? DEFAULT_EXEC_YIELD_TIME_MS
);
return { ...result, wallTimeSeconds: (performance.now() - callStartedAt) / 1_000 };
},
async writeStdin(params) {
const callStartedAt = performance.now();
if (params.chars !== undefined) {
sessions.write(params.sessionId, params.chars);
}
const result = await sessions.readAsync(
params.sessionId,
params.yieldTimeMs ?? DEFAULT_WRITE_YIELD_TIME_MS
);
return { ...result, wallTimeSeconds: (performance.now() - callStartedAt) / 1_000 };
},
},
stoppedPromise: sessions.stoppedPromise,
};
}
@@ -1,14 +1,28 @@
import { SystemError } from '@expo/eas-build-job';
import {
type SandboxDaemonCommandResult,
SandboxDaemonCommands,
type SandboxDaemonMethod,
SandboxDaemonRequestZ,
type SandboxDaemonResponse,
SystemError,
} from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import WebSocket from 'ws';
import {
type SandboxDaemonCommandImplementations,
createSandboxCommandImplementations,
} from './sandboxCommandImplementations';
export interface SandboxDaemonOptions {
credential: string;
serverUrl: string;
reconnectDelayMs: number;
logger: bunyan;
signal?: AbortSignal;
workingDirectory: string;
env: NodeJS.ProcessEnv;
}
export interface SandboxDaemon {
@@ -25,6 +39,12 @@ export async function startSandboxDaemonAsync(
let hasConnected = false;
let resolveConnected!: () => void;
let rejectConnected!: (error: Error) => void;
const { commandImplementations, stoppedPromise: commandsStoppedPromise } =
createSandboxCommandImplementations({
workingDirectory: options.workingDirectory,
env: options.env,
signal: abortController.signal,
});
const connected = new Promise<void>((resolve, reject) => {
resolveConnected = resolve;
rejectConnected = reject;
@@ -38,15 +58,39 @@ export async function startSandboxDaemonAsync(
const connectionLoop = (async () => {
while (!abortController.signal.aborted) {
try {
socket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
const connectedSocket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
handshakeTimeout: 10_000,
headers: { Authorization: `Bearer ${options.credential}` },
});
await waitForOpen(socket);
socket = connectedSocket;
await waitForOpen(connectedSocket);
options.logger.info('Sandbox MCP server connected.');
connectedSocket.on('message', async message => {
try {
const response = await handleMessageAsync(commandImplementations, message.toString());
// MCP fails pending calls on disconnect. Responses belong to this socket only.
if (connectedSocket.readyState !== WebSocket.OPEN) {
options.logger.warn(
'Sandbox command response was lost after disconnect. The command may have run and output may have been consumed.'
);
return;
}
await new Promise<void>((resolve, reject) => {
connectedSocket.send(JSON.stringify(response), error => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
} catch (error) {
options.logger.warn({ err: error }, 'Could not send sandbox command response.');
}
});
hasConnected = true;
resolveConnected();
await waitForClose(socket);
await waitForClose(connectedSocket);
} catch (error: any) {
if (!hasConnected) {
const message = `Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`;
@@ -82,6 +126,7 @@ export async function startSandboxDaemonAsync(
async stopAsync(): Promise<void> {
options.signal?.removeEventListener('abort', stop);
stop();
await commandsStoppedPromise;
await connectionLoop;
},
};
@@ -104,3 +149,48 @@ function waitForClose(socket: WebSocket): Promise<void> {
socket.once('error', reject);
});
}
async function handleMessageAsync(
commandImplementations: SandboxDaemonCommandImplementations,
message: string
): Promise<SandboxDaemonResponse> {
let rawRequest: unknown;
try {
rawRequest = JSON.parse(message);
} catch {
return { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } };
}
const request = SandboxDaemonRequestZ.safeParse(rawRequest);
if (!request.success) {
return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid request' } };
}
const { id, method, params } = request.data;
if (!Object.hasOwn(SandboxDaemonCommands, method)) {
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } };
}
const commandMethod = method as SandboxDaemonMethod;
const parsedParams = SandboxDaemonCommands[commandMethod].params.safeParse(params);
if (!parsedParams.success) {
return { jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params' } };
}
try {
return {
jsonrpc: '2.0',
id,
result: await (
commandImplementations[commandMethod] as (
params: typeof parsedParams.data
) => Promise<SandboxDaemonCommandResult<typeof commandMethod>>
)(parsedParams.data),
};
} catch (error) {
return {
jsonrpc: '2.0',
id,
error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' },
};
}
}
@@ -0,0 +1,293 @@
import * as pty from 'node-pty';
import { type ChildProcess, spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import { constants as osConstants } from 'node:os';
import path from 'node:path';
import { killProcessGroup } from '../../utils/processes';
const PROCESS_STOP_GRACE_PERIOD_MS = 1_000;
const SIGNAL_NAMES = new Map(
Object.entries(osConstants.signals)
.reverse()
.map(([name, number]) => [number, name])
);
export class ShellSessionManager {
// Keep completed sessions so callers can still read their output and exit status.
private readonly sessions = new Map<number, CommandSession>();
private nextSessionId = 1;
public readonly stoppedPromise: Promise<void>;
public constructor(
private readonly options: {
workingDirectory: string;
env: NodeJS.ProcessEnv;
signal: AbortSignal;
}
) {
this.stoppedPromise = (async () => {
if (!options.signal.aborted) {
await new Promise<void>(resolve => {
options.signal.addEventListener('abort', () => resolve(), { once: true });
});
}
await this.stopAsync();
})();
// Cleanup can fail before the owner awaits it.
this.stoppedPromise.catch(() => {});
}
public async startAsync({
cmd,
workdir,
tty,
}: {
cmd: string;
workdir?: string;
tty?: boolean;
}): Promise<number> {
this.throwIfStopped();
const workingDirectory = workdir
? path.resolve(this.options.workingDirectory, workdir)
: this.options.workingDirectory;
await validateWorkingDirectoryAsync(workingDirectory);
this.throwIfStopped();
const sessionId = this.nextSessionId++;
const session = tty
? startPtyCommand({ command: cmd, workingDirectory, env: this.options.env })
: startPipeCommand({ command: cmd, workingDirectory, env: this.options.env });
this.sessions.set(sessionId, session);
return sessionId;
}
public write(sessionId: number, chars: string): void {
this.throwIfStopped();
const session = this.getSession(sessionId);
if (!session.isCompleted && !session.error) {
session.write(chars);
}
}
public async readAsync(
sessionId: number,
yieldTimeMs: number
): Promise<
{ output: string } & (
| { exitCode: number }
| { terminationSignal: string }
| { sessionId: number }
)
> {
this.throwIfStopped();
const session = this.getSession(sessionId);
await waitForSessionsAsync([session], yieldTimeMs);
if (session.error) {
throw session.error;
}
const output = session.output;
session.output = '';
const result = {
output,
};
if (session.terminationSignal !== undefined) {
return { ...result, terminationSignal: session.terminationSignal };
}
if (session.exitCode !== undefined) {
return { ...result, exitCode: session.exitCode };
}
return { ...result, sessionId };
}
private async stopAsync(): Promise<void> {
const sessions = [...this.sessions.values()];
// Only signal groups while their leaders are alive. After a leader exits, its PID may
// be reused. Any surviving descendants are left to VM teardown.
for (const session of sessions) {
if (!session.hasLeaderExited) {
killProcessGroup(session.process, 'SIGTERM');
}
}
await waitForSessionsAsync(sessions, PROCESS_STOP_GRACE_PERIOD_MS);
for (const session of sessions) {
if (!session.hasLeaderExited) {
killProcessGroup(session.process, 'SIGKILL');
}
}
await waitForSessionsAsync(sessions, PROCESS_STOP_GRACE_PERIOD_MS);
this.sessions.clear();
}
private throwIfStopped(): void {
this.options.signal.throwIfAborted();
}
private getSession(sessionId: number): CommandSession {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Command session ${sessionId} does not exist.`);
}
return session;
}
}
type CommandSession = {
process: ChildProcess | pty.IPty;
completed: Promise<void>;
resolveCompleted: () => void;
isCompleted: boolean;
hasLeaderExited: boolean;
// Unread output is intentionally unlimited for now and can exhaust memory. Reading clears it.
output: string;
exitCode?: number;
terminationSignal?: string;
error?: Error;
write(chars: string): void;
};
function startPipeCommand({
command,
workingDirectory,
env,
}: {
command: string;
workingDirectory: string;
env: NodeJS.ProcessEnv;
}): CommandSession {
const child = spawn(command, {
cwd: workingDirectory,
env,
shell: env.SHELL ?? true,
detached: true,
});
const session = createSession(child);
session.write = (chars: string): void => {
child.stdin.write(chars);
};
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', data => {
session.output += data;
});
child.stderr.on('data', data => {
session.output += data;
});
child.stdin.on('error', () => {});
child.once('exit', () => {
session.hasLeaderExited = true;
});
child.once('error', error => {
if (!session.isCompleted) {
session.hasLeaderExited = true;
session.isCompleted = true;
session.error = error;
session.resolveCompleted();
}
});
child.once('close', (exitCode, signal) => {
if (!session.isCompleted) {
session.isCompleted = true;
if (signal) {
session.terminationSignal = signal;
} else {
session.exitCode = exitCode ?? 1;
}
session.resolveCompleted();
}
});
return session;
}
function startPtyCommand({
command,
workingDirectory,
env,
}: {
command: string;
workingDirectory: string;
env: NodeJS.ProcessEnv;
}): CommandSession {
const shell = env.SHELL ?? '/bin/sh';
const terminal = pty.spawn(shell, ['-c', command], {
cwd: workingDirectory,
env,
name: 'xterm-256color',
cols: 80,
rows: 24,
});
const session = createSession(terminal);
session.write = (chars: string): void => {
terminal.write(chars);
};
terminal.onData(data => {
session.output += data;
});
terminal.onExit(({ exitCode, signal }) => {
session.hasLeaderExited = true;
session.isCompleted = true;
if (signal) {
session.terminationSignal = SIGNAL_NAMES.get(signal) ?? `SIGNAL_${signal}`;
} else {
session.exitCode = exitCode;
}
session.resolveCompleted();
});
return session;
}
function createSession(process: CommandSession['process']): CommandSession {
let resolveCompleted!: () => void;
const completed = new Promise<void>(resolve => {
resolveCompleted = resolve;
});
return {
process,
completed,
resolveCompleted,
isCompleted: false,
hasLeaderExited: false,
output: '',
write: () => {},
};
}
async function validateWorkingDirectoryAsync(workingDirectory: string): Promise<void> {
let stats;
try {
stats = await fs.stat(workingDirectory);
} catch (error: any) {
if (error?.code === 'ENOENT') {
throw new Error(`Working directory does not exist: ${workingDirectory}`);
}
if (error?.code === 'EACCES') {
throw new Error(`Working directory is not accessible: ${workingDirectory}`);
}
throw error;
}
if (!stats.isDirectory()) {
throw new Error(`Working directory is not a directory: ${workingDirectory}`);
}
try {
await fs.access(workingDirectory, fs.constants.R_OK | fs.constants.X_OK);
} catch (error: any) {
if (error?.code === 'EACCES') {
throw new Error(`Working directory is not accessible: ${workingDirectory}`);
}
throw error;
}
}
async function waitForSessionsAsync(sessions: CommandSession[], timeoutMs: number): Promise<void> {
let timer: NodeJS.Timeout | undefined;
try {
await Promise.race([
Promise.all(sessions.map(session => session.completed)),
new Promise<void>(resolve => {
timer = setTimeout(resolve, timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
@@ -9,6 +9,12 @@ import {
PackageManager,
findPackagerRootDir,
getPackageVersionFromPackageJson,
resolveConfiguredPackageManager,
resolveFallbackPackageManager,
resolveOverridePackageManager,
resolvePackageAdd,
resolvePackageExec,
resolvePackageInstall,
resolvePackageManager,
resolvePackageVersionAsync,
shouldUseFrozenLockfile,
@@ -117,6 +123,176 @@ describe(resolvePackageManager, () => {
});
});
describe(resolveFallbackPackageManager, () => {
it('returns undefined when unset or empty', () => {
expect(resolveFallbackPackageManager({})).toBeUndefined();
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: '' })).toBeUndefined();
});
it('returns bun when set', () => {
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' })).toBe(
PackageManager.BUN
);
});
it('throws a UserError on an unsupported value', () => {
expect(() => resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' })).toThrow(
errors.UserError
);
});
});
describe(resolveOverridePackageManager, () => {
it('returns undefined when unset or empty', () => {
expect(resolveOverridePackageManager({})).toBeUndefined();
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: '' })).toBeUndefined();
});
it('returns bun when set', () => {
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' })).toBe(
PackageManager.BUN
);
});
it('throws a UserError on an unsupported value', () => {
expect(() => resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bunn' })).toThrow(
errors.UserError
);
});
});
describe(resolveConfiguredPackageManager, () => {
it('uses the unset default', () => {
expect(resolveConfiguredPackageManager({}, PackageManager.NPM)).toBe(PackageManager.NPM);
});
it('uses EAS_FALLBACK_PACKAGE_MANAGER when no override is set', () => {
expect(
resolveConfiguredPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' }, PackageManager.NPM)
).toBe(PackageManager.BUN);
});
it('prefers EAS_OVERRIDE_PACKAGE_MANAGER over the fallback', () => {
expect(
resolveConfiguredPackageManager(
{
EAS_OVERRIDE_PACKAGE_MANAGER: 'npm',
EAS_FALLBACK_PACKAGE_MANAGER: 'bun',
},
PackageManager.BUN
)
).toBe(PackageManager.NPM);
});
});
describe(resolvePackageExec, () => {
const packageArgs = ['@expo/serve-sim@latest', '--port', '1'];
it('maps npm to npx --yes', () => {
expect(resolvePackageExec(PackageManager.NPM, packageArgs)).toEqual({
command: 'npx',
args: ['--yes', ...packageArgs],
});
});
it('maps bun to bun x', () => {
expect(resolvePackageExec(PackageManager.BUN, packageArgs)).toEqual({
command: 'bun',
args: ['x', ...packageArgs],
});
});
it('maps yarn to npx --yes', () => {
expect(resolvePackageExec(PackageManager.YARN, packageArgs)).toEqual({
command: 'npx',
args: ['--yes', ...packageArgs],
});
});
it('maps pnpm to pnpm dlx', () => {
expect(resolvePackageExec(PackageManager.PNPM, packageArgs)).toEqual({
command: 'pnpm',
args: ['dlx', ...packageArgs],
});
});
});
describe(resolvePackageAdd, () => {
it('maps npm to npm install --no-audit', () => {
expect(resolvePackageAdd(PackageManager.NPM, 'appium@^3')).toEqual({
command: 'npm',
args: ['install', '--no-audit', 'appium@^3'],
});
});
it('maps bun to bun add', () => {
expect(resolvePackageAdd(PackageManager.BUN, 'agent-device@latest')).toEqual({
command: 'bun',
args: ['add', 'agent-device@latest'],
});
});
it('maps yarn to yarn add', () => {
expect(resolvePackageAdd(PackageManager.YARN, 'appium@^3')).toEqual({
command: 'yarn',
args: ['add', 'appium@^3'],
});
});
it('maps pnpm to pnpm add', () => {
expect(resolvePackageAdd(PackageManager.PNPM, 'appium@^3')).toEqual({
command: 'pnpm',
args: ['add', 'appium@^3'],
});
});
});
describe(resolvePackageInstall, () => {
it('maps bun production install', () => {
expect(resolvePackageInstall(PackageManager.BUN, { production: true })).toEqual({
command: 'bun',
args: ['install', '--production'],
});
});
it('maps npm production install without audit', () => {
expect(resolvePackageInstall(PackageManager.NPM, { production: true })).toEqual({
command: 'npm',
args: ['install', '--no-audit', '--omit=dev'],
});
});
it('maps yarn and pnpm production installs', () => {
expect(resolvePackageInstall(PackageManager.YARN, { production: true })).toEqual({
command: 'yarn',
args: ['install', '--production'],
});
expect(resolvePackageInstall(PackageManager.PNPM, { production: true })).toEqual({
command: 'pnpm',
args: ['install', '--prod'],
});
});
it('maps non-production installs', () => {
expect(resolvePackageInstall(PackageManager.NPM)).toEqual({
command: 'npm',
args: ['install', '--no-audit'],
});
expect(resolvePackageInstall(PackageManager.BUN)).toEqual({
command: 'bun',
args: ['install'],
});
expect(resolvePackageInstall(PackageManager.YARN)).toEqual({
command: 'yarn',
args: ['install'],
});
expect(resolvePackageInstall(PackageManager.PNPM)).toEqual({
command: 'pnpm',
args: ['install'],
});
});
});
describe(resolvePackageVersionAsync, () => {
const logger = {
debug: jest.fn(),
@@ -30,19 +30,100 @@ export function resolvePackageManager(
}
} catch {}
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
if (fallback) {
const parsed = z.enum(PackageManager).safeParse(fallback);
if (parsed.success) {
return parsed.data;
}
const allowed = Object.values(PackageManager).join(', ');
throw new errors.UserError(
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER',
`Invalid EAS_FALLBACK_PACKAGE_MANAGER value "${fallback}" (expected one of: ${allowed}).`
);
return resolveFallbackPackageManager(env) ?? PackageManager.YARN;
}
function parsePackageManagerEnvValue(
value: string | undefined,
envName: string,
errorCode: string
): PackageManager | undefined {
if (!value) {
return undefined;
}
const parsed = z.enum(PackageManager).safeParse(value);
if (parsed.success) {
return parsed.data;
}
const allowed = Object.values(PackageManager).join(', ');
throw new errors.UserError(
errorCode,
`Invalid ${envName} value "${value}" (expected one of: ${allowed}).`
);
}
export function resolveFallbackPackageManager(env: BuildStepEnv): PackageManager | undefined {
return parsePackageManagerEnvValue(
env.EAS_FALLBACK_PACKAGE_MANAGER,
'EAS_FALLBACK_PACKAGE_MANAGER',
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER'
);
}
export function resolveOverridePackageManager(env: BuildStepEnv): PackageManager | undefined {
return parsePackageManagerEnvValue(
env.EAS_OVERRIDE_PACKAGE_MANAGER,
'EAS_OVERRIDE_PACKAGE_MANAGER',
'EAS_INVALID_OVERRIDE_PACKAGE_MANAGER'
);
}
export function resolveConfiguredPackageManager(
env: BuildStepEnv,
whenUnset: PackageManager
): PackageManager {
return resolveOverridePackageManager(env) ?? resolveFallbackPackageManager(env) ?? whenUnset;
}
export function resolvePackageExec(
manager: PackageManager,
args: string[]
): { command: string; args: string[] } {
switch (manager) {
case PackageManager.NPM:
return { command: 'npx', args: ['--yes', ...args] };
case PackageManager.BUN:
return { command: 'bun', args: ['x', ...args] };
case PackageManager.YARN:
return { command: 'npx', args: ['--yes', ...args] };
case PackageManager.PNPM:
return { command: 'pnpm', args: ['dlx', ...args] };
}
}
export function resolvePackageAdd(
manager: PackageManager,
packageSpec: string
): { command: string; args: string[] } {
switch (manager) {
case PackageManager.NPM:
return { command: 'npm', args: ['install', '--no-audit', packageSpec] };
case PackageManager.BUN:
return { command: 'bun', args: ['add', packageSpec] };
case PackageManager.YARN:
return { command: 'yarn', args: ['add', packageSpec] };
case PackageManager.PNPM:
return { command: 'pnpm', args: ['add', packageSpec] };
}
}
export function resolvePackageInstall(
manager: PackageManager,
{ production }: { production?: boolean } = {}
): { command: string; args: string[] } {
switch (manager) {
case PackageManager.NPM:
return {
command: 'npm',
args: production ? ['install', '--no-audit', '--omit=dev'] : ['install', '--no-audit'],
};
case PackageManager.BUN:
return { command: 'bun', args: production ? ['install', '--production'] : ['install'] };
case PackageManager.YARN:
return { command: 'yarn', args: production ? ['install', '--production'] : ['install'] };
case PackageManager.PNPM:
return { command: 'pnpm', args: production ? ['install', '--prod'] : ['install'] };
}
return PackageManager.YARN;
}
/**
+10 -4
View File
@@ -10,14 +10,20 @@ export function isChildProcessAlive(child: ChildProcess): boolean {
* Kill a detached spawn's process group. Negated pid targets the group so bash/sleep
* children cannot survive after the parent is gone (e.g. across upterm redial).
*/
export function killProcessGroup(child: ChildProcess): void {
export function killProcessGroup(
child: { pid?: number; kill(signal?: NodeJS.Signals): void },
signal: NodeJS.Signals = 'SIGTERM'
): void {
if (child.pid == null) {
return;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill();
process.kill(-child.pid, signal);
} catch (error: any) {
// ESRCH means the process group no longer exists, so there is nothing left to stop.
if (error?.code !== 'ESRCH') {
child.kill(signal);
}
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "24.3.0",
"version": "24.5.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "24.3.0"
"@expo/steps": "24.5.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "24.3.0",
"version": "24.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
+5 -2
View File
@@ -75,8 +75,11 @@ export const SandboxDaemonResponseZ = z.union([
z.strictObject({ jsonrpc: z.literal('2.0'), id: z.string(), result: z.unknown().nonoptional() }),
z.strictObject({
jsonrpc: z.literal('2.0'),
id: z.string(),
error: z.strictObject({ code: z.number().int(), message: z.string() }),
id: z.string().nullable(),
error: z.strictObject({
code: z.number().int(),
message: z.string(),
}),
}),
]);
+133 -133
View File
File diff suppressed because it is too large. Load diff
+579 -25
View File
@@ -6680,6 +6680,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewApiUrl",
"description": "URL of the preview server, for reaching its API rather than its page.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "webPreviewToken",
"description": "Session token gating the web preview. Null when the preview runs ungated.",
@@ -25267,16 +25279,12 @@
"fields": [
{
"name": "previousPeriodTotal",
"description": null,
"description": "Total for the equal-length window before startTime. Null when the window is longer than 45 days, since the previous period would fall outside the 90-day retention.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@@ -25398,23 +25406,19 @@
},
{
"name": "previousPeriodCrashCount",
"description": "Fatal exception events in the equal-length window before startTime.",
"description": "Fatal exception events in the equal-length window before startTime. Null when the window is longer than 45 days.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previousPeriodCrashFreeSessions",
"description": "Same fractions for the equal-length window before startTime; null when it had no activity.",
"description": "Same fractions for the equal-length window before startTime; null when it had no activity, or when the window is longer than 45 days and the previous period falls outside the 90-day retention.",
"args": [],
"type": {
"kind": "SCALAR",
@@ -25438,16 +25442,12 @@
},
{
"name": "previousPeriodCrashedUsers",
"description": null,
"description": "Null when the window is longer than 45 days.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@@ -29533,6 +29533,38 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "firstSeenAt",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastSeenAt",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": null,
@@ -29810,6 +29842,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "FIRST_SEEN",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "LAST_SEEN",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "NAME",
"description": null,
@@ -33407,6 +33451,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewApiUrl",
"description": "URL of the preview server, for reaching its API rather than its page.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "webPreviewToken",
"description": "Session token gating the web preview. Null when the preview runs ungated.",
@@ -36268,6 +36324,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewApiUrl",
"description": "URL of the preview server, for reaching its API rather than its page.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "toolsAuthToken",
"description": null,
@@ -76017,6 +76085,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sandboxes",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SandboxQuery",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "snack",
"description": null,
@@ -77878,6 +77962,232 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Sandbox",
"description": null,
"fields": [
{
"name": "app",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "App",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "finishedAt",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastUsedAt",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startedAt",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "status",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "SandboxStatus",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "turtleJobRun",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "JobRun",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SandboxQuery",
"description": null,
"fields": [
{
"name": "byId",
"description": null,
"args": [
{
"name": "sandboxId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Sandbox",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "SandboxStatus",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "ERRORED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "RUNNING",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "STARTING",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "STOPPED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SecondFactorBooleanResult",
@@ -78654,6 +78964,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewApiUrl",
"description": "URL of the preview server, for reaching its API rather than its page.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewToken",
"description": "Session token gating the preview. Null when the preview runs ungated.",
@@ -81266,6 +81588,189 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SupabaseAdvisorLint",
"description": "An unresolved finding from Supabase's Security or Performance Advisor (database linter).",
"fields": [
{
"name": "cacheKey",
"description": "Stable identifier for this finding on this project.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "description",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "detail",
"description": "Project-specific explanation naming the affected schema object.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "entity",
"description": "Affected schema object, e.g. public.todos, when the lint names one.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "level",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "SupabaseAdvisorLintLevel",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "Lint rule identifier, e.g. rls_disabled_in_public.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "remediation",
"description": "Link to the remediation guide, when Supabase provides one.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "title",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "SupabaseAdvisorLintLevel",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "ERROR",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "INFO",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "WARN",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "SupabaseAdvisorType",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "PERFORMANCE",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "SECURITY",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SupabaseConnection",
@@ -81688,6 +82193,43 @@
"name": "SupabaseProject",
"description": null,
"fields": [
{
"name": "advisorLints",
"description": "Live unresolved lints from the project's Security or Performance Advisor, ordered by severity.\nReadable with view permission; a token refresh triggered by a read is persisted with elevated\nprivileges. Null when Supabase cannot be reached.",
"args": [
{
"name": "type",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "SupabaseAdvisorType",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SupabaseAdvisorLint",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "app",
"description": null,
@@ -93176,6 +93718,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewApiUrl",
"description": "URL of the preview server, for reaching its API rather than its page.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "webPreviewToken",
"description": "Session token gating the web preview. Null when the preview runs ungated.",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "24.4.2",
"version": "24.7.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,8 +48,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-json": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/eas-json": "24.5.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "24.0.0",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.3.0",
"@expo/steps": "24.5.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -11,8 +11,8 @@ import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVers
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { mockTestCommand } from './utils';
import { UserQuery } from '../../graphql/queries/UserQuery';
jest.mock('@expo/config', () => ({
...jest.requireActual('@expo/config'),
@@ -45,8 +45,8 @@ jest.mock('fs-extra', () => ({
writeFile: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../user/actions');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/queries/UserQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../project/uploadAccountScopedFileAsync');
jest.mock('../../project/uploadAccountScopedProjectSourceAsync');
@@ -110,7 +110,9 @@ const mockActor = {
describe('Go command', () => {
beforeEach(() => {
jest.mocked(ensureActorHasPrimaryAccount).mockReturnValue(mockAccount as any);
jest
.mocked(UserQuery.requireCurrentUserPrimaryAccountAsync)
.mockReturnValue(mockAccount as any);
jest.mocked(WorkflowRunQuery.expoGoRepackConfigurationAsync).mockResolvedValue({
files: [],
sdkVersion: '55.0.0',
@@ -8,7 +8,7 @@ import {
mockProjectId,
mockTestCommand,
} from './utils';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/logs/fetchLogs';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/fetchLogs';
import WorkflowLogView from '../../commands/workflow/logs';
import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -35,7 +35,7 @@ jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
jest.mock('../../commandUtils/workflow/logs/fetchLogs');
jest.mock('../../commandUtils/workflow/fetchLogs');
describe(WorkflowLogView, () => {
beforeEach(() => {
@@ -0,0 +1,233 @@
import { BuildPhase } from '@expo/eas-build-job';
import { v4 as uuid } from 'uuid';
import { groupLogLinesIntoSteps } from '../../commandUtils/logs/parseLogs';
import { JobLogs, RawLogLine } from '../../commandUtils/logs/types';
import fetch from '../../fetch';
import {
AppPlatform,
BuildFragment,
BuildPriority,
BuildStatus,
RealtimeLogsTargetType,
} from '../../graphql/generated';
import {
formatActiveBuildText,
formatActiveBuildsText,
isBuildCompleted,
logSourceForBuild,
} from '../logs';
jest.mock('../../fetch');
function createBuildFragment(overrides: Partial<BuildFragment> = {}): BuildFragment {
return {
id: 'build-id',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
platform: AppPlatform.Android,
logFiles: [],
priority: BuildPriority.Normal,
app: {
__typename: 'App',
slug: 'test-project',
id: uuid(),
name: 'test-project',
ownerAccount: {
__typename: 'Account',
id: uuid(),
name: 'test-account',
},
},
status: BuildStatus.InProgress,
isForIosSimulator: false,
...overrides,
};
}
function phaseLines(phase: string, count: number): RawLogLine[] {
return Array.from({ length: count }, (_, index) => ({ phase, msg: `line${index}` }));
}
function logsForPhase(phase: string, count: number): JobLogs {
return groupLogLinesIntoSteps(phaseLines(phase, count));
}
describe(logSourceForBuild, () => {
it('keys the source by the build id', () => {
expect(logSourceForBuild(createBuildFragment({ id: 'some-build-id' })).key).toBe(
'some-build-id'
);
});
it('targets the build itself for realtime logs', () => {
expect(logSourceForBuild(createBuildFragment({ id: 'some-build-id' })).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'some-build-id',
});
});
it('is in progress only while the build is in progress', () => {
expect(
logSourceForBuild(createBuildFragment({ status: BuildStatus.InProgress })).isInProgress
).toBe(true);
for (const status of [
BuildStatus.New,
BuildStatus.InQueue,
BuildStatus.PendingCancel,
BuildStatus.Canceled,
BuildStatus.Errored,
BuildStatus.Finished,
]) {
expect(logSourceForBuild(createBuildFragment({ status })).isInProgress).toBe(false);
}
});
describe('fetchRawLogLinesAsync', () => {
afterEach(() => {
jest.mocked(fetch).mockReset();
});
it('parses the first log file of the build', async () => {
jest.mocked(fetch).mockResolvedValueOnce({
text: async () =>
[
'{"logId":"1","phase":"INSTALL_DEPENDENCIES","msg":"npm ci"}',
'{"logId":"2","phase":"INSTALL_DEPENDENCIES","msg":"done"}',
].join('\n'),
} as any);
const source = logSourceForBuild(
createBuildFragment({ logFiles: ['https://logs.test/first', 'https://logs.test/second'] })
);
await expect(source.fetchRawLogLinesAsync()).resolves.toEqual([
{ logId: '1', phase: 'INSTALL_DEPENDENCIES', msg: 'npm ci' },
{ logId: '2', phase: 'INSTALL_DEPENDENCIES', msg: 'done' },
]);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith('https://logs.test/first', {
timeout: expect.any(Number),
});
});
it('returns null when the build has no log file', async () => {
await expect(
logSourceForBuild(createBuildFragment({ logFiles: [] })).fetchRawLogLinesAsync()
).resolves.toBeNull();
expect(fetch).not.toHaveBeenCalled();
});
});
});
describe(isBuildCompleted, () => {
it('reports FINISHED as completed', () => {
expect(isBuildCompleted(BuildStatus.Finished)).toBe(true);
});
it('reports ERRORED as completed', () => {
expect(isBuildCompleted(BuildStatus.Errored)).toBe(true);
});
it('reports CANCELED as completed', () => {
expect(isBuildCompleted(BuildStatus.Canceled)).toBe(true);
});
it('reports NEW as not completed', () => {
expect(isBuildCompleted(BuildStatus.New)).toBe(false);
});
it('reports IN_QUEUE as not completed', () => {
expect(isBuildCompleted(BuildStatus.InQueue)).toBe(false);
});
it('reports IN_PROGRESS as not completed', () => {
expect(isBuildCompleted(BuildStatus.InProgress)).toBe(false);
});
it('reports PENDING_CANCEL as not completed', () => {
expect(isBuildCompleted(BuildStatus.PendingCancel)).toBe(false);
});
});
describe(formatActiveBuildText, () => {
it('returns the base text alone when there are no logs', () => {
expect(formatActiveBuildText('Build in progress...', new Map())).toBe('Build in progress...');
});
it('shows the display name of a raw build phase', () => {
const output = formatActiveBuildText(
'Build in progress...',
logsForPhase(BuildPhase.INSTALL_DEPENDENCIES, 1)
);
expect(output).toContain('Current phase');
expect(output).toContain('Install dependencies');
expect(output).not.toContain('INSTALL_DEPENDENCIES');
});
it('passes a build step display name through untouched', () => {
const output = formatActiveBuildText(
'Build in progress...',
groupLogLinesIntoSteps([
{ buildStepId: 'step-id-1', buildStepDisplayName: 'Run fastlane build', msg: 'line0' },
])
);
expect(output).toContain('Run fastlane build');
expect(output).not.toContain('step-id-1');
});
it('keeps the base text and five trailing log lines', () => {
const output = formatActiveBuildText(
'Build in progress...',
logsForPhase(BuildPhase.RUN_GRADLEW, 10)
);
expect(output.startsWith('Build in progress...\n')).toBe(true);
expect(output).toContain('line5');
expect(output).toContain('line9');
expect(output).not.toContain('line4');
});
});
describe(formatActiveBuildsText, () => {
it('returns the base text alone when no build has logs', () => {
expect(
formatActiveBuildsText('Waiting for builds to complete.', [
{ build: createBuildFragment(), logs: new Map() },
])
).toBe('Waiting for builds to complete.');
});
it('labels each build with its platform emoji and display name', () => {
const output = formatActiveBuildsText('Waiting for builds to complete.', [
{
build: createBuildFragment({ id: 'android-build', platform: AppPlatform.Android }),
logs: logsForPhase(BuildPhase.RUN_GRADLEW, 1),
},
{
build: createBuildFragment({ id: 'ios-build', platform: AppPlatform.Ios }),
logs: logsForPhase(BuildPhase.RUN_FASTLANE, 1),
},
]);
expect(output).toContain('🤖 Android');
expect(output).toContain('Run gradlew');
expect(output).toContain('🍏 iOS');
expect(output).toContain('Run fastlane');
});
it('keeps three trailing log lines per build', () => {
const output = formatActiveBuildsText('Waiting for builds to complete.', [
{
build: createBuildFragment({ id: 'android-build', platform: AppPlatform.Android }),
logs: logsForPhase(BuildPhase.RUN_GRADLEW, 10),
},
]);
expect(output).toContain('line7');
expect(output).toContain('line9');
expect(output).not.toContain('line6');
});
});
@@ -0,0 +1,163 @@
import { BuildPhase } from '@expo/eas-build-job';
import { v4 as uuid } from 'uuid';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { LogsState } from '../../commandUtils/logs/state';
import { groupLogLinesIntoSteps } from '../../commandUtils/logs/parseLogs';
import { JobLogs } from '../../commandUtils/logs/types';
import { LogsWatcher } from '../../commandUtils/logs/watcher';
import {
AppPlatform,
BuildFragment,
BuildPriority,
BuildStatus,
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { Ora, isSpinnerEnabled, ora } from '../../ora';
import { createRealtimeLogsClient } from '../../utils/centrifuge';
import { sleepAsync } from '../../utils/promise';
import { waitForBuildEndAsync } from '../build';
jest.mock('../../ora', () => ({
...jest.requireActual('../../ora'),
ora: jest.fn(),
isSpinnerEnabled: jest.fn(),
}));
jest.mock('../../commandUtils/logs/watcher', () => ({
LogsWatcher: jest.fn(),
}));
jest.mock('../../utils/centrifuge', () => ({
createRealtimeLogsClient: jest.fn(),
}));
jest.mock('../../graphql/queries/BuildQuery', () => ({
BuildQuery: {
byIdAsync: jest.fn(),
},
}));
jest.mock('../../utils/promise', () => ({
...jest.requireActual('../../utils/promise'),
sleepAsync: jest.fn(),
}));
const graphqlClient = {} as unknown as ExpoGraphqlClient;
function createBuildFragment(status: BuildStatus): BuildFragment {
return {
id: 'build-id',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
platform: AppPlatform.Android,
logFiles: [],
priority: BuildPriority.Normal,
app: {
__typename: 'App',
slug: 'test-project',
id: uuid(),
name: 'test-project',
ownerAccount: {
__typename: 'Account',
id: uuid(),
name: 'test-account',
},
},
status,
isForIosSimulator: false,
};
}
function createSpinner(): Ora {
const spinner: Record<string, unknown> = {
text: '',
prefixText: '',
isSpinning: true,
start: jest.fn(() => spinner),
stop: jest.fn(() => spinner),
stopAndPersist: jest.fn(() => spinner),
succeed: jest.fn(() => spinner),
fail: jest.fn(() => spinner),
warn: jest.fn(() => spinner),
};
return spinner as unknown as Ora;
}
function setBuildStatuses(statuses: BuildStatus[]): void {
const mocked = jest.mocked(BuildQuery.byIdAsync);
for (const status of statuses) {
mocked.mockResolvedValueOnce(createBuildFragment(status));
}
}
describe(waitForBuildEndAsync, () => {
let spinner: Ora;
let publish: () => void;
let watcherClose: jest.Mock;
let logs: JobLogs;
beforeEach(() => {
jest.clearAllMocks();
spinner = createSpinner();
logs = new Map();
publish = () => {};
watcherClose = jest.fn();
jest.mocked(ora).mockReturnValue(spinner);
jest.mocked(LogsWatcher).mockImplementation(((
_createRealtimeLogsClient: unknown,
onRealtimeLogs: () => void
) => {
publish = onRealtimeLogs;
return {
syncAsync: jest.fn(async () =>
new Map<string, LogsState>([
['build-id', { getLogs: () => logs, markCompleted: jest.fn() } as unknown as LogsState],
])
),
close: watcherClose,
};
}) as unknown as () => LogsWatcher);
jest.mocked(sleepAsync).mockImplementation(async () => {
publish();
});
});
it('does not create a watcher when the spinner is disabled', async () => {
jest.mocked(isSpinnerEnabled).mockReturnValue(false);
setBuildStatuses([BuildStatus.Finished]);
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
expect(LogsWatcher).not.toHaveBeenCalled();
expect(createRealtimeLogsClient).not.toHaveBeenCalled();
});
it('renders the current phase and its log tail on publication while in progress', async () => {
jest.mocked(isSpinnerEnabled).mockReturnValue(true);
setBuildStatuses([BuildStatus.InProgress, BuildStatus.Finished]);
jest.mocked(sleepAsync).mockImplementationOnce(async () => {
logs = groupLogLinesIntoSteps([
{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'npm ci' },
{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'added 1 package' },
]);
publish();
});
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
expect(LogsWatcher).toHaveBeenCalledTimes(1);
expect(spinner.text).toContain('Build in progress...');
expect(spinner.text).toContain('Install dependencies');
expect(spinner.text).toContain('added 1 package');
expect(watcherClose).toHaveBeenCalledTimes(1);
});
it('ignores a publication that arrives while the build is queued', async () => {
jest.mocked(isSpinnerEnabled).mockReturnValue(true);
setBuildStatuses([BuildStatus.InProgress, BuildStatus.InQueue, BuildStatus.Finished]);
logs = groupLogLinesIntoSteps([{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'npm ci' }]);
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
expect(jest.mocked(sleepAsync).mock.calls.length).toBe(2);
expect(spinner.text).toBe('Build queued...');
});
});
+114 -21
View File
@@ -30,6 +30,12 @@ import {
} from './errors';
import { transformMetadata } from './graphql';
import { LocalBuildMode, runLocalBuildAsync } from './local';
import {
formatActiveBuildText,
formatActiveBuildsText,
isBuildCompleted,
logSourceForBuild,
} from './logs';
import { collectMetadataAsync } from './metadata';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
@@ -46,6 +52,8 @@ import { getExpoWebsiteBaseUrl } from '../api';
import { formatStarterSubscribeCommand } from '../billing/plans';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import { LogsState } from '../commandUtils/logs/state';
import { LogsWatcher } from '../commandUtils/logs/watcher';
import { createFingerprintAsync } from '../fingerprint/cli';
import {
AppPlatform,
@@ -60,7 +68,7 @@ import {
import { BuildMutation, BuildResult } from '../graphql/mutations/BuildMutation';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log, { learnMore, link } from '../log';
import { Ora, ora } from '../ora';
import { Ora, isSpinnerEnabled, ora, updateSpinnerText } from '../ora';
import {
RequestedPlatform,
appPlatformDisplayNames,
@@ -70,6 +78,7 @@ import {
import { maybeUploadFingerprintAsync } from '../project/maybeUploadFingerprintAsync';
import { resolveRuntimeVersionAsync } from '../project/resolveRuntimeVersionAsync';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { createRealtimeLogsClient } from '../utils/centrifuge';
import { formatBytes } from '../utils/files';
import { printJsonOnlyOutput } from '../utils/json';
import { createProgressTracker } from '../utils/progress';
@@ -538,19 +547,64 @@ export async function waitForBuildEndAsync(
originalSpinnerText = 'Waiting for builds to complete. You can press Ctrl+C to exit.';
spinner = ora('Waiting for builds to complete. You can press Ctrl+C to exit.').start();
}
while (true) {
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
const { refetch } =
builds.length === 1
? await handleSingleBuildProgressAsync({ build: builds[0], accountName }, { spinner })
: await handleMultipleBuildsProgressAsync({ builds }, { spinner, originalSpinnerText });
if (!refetch) {
return builds;
let render = (): void => {};
const watcher = isSpinnerEnabled()
? new LogsWatcher(
() => createRealtimeLogsClient(graphqlClient),
() => {
render();
}
)
: null;
try {
while (true) {
render = (): void => {};
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
const logsStates = await syncBuildLogsAsync(watcher, builds);
const installRender = (nextRender: () => void): void => {
render = nextRender;
};
const { refetch } =
builds.length === 1
? await handleSingleBuildProgressAsync(
{ build: builds[0], accountName },
{ spinner, logsStates, installRender }
)
: await handleMultipleBuildsProgressAsync(
{ builds },
{ spinner, originalSpinnerText, logsStates, installRender }
);
if (!refetch) {
return builds;
}
await sleepAsync(intervalSec * 1000);
}
await sleepAsync(intervalSec * 1000);
} finally {
watcher?.close();
}
}
async function syncBuildLogsAsync(
watcher: LogsWatcher | null,
builds: MaybeBuildFragment[]
): Promise<Map<string, LogsState>> {
if (!watcher) {
return new Map();
}
const existingBuilds = builds.filter(isBuildFragment);
const logsStates = await watcher.syncAsync(existingBuilds.map(build => logSourceForBuild(build)));
for (const build of existingBuilds) {
if (isBuildCompleted(build.status)) {
nullthrows(
logsStates.get(build.id),
'syncAsync must have been called before markCompleted'
).markCompleted();
}
}
return logsStates;
}
async function getBuildsSafelyAsync(
graphqlClient: ExpoGraphqlClient,
buildIds: string[]
@@ -570,6 +624,11 @@ interface BuildProgressResult {
refetch: boolean;
}
type BuildLogsProgressOptions = {
logsStates: Map<string, LogsState>;
installRender: (render: () => void) => void;
};
let queueProgressBarStarted = false;
const queueProgressBar = new cliProgress.SingleBar(
{ format: '|{bar}| {estimatedWaitTime}' },
@@ -587,10 +646,12 @@ async function handleSingleBuildProgressAsync(
build: MaybeBuildFragment;
accountName: string;
},
{ spinner }: { spinner: Ora }
{ spinner, logsStates, installRender }: { spinner: Ora } & BuildLogsProgressOptions
): Promise<BuildProgressResult> {
if (build === null) {
spinner.text = 'Could not fetch the build status. Check your network connection.';
updateSpinnerText(spinner, {
text: 'Could not fetch the build status. Check your network connection.',
});
return { refetch: true };
}
@@ -615,16 +676,18 @@ async function handleSingleBuildProgressAsync(
statusNewSetAt ??= now;
const newStatusDurationMs = now - statusNewSetAt;
if (newStatusDurationMs < NEW_STATUS_GRACE_PERIOD_MS) {
spinner.text = 'Waiting for build to get enqueued…';
updateSpinnerText(spinner, { text: 'Waiting for build to get enqueued…' });
} else {
spinner.text = `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
formatAccountBillingUrl(accountName)
)}.`;
updateSpinnerText(spinner, {
text: `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
formatAccountBillingUrl(accountName)
)}.`,
});
}
break;
}
case BuildStatus.InQueue: {
spinner.text = 'Build queued...';
updateSpinnerText(spinner, { text: 'Build queued...' });
const progressBarPayload =
typeof build.estimatedWaitTimeLeftSeconds === 'number'
? { estimatedWaitTime: formatEstimatedWaitTime(build.estimatedWaitTimeLeftSeconds) }
@@ -661,9 +724,17 @@ async function handleSingleBuildProgressAsync(
case BuildStatus.Canceled:
spinner.fail('Build canceled');
return { refetch: false };
case BuildStatus.InProgress:
spinner.text = 'Build in progress...';
case BuildStatus.InProgress: {
const logsState = logsStates.get(build.id);
const render = (): void => {
updateSpinnerText(spinner, {
text: formatActiveBuildText('Build in progress...', logsState?.getLogs() ?? new Map()),
});
};
installRender(render);
render();
break;
}
case BuildStatus.Errored:
spinner.fail('Build failed');
if (build.error) {
@@ -698,7 +769,12 @@ const platforms = [AppPlatform.Android, AppPlatform.Ios];
async function handleMultipleBuildsProgressAsync(
{ builds: maybeBuilds }: { builds: MaybeBuildFragment[] },
{ spinner, originalSpinnerText }: { spinner: Ora; originalSpinnerText: string }
{
spinner,
originalSpinnerText,
logsStates,
installRender,
}: { spinner: Ora; originalSpinnerText: string } & BuildLogsProgressOptions
): Promise<BuildProgressResult> {
const buildCount = maybeBuilds.length;
const builds = maybeBuilds.filter<BuildFragment>(isBuildFragment);
@@ -732,7 +808,24 @@ async function handleMultipleBuildsProgressAsync(
someNew &&
statusNewSetAt !== null &&
Date.now() - statusNewSetAt >= NEW_STATUS_GRACE_PERIOD_MS;
spinner.text = formatPendingBuildsText(originalSpinnerText, builds, showConcurrencyWarning);
const pendingBuildsText = formatPendingBuildsText(
originalSpinnerText,
builds,
showConcurrencyWarning
);
const inProgressBuilds = builds.filter(build => build.status === BuildStatus.InProgress);
const render = (): void => {
const text = formatActiveBuildsText(
pendingBuildsText,
inProgressBuilds.map(build => ({
build,
logs: logsStates.get(build.id)?.getLogs() ?? new Map(),
}))
);
updateSpinnerText(spinner, { text });
};
installRender(render);
render();
return { refetch: true };
}
}
+113
View File
@@ -0,0 +1,113 @@
import { BuildPhase, buildPhaseDisplayName } from '@expo/eas-build-job';
import { choicesFromJobLogs, stepLogTail } from '../commandUtils/logs/format';
import { parseLogLines } from '../commandUtils/logs/parseLogs';
import { JobLogs, RawLogLine } from '../commandUtils/logs/types';
import { LogSource } from '../commandUtils/logs/watcher';
import fetch from '../fetch';
import { BuildFragment, BuildStatus, RealtimeLogsTargetType } from '../graphql/generated';
import Log from '../log';
import { appPlatformDisplayNames, appPlatformEmojis } from '../platform';
import formatFields, { FormatFieldsItem } from '../utils/formatFields';
const LOGS_FETCH_TIMEOUT_MS = 5_000;
const SINGLE_BUILD_MAX_LOG_LINES = 5;
const MULTIPLE_BUILDS_MAX_LOG_LINES = 3;
async function fetchAndParseLogsForBuildAsync(build: BuildFragment): Promise<RawLogLine[] | null> {
const logFileUrl = build.logFiles[0];
if (!logFileUrl) {
return null;
}
const response = await fetch(logFileUrl, { timeout: LOGS_FETCH_TIMEOUT_MS });
const rawLogs = await response.text();
if (!rawLogs) {
return null;
}
const { logLines, errors } = parseLogLines(rawLogs);
for (const error of errors) {
Log.debug(`Failed to parse a log line: ${error.message}`);
}
return logLines;
}
export function logSourceForBuild(build: BuildFragment): LogSource {
return {
key: build.id,
realtimeTarget: { type: RealtimeLogsTargetType.Build, id: build.id },
isInProgress: build.status === BuildStatus.InProgress,
fetchRawLogLinesAsync: async () => await fetchAndParseLogsForBuildAsync(build),
};
}
export function isBuildCompleted(status: BuildStatus): boolean {
switch (status) {
case BuildStatus.Finished:
case BuildStatus.Errored:
case BuildStatus.Canceled:
return true;
case BuildStatus.New:
case BuildStatus.InQueue:
case BuildStatus.InProgress:
case BuildStatus.PendingCancel:
return false;
}
}
function stepDisplayName(label: string): string {
return Object.hasOwn(buildPhaseDisplayName, label)
? buildPhaseDisplayName[label as BuildPhase]
: label;
}
function activeBuildFields(
logs: JobLogs,
maxLogLines: number,
buildLabel: string | null
): FormatFieldsItem[] {
const steps = choicesFromJobLogs(logs);
if (steps.length === 0) {
return [];
}
const currentStep = steps[steps.length - 1];
const fields: FormatFieldsItem[] = [{ label: '', value: '' }];
if (buildLabel !== null) {
fields.push({ label: ' Build', value: buildLabel });
}
fields.push({ label: ' Current phase', value: stepDisplayName(currentStep.name) });
if (currentStep.logLines?.length) {
fields.push({ label: ' Current logs', value: '' });
for (const logLine of stepLogTail(currentStep, maxLogLines)) {
fields.push({ label: '', value: logLine });
}
}
return fields;
}
function appendFields(baseText: string, fields: FormatFieldsItem[]): string {
if (fields.length === 0) {
return baseText;
}
return `${baseText}\n${formatFields(fields)}`;
}
export function formatActiveBuildText(baseText: string, logs: JobLogs): string {
return appendFields(baseText, activeBuildFields(logs, SINGLE_BUILD_MAX_LOG_LINES, null));
}
export function formatActiveBuildsText(
baseText: string,
buildsWithLogs: { build: BuildFragment; logs: JobLogs }[]
): string {
return appendFields(
baseText,
buildsWithLogs.flatMap(({ build, logs }) =>
activeBuildFields(
logs,
MULTIPLE_BUILDS_MAX_LOG_LINES,
`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]}`
)
)
);
}
@@ -45,11 +45,6 @@ describe(getProjectIdAsync, () => {
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_1',
@@ -420,11 +415,6 @@ describe(getProjectIdAsync, () => {
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_1',
@@ -524,11 +514,6 @@ describe(getProjectIdAsync, () => {
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
@@ -1,7 +1,7 @@
import { groupLogLinesIntoSteps, mergeLogLines, parseLogLines } from '../parseLogs';
import { WorkflowRawLogLine } from '../../types';
import { RawLogLine } from '../types';
function logLine(overrides: Partial<WorkflowRawLogLine> = {}): WorkflowRawLogLine {
function logLine(overrides: Partial<RawLogLine> = {}): RawLogLine {
return { msg: 'a message', ...overrides };
}
@@ -0,0 +1,110 @@
import { LogsState } from '../state';
import { RawLogLine } from '../types';
function fileLine(logId: string, msg: string, buildStepId = 'install'): RawLogLine {
return { logId, buildStepId, msg };
}
function stepMessages(logsState: LogsState): string[] {
return Array.from(logsState.getLogs().values()).flatMap(group =>
group.logLines.map(logLine => logLine.msg)
);
}
describe(LogsState, () => {
it('hides realtime lines until the file logs reach them', () => {
const logsState = new LogsState();
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 LogsState();
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 LogsState();
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 LogsState();
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 LogsState();
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 LogsState();
const keyless: RawLogLine = { 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 LogsState();
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 LogsState();
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 LogsState();
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 LogsState();
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,153 @@
import { RealtimeLogsTargetType } from '../../../graphql/generated';
import { RealtimeLogsClient } from '../../../utils/centrifuge';
import { LogSource, LogsWatcher } from '../watcher';
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++,
},
};
}
describe(LogsWatcher, () => {
let fetchRawLogLinesAsync: jest.Mock;
beforeEach(() => {
fetchRawLogLinesAsync = jest
.fn()
.mockResolvedValue([{ logId: '1', buildStepId: 'install', msg: 'from the file' }]);
});
function inProgressSource(overrides: Partial<LogSource> = {}): LogSource {
return {
key: 'job1',
realtimeTarget: { type: RealtimeLogsTargetType.JobRun, id: 'job-run-id' },
isInProgress: true,
fetchRawLogLinesAsync,
...overrides,
};
}
it('subscribes once per in-progress source', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
await watcher.syncAsync([inProgressSource()]);
expect(fake.subscribeCalls).toEqual([
{ target: { type: RealtimeLogsTargetType.JobRun, id: 'job-run-id' } },
]);
});
it('reports realtime logs as they arrive', async () => {
const fake = createFakeClient();
const onRealtimeLogs = jest.fn();
const watcher = new LogsWatcher(() => fake.client, onRealtimeLogs);
await watcher.syncAsync([inProgressSource()]);
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 LogsWatcher(() => fake.client, onRealtimeLogs);
await watcher.syncAsync([inProgressSource()]);
fake.publish([{ msg: 'no logId' }]);
expect(onRealtimeLogs).not.toHaveBeenCalled();
});
it('still fetches logs when the realtime logs client is unavailable', async () => {
const watcher = new LogsWatcher(
() => null,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(1);
});
it('fetches logs on every sync while in progress, and stops once the source is not', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
await watcher.syncAsync([inProgressSource()]);
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(2);
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(2);
});
it('does not fetch logs for a source that is not in progress', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
expect(fetchRawLogLinesAsync).not.toHaveBeenCalled();
});
it('closes the subscription when a source leaves in-progress', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
expect(fake.closeCount()).toBe(0);
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
expect(fake.closeCount()).toBe(1);
});
it('closes the client and its subscriptions', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
watcher.close();
expect(fake.closeCount()).toBe(2);
});
});
@@ -0,0 +1,25 @@
import { JobLogs, LogLine } from './types';
import { Choice } from '../../prompts';
export function choicesFromJobLogs(
logs: JobLogs
): (Choice & { name: string; status: string; logLines: LogLine[] | undefined })[] {
return Array.from(logs.values())
.map(({ key, label, result, logLines }) => ({
title: `${label} - ${result ?? ''}`,
name: label,
status: result ?? '',
value: key,
logLines,
}))
.filter(step => step.status !== 'skipped');
}
export function stepLogTail(
step: { logLines?: LogLine[] },
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);
}
@@ -0,0 +1,56 @@
import { JobLogs, RawLogLine } from './types';
import uniqBy from '../../utils/expodash/uniqBy';
export function parseLogLines(rawLogs: string): {
logLines: RawLogLine[];
errors: Error[];
} {
const logLines: RawLogLine[] = [];
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 RawLogLine>(...logLineGroups: T[][]): T[] {
return uniqBy(logLineGroups.flat().reverse(), logLine => logLine.logId ?? Symbol()).reverse();
}
export function groupLogLinesIntoSteps(
logLines: RawLogLine[],
accumulator: JobLogs = new Map()
): JobLogs {
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;
}
@@ -0,0 +1,94 @@
import { groupLogLinesIntoSteps, mergeLogLines } from './parseLogs';
import { JobLogs, RawLogLine } from './types';
type RealtimeLogLine = RawLogLine & { logId: string };
function isRealtimeLogLine(entry: unknown): entry is RealtimeLogLine {
return (
typeof entry === 'object' &&
entry !== null &&
typeof (entry as Partial<RawLogLine>).logId === 'string'
);
}
export class LogsState {
private fileLogIds = new Set<string>();
private realtimeLogLines: RealtimeLogLine[] = [];
private realtimeLogIds = new Set<string>();
private haveFileLogsCaughtUp = false;
private realtimeLogsRevealed = false;
private jobLogs: JobLogs = new Map();
public ingestFileLogLines(logLines: RawLogLine[]): 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.jobLogs = 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.jobLogs);
}
return true;
}
public markCompleted(): void {
this.revealRealtimeLogs();
}
public getLogs(): JobLogs {
return this.jobLogs;
}
private revealRealtimeLogs(): void {
if (this.realtimeLogsRevealed) {
return;
}
this.realtimeLogsRevealed = true;
groupLogLinesIntoSteps(this.realtimeLogLines, this.jobLogs);
}
}
@@ -0,0 +1,23 @@
export type RawLogLine = {
logId?: string;
buildStepId?: string;
buildStepDisplayName?: string;
phase?: string;
time?: string;
msg?: string;
result?: string;
marker?: string;
err?: any;
};
export type LogLine = Pick<RawLogLine, 'time' | 'result' | 'marker' | 'err'> &
Required<Pick<RawLogLine, 'msg'>>;
export type StepLogs = {
key: string;
label: string;
result?: string;
logLines: LogLine[];
};
export type JobLogs = Map<string, StepLogs>;
@@ -0,0 +1,102 @@
import { LogsState } from './state';
import { RawLogLine } from './types';
import { RealtimeLogsTargetInput } from '../../graphql/generated';
import Log from '../../log';
import { RealtimeLogsClient, RealtimeLogsSubscription } from '../../utils/centrifuge';
import nullthrows from 'nullthrows';
export type LogSource = {
key: string;
realtimeTarget: RealtimeLogsTargetInput | null;
isInProgress: boolean;
fetchRawLogLinesAsync: () => Promise<RawLogLine[] | null>;
};
type TrackedSource = {
logsState: LogsState;
subscription: RealtimeLogsSubscription | null;
};
export class LogsWatcher {
private readonly trackedSources = new Map<string, TrackedSource>();
private realtimeLogsClient?: RealtimeLogsClient | null;
constructor(
private readonly createRealtimeLogsClient: () => RealtimeLogsClient | null,
private readonly onRealtimeLogs: () => void
) {}
public async syncAsync(sources: LogSource[]): Promise<Map<string, LogsState>> {
await Promise.all(sources.map(source => this.syncSourceAsync(source)));
return new Map(
sources.map(source => [
source.key,
nullthrows(
this.trackedSources.get(source.key),
'source has to be in tracked sources after it was synced'
).logsState,
])
);
}
public close(): void {
for (const trackedSource of this.trackedSources.values()) {
trackedSource.subscription?.close();
trackedSource.subscription = null;
}
this.realtimeLogsClient?.close();
}
private async syncSourceAsync(source: LogSource): Promise<void> {
let trackedSource = this.trackedSources.get(source.key);
if (!trackedSource) {
trackedSource = { logsState: new LogsState(), subscription: null };
this.trackedSources.set(source.key, trackedSource);
}
if (!source.isInProgress) {
trackedSource.subscription?.close();
trackedSource.subscription = null;
return;
}
await Promise.all([
trackedSource.subscription ? Promise.resolve() : this.subscribeAsync(source, trackedSource),
this.fetchLogsAsync(source, trackedSource),
]);
}
private async fetchLogsAsync(source: LogSource, trackedSource: TrackedSource): Promise<void> {
try {
const logLines = await source.fetchRawLogLinesAsync();
if (logLines) {
trackedSource.logsState.ingestFileLogLines(logLines);
}
} catch (err: any) {
Log.debug(`Failed to fetch logs for job ${source.key}: ${err.message}`);
}
}
private getRealtimeLogsClient(): RealtimeLogsClient | null {
if (this.realtimeLogsClient === undefined) {
this.realtimeLogsClient = this.createRealtimeLogsClient();
}
return this.realtimeLogsClient;
}
private async subscribeAsync(source: LogSource, trackedSource: TrackedSource): Promise<void> {
const target = source.realtimeTarget;
if (!target) {
return;
}
const realtimeLogsClient = this.getRealtimeLogsClient();
if (!realtimeLogsClient) {
return;
}
trackedSource.subscription = await realtimeLogsClient.subscribeAsync({ target }, data => {
if (trackedSource.logsState.ingestRealtimeLogLines(data)) {
this.onRealtimeLogs();
}
});
}
}
@@ -1,9 +1,9 @@
import { getMockWorkflowCustomJobFragment } from '../../../../__tests__/commands/utils';
import { BuildQuery } from '../../../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../../../context/contextUtils/createGraphqlClient';
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');
jest.mock('../../../graphql/queries/BuildQuery');
describe(fetchRawLogsForJobAsync, () => {
afterEach(() => {
@@ -0,0 +1,108 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
import {
RealtimeLogsTargetType,
WorkflowJobStatus,
WorkflowJobType,
} from '../../../graphql/generated';
import { fetchRawLogsForJobAsync } from '../fetchLogs';
import { logSourceForWorkflowJob } from '../logs';
import { WorkflowJobResult } from '../types';
jest.mock('../fetchLogs');
function inProgressJob(): WorkflowJobResult {
const job = getMockWorkflowRunWithJobsFragment().jobs[0];
return { ...job, status: WorkflowJobStatus.InProgress };
}
describe(logSourceForWorkflowJob, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('keys the source by the job id', () => {
expect(logSourceForWorkflowJob({} as any, inProgressJob()).key).toBe('job1');
});
it('is in progress only while the job is in progress', () => {
const job = inProgressJob();
expect(logSourceForWorkflowJob({} as any, job).isInProgress).toBe(true);
for (const status of [
WorkflowJobStatus.New,
WorkflowJobStatus.ActionRequired,
WorkflowJobStatus.PendingCancel,
WorkflowJobStatus.Success,
WorkflowJobStatus.Failure,
WorkflowJobStatus.Canceled,
WorkflowJobStatus.Skipped,
]) {
expect(logSourceForWorkflowJob({} as any, { ...job, status }).isInProgress).toBe(false);
}
});
it('targets the turtle job run when there is one', () => {
const job = { ...inProgressJob(), turtleJobRun: { id: 'job-run-id' } } as WorkflowJobResult;
expect(logSourceForWorkflowJob({} as any, job).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.JobRun,
id: 'job-run-id',
});
});
it('falls back to the build when there is no turtle job run', () => {
const withTurtleBuild = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: { id: 'build-id' },
} as WorkflowJobResult;
const withBuildOutput = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: null,
outputs: { build_id: 'output-build-id' },
} as WorkflowJobResult;
expect(logSourceForWorkflowJob({} as any, withTurtleBuild).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'build-id',
});
expect(logSourceForWorkflowJob({} as any, withBuildOutput).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'output-build-id',
});
});
it('has no realtime target when neither is present', () => {
const job = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: null,
outputs: null,
} as WorkflowJobResult;
expect(logSourceForWorkflowJob({} as any, job).realtimeTarget).toBeNull();
});
it('fetches and parses the job logs with the graphql client it was given', async () => {
const graphqlClient = {} as any;
const job = { ...inProgressJob(), type: WorkflowJobType.Build } as WorkflowJobResult;
jest
.mocked(fetchRawLogsForJobAsync)
.mockResolvedValue(
[
'{"logId":"1","buildStepId":"install","msg":"npm ci"}',
'{"logId":"2","buildStepId":"install","msg":"done"}',
].join('\n')
);
await expect(
logSourceForWorkflowJob(graphqlClient, job).fetchRawLogLinesAsync()
).resolves.toEqual([
{ logId: '1', buildStepId: 'install', msg: 'npm ci' },
{ logId: '2', buildStepId: 'install', msg: 'done' },
]);
expect(fetchRawLogsForJobAsync).toHaveBeenCalledWith({ graphqlClient }, job);
});
});
@@ -1,15 +1,15 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
import { WorkflowJobStatus } from '../../../graphql/generated';
import { groupLogLinesIntoSteps, parseLogLines } from '../logs/parseLogs';
import { WorkflowLogs, WorkflowRawLogLine } from '../types';
import { groupLogLinesIntoSteps, parseLogLines } from '../../logs/parseLogs';
import { JobLogs, RawLogLine } from '../../logs/types';
import { formatActiveWorkflowRun, formatFailedWorkflowRun } from '../utils';
function jobWithLogs(
logLines: WorkflowRawLogLine[],
logLines: RawLogLine[],
status: WorkflowJobStatus = WorkflowJobStatus.InProgress
): {
job: ReturnType<typeof getMockWorkflowRunWithJobsFragment>['jobs'][number];
logs: WorkflowLogs;
logs: JobLogs;
} {
return {
job: { ...getMockWorkflowRunWithJobsFragment().jobs[0], status },
@@ -17,7 +17,7 @@ function jobWithLogs(
};
}
function stepLines(buildStepId: string, count: number): WorkflowRawLogLine[] {
function stepLines(buildStepId: string, count: number): RawLogLine[] {
return Array.from({ length: count }, (_, index) => ({ buildStepId, msg: `line${index}` }));
}
@@ -1,6 +1,6 @@
import { WorkflowJobResult } from '../types';
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
import { WorkflowJobResult } from './types';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
export async function fetchRawLogsForJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
@@ -0,0 +1,59 @@
import { fetchRawLogsForJobAsync } from './fetchLogs';
import { WorkflowJobResult } from './types';
import {
RealtimeLogsTargetInput,
RealtimeLogsTargetType,
WorkflowJobStatus,
} from '../../graphql/generated';
import Log from '../../log';
import { groupLogLinesIntoSteps, parseLogLines } from '../logs/parseLogs';
import { JobLogs, RawLogLine } from '../logs/types';
import { LogSource } from '../logs/watcher';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
async function fetchAndParseLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<RawLogLine[] | 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<JobLogs | null> {
const logLines = await fetchAndParseLogsFromJobAsync(state, job);
return logLines && groupLogLinesIntoSteps(logLines);
}
export function logSourceForWorkflowJob(
graphqlClient: ExpoGraphqlClient,
job: WorkflowJobResult
): LogSource {
return {
key: job.id,
realtimeTarget: realtimeLogsTargetForJob(job),
isInProgress: job.status === WorkflowJobStatus.InProgress,
fetchRawLogLinesAsync: async () => await fetchAndParseLogsFromJobAsync({ graphqlClient }, job),
};
}
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;
}
@@ -1,308 +0,0 @@
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']);
});
});
@@ -1,83 +0,0 @@
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);
}
@@ -1,210 +0,0 @@
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,13 +1,10 @@
import { Choice } from 'prompts';
import { WorkflowJobResult, WorkflowLogs } from './types';
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
import {
choiceFromWorkflowJob,
choiceFromWorkflowRun,
choicesFromWorkflowLogs,
processWorkflowRuns,
} from './utils';
import { WorkflowJobResult } from './types';
import { fetchAndProcessLogsFromJobAsync } from './logs';
import { choicesFromJobLogs } from '../logs/format';
import { JobLogs } from '../logs/types';
import { choiceFromWorkflowJob, choiceFromWorkflowRun, processWorkflowRuns } from './utils';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
@@ -58,7 +55,7 @@ export type WorkflowCommandSelectionState = {
jobId?: string;
step?: string;
job?: WorkflowJobResult;
logs?: WorkflowLogs | null;
logs?: JobLogs | null;
message?: string;
};
@@ -140,7 +137,7 @@ export function moveToWorkflowSelectionFinishedState(
previousState: WorkflowCommandSelectionState,
params: {
step: string;
logs: WorkflowLogs;
logs: JobLogs;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
@@ -276,7 +273,7 @@ export const workflowStepSelectionAction: WorkflowCommandSelectionAction = async
return moveToWorkflowSelectionFinishedState(prevState, { step: '', logs });
}
const choices: Choice[] = [
...choicesFromWorkflowLogs(logs),
...choicesFromJobLogs(logs),
{
title: 'Go back and select a different workflow job',
value: 'go-back',
@@ -41,27 +41,3 @@ export type WorkflowRunWithJobsResult = WorkflowRunResult & {
jobs: WorkflowJobResult[];
logs?: string;
};
export type WorkflowRawLogLine = {
logId?: string;
buildStepId?: string;
buildStepDisplayName?: string;
phase?: string;
time?: string;
msg?: string;
result?: string;
marker?: string;
err?: any;
};
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,14 +1,8 @@
import chalk from 'chalk';
import * as fs from 'node:fs';
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
import {
WorkflowJobResult,
WorkflowLogLine,
WorkflowLogs,
WorkflowRunResult,
WorkflowTriggerType,
} from './types';
import { fetchAndProcessLogsFromJobAsync, logSourceForWorkflowJob } from './logs';
import { WorkflowJobResult, WorkflowRunResult, WorkflowTriggerType } from './types';
import {
WorkflowJobStatus,
WorkflowRunByIdQuery,
@@ -17,7 +11,9 @@ import {
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import { WorkflowRunLogsWatcher } from './logs/watcher';
import { choicesFromJobLogs, stepLogTail } from '../logs/format';
import { JobLogs } from '../logs/types';
import { LogsWatcher } from '../logs/watcher';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { ora, updateSpinnerText } from '../../ora';
@@ -83,20 +79,6 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
};
}
export function choicesFromWorkflowLogs(
logs: WorkflowLogs
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
return Array.from(logs.values())
.map(({ key, label, result, logLines }) => ({
title: `${label} - ${result ?? ''}`,
name: label,
status: result ?? '',
value: key,
logLines,
}))
.filter(step => step.status !== 'skipped');
}
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
return runs.map(run => {
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
@@ -157,18 +139,9 @@ type WorkflowRunWithJobs = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'];
type JobWithLogs = {
job: WorkflowRunWithJobs['jobs'][number];
logs: WorkflowLogs;
logs: JobLogs;
};
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: WorkflowRunWithJobs
@@ -195,7 +168,7 @@ export function formatActiveWorkflowRun(
if (job.status !== WorkflowJobStatus.InProgress) {
continue;
}
const steps = choicesFromWorkflowLogs(logs);
const steps = choicesFromJobLogs(logs);
if (steps.length === 0) {
continue;
}
@@ -222,7 +195,7 @@ export function formatFailedWorkflowRun(
for (const { job, logs } of jobLogs) {
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Failed job', value: job.name });
const steps = choicesFromWorkflowLogs(logs);
const steps = choicesFromJobLogs(logs);
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
if (!failedStep) {
continue;
@@ -290,8 +263,7 @@ export async function showWorkflowStatusAsync(
prefixText: chalk`{bold.yellow Workflow run is waiting to start:}`,
});
const watcher = new WorkflowRunLogsWatcher(
graphqlClient,
const watcher = new LogsWatcher(
() => createRealtimeLogsClient(graphqlClient),
() => {
renderActiveWorkflowRun();
@@ -316,12 +288,14 @@ export async function showWorkflowStatusAsync(
updateSpinnerText(spinner, {
prefixText: chalk`{bold.green Workflow run is in progress:}`,
});
const logsStates = await watcher.syncJobsAsync(workflowRun.jobs);
const logsStates = await watcher.syncAsync(
workflowRun.jobs.map(job => logSourceForWorkflowJob(graphqlClient, job))
);
for (const job of workflowRun.jobs) {
if (isJobCompleted(job.status)) {
nullthrows(
logsStates.get(job.id),
'syncJobsAsync must have been called before markCompleted'
'syncAsync must have been called before markCompleted'
).markCompleted();
}
}
@@ -331,7 +305,7 @@ export async function showWorkflowStatusAsync(
job,
logs: nullthrows(
logsStates.get(job.id),
'syncJobsAsync must have been called before getLogs'
'syncAsync must have been called before getLogs'
).getLogs(),
}))
);
+22 -18
View File
@@ -19,6 +19,7 @@ import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExist
import { ensureTestFlightGroupExistsAsync } from '../credentials/ios/appstore/ensureTestFlightGroup';
import { Target } from '../credentials/ios/types';
import {
AccountFragment,
WorkflowJobStatus,
WorkflowProjectSourceType,
WorkflowRunStatus,
@@ -34,7 +35,6 @@ import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersion
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../user/actions';
import { Actor, getActorDisplayName } from '../user/User';
import { sleepAsync } from '../utils/promise';
import { Client } from '../vcs/vcs';
@@ -43,6 +43,7 @@ import {
INVALID_BUNDLE_IDENTIFIER_MESSAGE,
isBundleIdentifierValid,
} from '../project/ios/bundleIdentifier';
import { UserQuery } from '../graphql/queries/UserQuery';
function deriveBundleIdSlug(bundleId: string): string {
return bundleId.split('.').filter(Boolean).pop()!;
@@ -142,8 +143,11 @@ export default class Go extends EasCommand {
} = await this.getContextAsync(Go, {
nonInteractive: false,
});
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
const userPrimaryAccount = await UserQuery.requireCurrentUserPrimaryAccountAsync(graphqlClient);
const detectedSdkVersion = await detectProjectSdkVersionAsync(process.cwd());
if (detectedSdkVersion && !flags['sdk-version']) {
Log.log(
@@ -154,7 +158,7 @@ export default class Go extends EasCommand {
if (!sdkVersion) {
({ sdkVersion } = await this.selectSdkVersionAsync(graphqlClient));
}
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
const bundleId = flags['bundle-id'] ?? this.generateBundleId(userPrimaryAccount);
if (!isBundleIdentifierValid(bundleId)) {
throw new Error(
`"${bundleId}" is not a valid iOS bundle identifier. ${INVALID_BUNDLE_IDENTIFIER_MESSAGE} Pass a valid identifier with --bundle-id.`
@@ -167,7 +171,7 @@ export default class Go extends EasCommand {
let projectId: string;
try {
projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, slug)
this.ensureEasProjectAsync(graphqlClient, userPrimaryAccount, slug)
);
} catch (error) {
setupSpinner.fail();
@@ -202,7 +206,7 @@ export default class Go extends EasCommand {
} = await this.dispatchWorkflowAsync(
graphqlClient,
projectId,
actor,
userPrimaryAccount,
bundleId,
appName,
ascApp.id,
@@ -275,8 +279,8 @@ export default class Go extends EasCommand {
};
}
private generateBundleId(actor: Actor): string {
const username = ensureActorHasPrimaryAccount(actor).name;
private generateBundleId(userPrimaryAccount: AccountFragment): string {
const username = userPrimaryAccount.name;
const sanitizedUsername = username
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
@@ -287,14 +291,12 @@ export default class Go extends EasCommand {
private async ensureEasProjectAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
userPrimaryAccount: AccountFragment,
slug: string
): Promise<string> {
const account = ensureActorHasPrimaryAccount(actor);
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
account.name,
userPrimaryAccount.name,
slug
);
@@ -303,7 +305,7 @@ export default class Go extends EasCommand {
}
return await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
accountId: userPrimaryAccount.id,
projectName: slug,
});
}
@@ -394,7 +396,7 @@ export default class Go extends EasCommand {
private async dispatchWorkflowAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
actor: Actor,
userPrimaryAccount: AccountFragment,
bundleId: string,
appName: string,
ascAppId: string,
@@ -402,8 +404,6 @@ export default class Go extends EasCommand {
tmpDir: string,
vcsClient: Client
): Promise<{ workflowUrl: string; workflowRunId: string; sdkVersion: string }> {
const account = ensureActorHasPrimaryAccount(actor);
const repackConfig = await WorkflowRunQuery.expoGoRepackConfigurationAsync(graphqlClient, {
appId: projectId,
ascAppId,
@@ -421,17 +421,17 @@ export default class Go extends EasCommand {
const { projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
graphqlClient,
vcsClient,
accountId: account.id,
accountId: userPrimaryAccount.id,
});
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
accountId: userPrimaryAccount.id,
filePath: path.join(tmpDir, 'eas.json'),
maxSizeBytes: 1024 * 1024,
});
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
accountId: userPrimaryAccount.id,
filePath: path.join(tmpDir, 'package.json'),
maxSizeBytes: 1024 * 1024,
});
@@ -449,7 +449,11 @@ export default class Go extends EasCommand {
},
});
const workflowUrl = getWorkflowRunUrl(account.name, deriveBundleIdSlug(bundleId), result.id);
const workflowUrl = getWorkflowRunUrl(
userPrimaryAccount.name,
deriveBundleIdSlug(bundleId),
result.id
);
return { workflowUrl, workflowRunId: result.id, sdkVersion: repackConfig.sdkVersion };
}
@@ -9,7 +9,6 @@ import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
Role,
} from '../../../../graphql/generated';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
@@ -56,12 +55,6 @@ describe(IntegrationsConvexConnect, () => {
email: '[email protected]',
featureGates: {},
isExpoAdmin: false,
primaryAccount: {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
accounts: [],
};
@@ -62,12 +62,6 @@ describe(IntegrationsPostHogConnect, () => {
email: '[email protected]',
featureGates: {},
isExpoAdmin: false,
primaryAccount: {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
accounts: [],
};
@@ -28,7 +28,6 @@ jest.mock('../../../project/expoConfig', () => ({
createOrModifyExpoConfigAsync: jest.fn(),
}));
jest.mock('../../../prompts');
jest.mock('../../../user/actions');
jest.mock('../../../graphql/mutations/AppMutation');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../ora', () => ({
@@ -7,11 +7,11 @@ import {
WorkflowCommandSelectionStateValue,
executeWorkflowSelectionActionsAsync,
} from '../../commandUtils/workflow/stateMachine';
import { WorkflowLogLine, WorkflowLogs } from '../../commandUtils/workflow/types';
import { JobLogs, LogLine } from '../../commandUtils/logs/types';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
function printLogsForAllSteps(logs: WorkflowLogs): void {
function printLogsForAllSteps(logs: JobLogs): void {
[...logs.values()].forEach(({ label, logLines }) => {
if (logLines.length === 0) {
return;
@@ -85,7 +85,7 @@ export default class WorkflowLogView extends EasCommand {
return;
}
const logs = finalSelectionState?.logs as unknown as WorkflowLogs | null;
const logs = finalSelectionState?.logs;
if (allSteps) {
if (logs) {
if (flags.json) {
@@ -103,7 +103,7 @@ export default class WorkflowLogView extends EasCommand {
const logGroup = logs?.get(selectedStep);
if (logGroup) {
if (flags.json) {
const output: { [key: string]: WorkflowLogLine[] | null } = {};
const output: { [key: string]: LogLine[] | null } = {};
output[selectedStep] = logGroup.logLines;
printJsonOnlyOutput(output);
} else {
@@ -8,11 +8,6 @@ export const jester: Actor = {
id: 'jester-id',
email: '[email protected]',
username: 'jester',
primaryAccount: {
id: 'jester-account-id',
name: 'jester',
viewerUserPermission: { role: Role.Admin },
},
accounts: [
{
id: 'jester-account-id',
@@ -1,7 +1,4 @@
import {
jester as mockJester,
testAppQueryByIdResponse,
} from '../../../../credentials/__tests__/fixtures-constants';
import { testAppQueryByIdResponse } from '../../../../credentials/__tests__/fixtures-constants';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { testKeystore } from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
@@ -11,7 +8,6 @@ import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { CreateKeystore } from '../CreateKeystore';
jest.mock('../../../../prompts', () => ({ confirmAsync: jest.fn(() => true) }));
jest.mock('../../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
jest.mock('../../../utils/promptForCredentials');
jest.mock('../../utils/keystore', () => ({ generateRandomKeystoreAsync: jest.fn() }));
jest.mock('../../../../graphql/queries/AppQuery');
@@ -4,17 +4,13 @@ import {
testAndroidBuildCredentialsFragment,
testJksAndroidKeystoreFragment,
} from '../../../__tests__/fixtures-android';
import {
jester as mockJester,
testAppQueryByIdResponse,
} from '../../../__tests__/fixtures-constants';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { generateRandomKeystoreAsync } from '../../utils/keystore';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { SetUpBuildCredentials } from '../SetUpBuildCredentials';
jest.mock('../../../../ora');
jest.mock('../../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
jest.mock('../../../../prompts', () => ({ confirmAsync: jest.fn(() => true) }));
jest.mock('../../utils/keystore', () => ({ generateRandomKeystoreAsync: jest.fn() }));
jest.mock('../../../../graphql/queries/AppQuery');
@@ -25,7 +25,6 @@ import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
import { resolveTargetsAsync } from '../../project/ios/target';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { confirmAsync, promptAsync, selectAsync } from '../../prompts';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { CredentialsContext, CredentialsContextProjectInfo } from '../context';
import {
AppStoreApiKeyPurpose,
@@ -53,6 +52,7 @@ import { UpdateCredentialsJson } from '../ios/actions/UpdateCredentialsJson';
import { AppLookupParams } from '../ios/api/graphql/types/AppLookupParams';
import { App, IosAppCredentialsMap, Target } from '../ios/types';
import { displayIosCredentials } from '../ios/utils/printCredentials';
import { UserQuery } from '../../graphql/queries/UserQuery';
export class ManageIos {
constructor(
@@ -95,7 +95,7 @@ export class ManageIos {
const account = ctx.hasProjectContext
? await getAccountForProjectAsync(await ctx.getProjectIdAsync())
: ensureActorHasPrimaryAccount(ctx.user);
: await UserQuery.requireCurrentUserPrimaryAccountAsync(ctx.graphqlClient);
let app = null;
let targets = null;
@@ -8,8 +8,8 @@ import { Action } from './HelperActions';
import { ManageIos } from './ManageIos';
import { AccountFragment } from '../../graphql/generated';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { CredentialsContext, CredentialsContextProjectInfo } from '../context';
import { UserQuery } from '../../graphql/queries/UserQuery';
export class SetUpIosBuildCredentials extends ManageIos {
constructor(
@@ -56,7 +56,7 @@ export class SetUpIosBuildCredentials extends ManageIos {
const account = ctx.hasProjectContext
? await getAccountForProjectAsync(await ctx.getProjectIdAsync())
: ensureActorHasPrimaryAccount(ctx.user);
: await UserQuery.requireCurrentUserPrimaryAccountAsync(ctx.graphqlClient);
let app = null;
let targets = null;
@@ -26,11 +26,6 @@ describe(AccountResolver, () => {
id: 'user_id_666',
email: '[email protected]',
username: 'dominik',
primaryAccount: {
id: 'account_id_777',
name: 'dominik',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_777',
+144 -20
View File
@@ -1102,6 +1102,8 @@ export type AgentDeviceRunSessionRemoteConfig = {
*/
egressUrl?: Maybe<Scalars['String']['output']>;
/** Session token gating the web preview. Null when the preview runs ungated. */
/** URL of the preview server, for reaching its API rather than its page. */
previewApiUrl?: Maybe<Scalars['String']['output']>;
webPreviewToken?: Maybe<Scalars['String']['output']>;
/**
* URL of the web preview surface for the session. Null when a web preview is
@@ -3639,7 +3641,8 @@ export type AppObserveOverviewEngagementInput = {
export type AppObserveOverviewEngagementStat = {
__typename?: 'AppObserveOverviewEngagementStat';
previousPeriodTotal: Scalars['Int']['output'];
/** Total for the equal-length window before startTime. Null when the window is longer than 45 days, since the previous period would fall outside the 90-day retention. */
previousPeriodTotal?: Maybe<Scalars['Int']['output']>;
/** Per-bucket approximate uniques; buckets do not sum to `total`. */
series: Array<AppObserveOverviewEngagementBucket>;
total: Scalars['Int']['output'];
@@ -3655,12 +3658,13 @@ export type AppObserveOverviewStability = {
crashFreeUsers: Scalars['Float']['output'];
/** Distinct users that hit a fatal error in range. */
crashedUsers: Scalars['Int']['output'];
/** Fatal exception events in the equal-length window before startTime. */
previousPeriodCrashCount: Scalars['Int']['output'];
/** Same fractions for the equal-length window before startTime; null when it had no activity. */
/** Fatal exception events in the equal-length window before startTime. Null when the window is longer than 45 days. */
previousPeriodCrashCount?: Maybe<Scalars['Int']['output']>;
/** Same fractions for the equal-length window before startTime; null when it had no activity, or when the window is longer than 45 days and the previous period falls outside the 90-day retention. */
previousPeriodCrashFreeSessions?: Maybe<Scalars['Float']['output']>;
previousPeriodCrashFreeUsers?: Maybe<Scalars['Float']['output']>;
previousPeriodCrashedUsers: Scalars['Int']['output'];
/** Null when the window is longer than 45 days. */
previousPeriodCrashedUsers?: Maybe<Scalars['Int']['output']>;
series: Array<AppObserveOverviewStabilityBucket>;
/** All exception events in range, for distinguishing quiet apps from crash-free ones. */
totalErrors: Scalars['Int']['output'];
@@ -4126,6 +4130,8 @@ export enum AppObserveUserEventListOrderByField {
export type AppObserveUserEventName = {
__typename?: 'AppObserveUserEventName';
count: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
lastSeenAt: Scalars['DateTime']['output'];
name: Scalars['String']['output'];
};
@@ -4156,6 +4162,8 @@ export type AppObserveUserEventNamesOrderBy = {
export enum AppObserveUserEventNamesOrderByField {
Count = 'COUNT',
FirstSeen = 'FIRST_SEEN',
LastSeen = 'LAST_SEEN',
Name = 'NAME'
}
@@ -4714,6 +4722,8 @@ export type AppiumRunSessionRemoteConfig = {
*/
egressUrl?: Maybe<Scalars['String']['output']>;
/** Session token gating the web preview. Null when the preview runs ungated. */
/** URL of the preview server, for reaching its API rather than its page. */
previewApiUrl?: Maybe<Scalars['String']['output']>;
webPreviewToken?: Maybe<Scalars['String']['output']>;
/**
* URL of the web preview surface for the session. Null when a web preview is
@@ -5141,6 +5151,8 @@ export type ArgentRunSessionRemoteConfig = {
* session does not use local egress.
*/
egressUrl?: Maybe<Scalars['String']['output']>;
/** URL of the preview server, for reaching its API rather than its page. */
previewApiUrl?: Maybe<Scalars['String']['output']>;
toolsAuthToken?: Maybe<Scalars['String']['output']>;
toolsUrl: Scalars['String']['output'];
/** Session token gating the web preview. Null when the preview runs ungated. */
@@ -10793,6 +10805,7 @@ export type RootQuery = {
posthogIntegration: PostHogIntegrationQuery;
/** Top-level query object for querying Runtimes. */
runtimes: RuntimeQuery;
sandboxes: SandboxQuery;
snack: SnackQuery;
/** Top-level query object for querying Expo status page services. */
statuspageService: StatuspageServiceQuery;
@@ -11037,6 +11050,36 @@ export type SsoUserDataInput = {
lastName?: InputMaybe<Scalars['String']['input']>;
};
export type Sandbox = {
__typename?: 'Sandbox';
app: App;
createdAt: Scalars['DateTime']['output'];
finishedAt?: Maybe<Scalars['DateTime']['output']>;
id: Scalars['ID']['output'];
lastUsedAt: Scalars['DateTime']['output'];
startedAt?: Maybe<Scalars['DateTime']['output']>;
status: SandboxStatus;
turtleJobRun: JobRun;
updatedAt: Scalars['DateTime']['output'];
};
export type SandboxQuery = {
__typename?: 'SandboxQuery';
byId: Sandbox;
};
export type SandboxQuery_ByIdArgs = {
sandboxId: Scalars['ID']['input'];
};
export enum SandboxStatus {
Errored = 'ERRORED',
Running = 'RUNNING',
Starting = 'STARTING',
Stopped = 'STOPPED'
}
export type SecondFactorBooleanResult = {
__typename?: 'SecondFactorBooleanResult';
success: Scalars['Boolean']['output'];
@@ -11177,6 +11220,8 @@ export type ServeSimRunSessionRemoteConfig = {
*/
egressUrl?: Maybe<Scalars['String']['output']>;
/** Session token gating the preview. Null when the preview runs ungated. */
/** URL of the preview server, for reaching its API rather than its page. */
previewApiUrl?: Maybe<Scalars['String']['output']>;
previewToken?: Maybe<Scalars['String']['output']>;
previewUrl: Scalars['String']['output'];
/** @deprecated Use previewUrl instead. */
@@ -11546,6 +11591,35 @@ export type SubscriptionDetails_PlanEnablementArgs = {
serviceMetric: EasServiceMetric;
};
/** An unresolved finding from Supabase's Security or Performance Advisor (database linter). */
export type SupabaseAdvisorLint = {
__typename?: 'SupabaseAdvisorLint';
/** Stable identifier for this finding on this project. */
cacheKey: Scalars['String']['output'];
description: Scalars['String']['output'];
/** Project-specific explanation naming the affected schema object. */
detail: Scalars['String']['output'];
/** Affected schema object, e.g. public.todos, when the lint names one. */
entity?: Maybe<Scalars['String']['output']>;
level: SupabaseAdvisorLintLevel;
/** Lint rule identifier, e.g. rls_disabled_in_public. */
name: Scalars['String']['output'];
/** Link to the remediation guide, when Supabase provides one. */
remediation?: Maybe<Scalars['String']['output']>;
title: Scalars['String']['output'];
};
export enum SupabaseAdvisorLintLevel {
Error = 'ERROR',
Info = 'INFO',
Warn = 'WARN'
}
export enum SupabaseAdvisorType {
Performance = 'PERFORMANCE',
Security = 'SECURITY'
}
export type SupabaseConnection = {
__typename?: 'SupabaseConnection';
account: Account;
@@ -11622,6 +11696,12 @@ export type SupabaseOrganization = {
export type SupabaseProject = {
__typename?: 'SupabaseProject';
/**
* Live unresolved lints from the project's Security or Performance Advisor, ordered by severity.
* Readable with view permission; a token refresh triggered by a read is persisted with elevated
* privileges. Null when Supabase cannot be reached.
*/
advisorLints?: Maybe<Array<SupabaseAdvisorLint>>;
app: App;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
@@ -11633,6 +11713,11 @@ export type SupabaseProject = {
updatedAt: Scalars['DateTime']['output'];
};
export type SupabaseProject_AdvisorLintsArgs = {
type: SupabaseAdvisorType;
};
export type SupabaseProjectMutation = {
__typename?: 'SupabaseProjectMutation';
/** Removes the Expo-side project link only; the Supabase project is preserved. */
@@ -13260,6 +13345,8 @@ export type WebPreviewOnlyRunSessionRemoteConfig = {
*/
egressUrl?: Maybe<Scalars['String']['output']>;
/** Session token gating the web preview. Null when the preview runs ungated. */
/** URL of the preview server, for reaching its API rather than its page. */
previewApiUrl?: Maybe<Scalars['String']['output']>;
webPreviewToken?: Maybe<Scalars['String']['output']>;
webPreviewUrl: Scalars['String']['output'];
};
@@ -16309,11 +16396,11 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
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, webPreviewToken?: string | null, egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null }
| { __typename: 'AppiumRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, appiumUrl: string, capabilities: any, webPreviewUrl?: string | null, webPreviewToken?: string | null }
| { __typename: 'ArgentRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null }
| { __typename: 'ServeSimRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null }
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null }
| { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null, egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null }
| { __typename: 'AppiumRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, appiumUrl: string, capabilities: any, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null }
| { __typename: 'ArgentRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null }
| { __typename: 'ServeSimRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null, previewApiUrl?: string | null }
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewApiUrl?: string | null, previewUrl: string, previewToken?: string | null }
| null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
@@ -16714,30 +16801,40 @@ export type CurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type CurrentUserQuery = { __typename?: 'RootQuery', meActor?:
| { __typename: 'PartnerActor', username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename: 'PartnerActor', id: string, username: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> }
| { __typename: 'Robot', firstName?: string | null, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename: 'Robot', id: string, firstName?: string | null, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> }
| { __typename: 'SSOUser', username: string, id: string, featureGates: any, isExpoAdmin: boolean, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename: 'SSOUser', id: string, username: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> }
| { __typename: 'User', email: string, username: string, id: string, featureGates: any, isExpoAdmin: boolean, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename: 'User', id: string, email: string, username: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> }
| null };
export type CurrentUserWithPrimaryAccountQueryVariables = Exact<{ [key: string]: never; }>;
export type CurrentUserWithPrimaryAccountQuery = { __typename?: 'RootQuery', meActor?:
| { __typename: 'PartnerActor', id: string }
| { __typename: 'Robot', id: string }
| { __typename: 'SSOUser', id: string, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } } }
| { __typename: 'User', id: string, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } } }
| null };
export type WebhooksByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
webhookFilter?: InputMaybe<WebhookFilter>;
@@ -16895,6 +16992,33 @@ export type UsageMetricTotalFragment = { __typename?: 'UsageMetricTotal', id: st
export type AccountUsageMetricFragment = { __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number };
type MeActorFragment_PartnerActor = { __typename: 'PartnerActor', username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> };
type MeActorFragment_Robot = { __typename: 'Robot', firstName?: string | null, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> };
type MeActorFragment_SsoUser = { __typename: 'SSOUser', username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> };
type MeActorFragment_User = { __typename: 'User', email: string, username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> };
export type MeActorFragment =
| MeActorFragment_PartnerActor
| MeActorFragment_Robot
| MeActorFragment_SsoUser
| MeActorFragment_User
;
export type AppFragment = { __typename?: 'App', id: string, name: string, fullName: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
@@ -88,6 +88,7 @@ export const DeviceRunSessionQuery = {
agentDeviceRemoteSessionToken
webPreviewUrl
webPreviewToken
previewApiUrl
egressUrl
egressToken
egressFingerprint
@@ -102,6 +103,7 @@ export const DeviceRunSessionQuery = {
toolsAuthToken
webPreviewUrl
webPreviewToken
previewApiUrl
}
... on AppiumRunSessionRemoteConfig {
egressUrl
@@ -112,6 +114,7 @@ export const DeviceRunSessionQuery = {
capabilities
webPreviewUrl
webPreviewToken
previewApiUrl
}
... on ServeSimRunSessionRemoteConfig {
egressUrl
@@ -120,6 +123,7 @@ export const DeviceRunSessionQuery = {
egressPort
previewUrl
previewToken
previewApiUrl
}
... on WebPreviewOnlyRunSessionRemoteConfig {
egressUrl
@@ -128,6 +132,7 @@ export const DeviceRunSessionQuery = {
egressPort
previewUrl: webPreviewUrl
previewToken: webPreviewToken
previewApiUrl
}
}
turtleJobRun {
@@ -3,8 +3,13 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import { CurrentUserQuery } from '../generated';
import {
AccountFragment,
CurrentUserQuery,
CurrentUserWithPrimaryAccountQuery,
} from '../generated';
import { AccountFragmentNode } from '../types/Account';
import { MeActorFragmentNode } from '../types/Actor';
export const UserQuery = {
async currentUserAsync(graphqlClient: ExpoGraphqlClient): Promise<CurrentUserQuery['meActor']> {
@@ -16,31 +21,10 @@ export const UserQuery = {
meActor {
__typename
id
... on User {
email
}
... on UserActor {
username
primaryAccount {
id
...AccountFragment
}
}
... on Robot {
firstName
}
accounts {
id
...AccountFragment
}
... on PartnerActor {
username
}
featureGates
isExpoAdmin
...MeActorFragment
}
}
${print(AccountFragmentNode)}
${print(MeActorFragmentNode)}
`,
{},
{
@@ -52,4 +36,52 @@ export const UserQuery = {
return data.meActor;
},
async requireCurrentUserPrimaryAccountAsync(
graphqlClient: ExpoGraphqlClient
): Promise<AccountFragment> {
let data: CurrentUserWithPrimaryAccountQuery;
try {
data = await withErrorHandlingAsync(
graphqlClient
.query<CurrentUserWithPrimaryAccountQuery>(
gql`
query CurrentUserWithPrimaryAccount {
meActor {
__typename
id
... on UserActor {
primaryAccount {
id
...AccountFragment
}
}
}
}
${print(AccountFragmentNode)}
`,
{},
{
additionalTypenames: ['User', 'SSOUser'],
}
)
.toPromise()
);
} catch (error) {
throw new Error(
'An error occurred while fetching the primary account of the current user. Check to ensure your session has sufficient scope on your primary account.'
);
}
const actor = data.meActor;
if (!actor) {
throw new Error('Must be logged in to perform this action.');
}
if (actor.__typename === 'User' || actor.__typename === 'SSOUser') {
return actor.primaryAccount;
}
throw new Error(`This action is not supported for the ${actor.__typename} user type.`);
},
};
@@ -0,0 +1,31 @@
/* eslint-disable graphql/required-fields */
import { print } from 'graphql';
import gql from 'graphql-tag';
import { AccountFragmentNode } from './Account';
export const MeActorFragmentNode = gql`
${print(AccountFragmentNode)}
fragment MeActorFragment on Actor {
__typename
id
... on User {
email
}
... on UserActor {
username
}
... on Robot {
firstName
}
accounts {
id
...AccountFragment
}
... on PartnerActor {
username
}
featureGates
isExpoAdmin
}
`;
@@ -1,4 +1,4 @@
import { AppObserveUserEvent, PageInfo } from '../../graphql/generated';
import { AppObserveUserEvent, AppObserveUserEventName, PageInfo } from '../../graphql/generated';
import {
buildObserveCustomEventNamesTable,
buildObserveCustomEventsEmptyWithSuggestionsJson,
@@ -7,6 +7,16 @@ import {
buildObserveCustomEventsTable,
} from '../formatCustomEvents';
function makeEventName(name: string, count: number): AppObserveUserEventName {
return {
__typename: 'AppObserveUserEventName' as const,
name,
count,
firstSeenAt: '2025-01-01T00:00:00.000Z',
lastSeenAt: '2025-01-15T00:00:00.000Z',
};
}
function makeCustomEvent(overrides: Partial<AppObserveUserEvent> = {}): AppObserveUserEvent {
return {
__typename: 'AppObserveUserEvent' as const,
@@ -299,8 +309,8 @@ describe(buildObserveCustomEventNamesTable, () => {
it('shows event names with counts', () => {
const output = buildObserveCustomEventNamesTable([
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
makeEventName('foo', 10),
makeEventName('bar', 5),
]);
expect(output).toContain('foo');
expect(output).toContain('10');
@@ -309,10 +319,9 @@ describe(buildObserveCustomEventNamesTable, () => {
});
it('appends a truncation notice when isTruncated is true', () => {
const output = buildObserveCustomEventNamesTable(
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
{ isTruncated: true }
);
const output = buildObserveCustomEventNamesTable([makeEventName('foo', 10)], {
isTruncated: true,
});
expect(output).toContain('Result is truncated');
});
});
@@ -320,8 +329,8 @@ describe(buildObserveCustomEventNamesTable, () => {
describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
it('shows the filtered event name and the available event names', () => {
const output = buildObserveCustomEventsEmptyWithSuggestionsTable('login', [
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
makeEventName('foo', 10),
makeEventName('bar', 5),
]);
expect(output).toContain('No events found matching "login"');
@@ -342,7 +351,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
it('appends a truncation notice when isTruncated is set', () => {
const output = buildObserveCustomEventsEmptyWithSuggestionsTable(
'login',
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
[makeEventName('foo', 10)],
{ isTruncated: true }
);
@@ -352,7 +361,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
it('includes the time range description in the message when provided', () => {
const output = buildObserveCustomEventsEmptyWithSuggestionsTable(
'login',
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
[makeEventName('foo', 10)],
{ daysBack: 7 }
);
@@ -364,10 +373,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsJson, () => {
it('returns the filtered event name, empty events array, and available names', () => {
const result = buildObserveCustomEventsEmptyWithSuggestionsJson(
'login',
[
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
],
[makeEventName('foo', 10), makeEventName('bar', 5)],
false
);
+5 -1
View File
@@ -17,6 +17,10 @@ const errorReal = console.error;
const isCi = boolish('CI', false);
export function isSpinnerEnabled(): boolean {
return !(Log.isDebug || !process.stdin.isTTY || isCi);
}
/**
* A custom ora spinner that sends the stream to stdout in CI, or non-TTY, instead of stderr (the default).
*
@@ -25,7 +29,7 @@ const isCi = boolish('CI', false);
*/
export function ora(options?: Options | string): Ora {
const inputOptions = typeof options === 'string' ? { text: options } : (options ?? {});
const disabled = Log.isDebug || !process.stdin.isTTY || isCi;
const disabled = !isSpinnerEnabled();
const spinner = oraReal({
// Ensure our non-interactive mode emulates CI mode.
isEnabled: !disabled,
@@ -10,11 +10,6 @@ const userActor: Actor = {
id: 'user-id',
email: '[email protected]',
username: 'jane',
primaryAccount: {
id: 'account-id-personal',
name: 'jane',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account-id-org',
@@ -4,7 +4,7 @@ import os from 'os';
import { instance, mock } from 'ts-mockito';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { jester, jester as mockJester } from '../../../credentials/__tests__/fixtures-constants';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { promptAsync } from '../../../prompts';
import { resolveVcsClient } from '../../../vcs';
@@ -16,7 +16,6 @@ import {
jest.mock('fs');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
const vcsClient = resolveVcsClient();
@@ -2,14 +2,12 @@ import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import { jester as mockJester } from '../../../credentials/__tests__/fixtures-constants';
import { promptAsync } from '../../../prompts';
import { resolveVcsClient } from '../../../vcs';
import { resolveGradleBuildContextAsync } from '../gradle';
jest.mock('fs');
jest.mock('../../../prompts');
jest.mock('../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
const vcsClient = resolveVcsClient();
@@ -5,7 +5,7 @@ import path from 'path';
import { instance, mock } from 'ts-mockito';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { jester, jester as mockJester } from '../../../credentials/__tests__/fixtures-constants';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { promptAsync } from '../../../prompts';
import { resolveVcsClient } from '../../../vcs';
@@ -18,7 +18,6 @@ import {
jest.mock('fs');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
const vcsClient = resolveVcsClient();
@@ -240,25 +240,39 @@ describe('simulator resource class flags', () => {
});
describe(formatPreviewUrl, () => {
it('appends the session token for a gated preview', () => {
expect(formatPreviewUrl('https://preview.example.test', 'tok-1')).toBe(
'https://preview.example.test/?token=tok-1'
);
const PAGE_URL = 'https://expo.dev/simulator-preview/abc';
const SERVER_URL = 'https://web-preview-abc.eas-simulator.ngrok.dev';
const pageConfig = {
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
previewUrl: PAGE_URL,
previewToken: 'tok-1',
previewApiUrl: SERVER_URL,
};
const serverConfig = {
__typename: 'ServeSimRunSessionRemoteConfig' as const,
previewUrl: SERVER_URL,
previewToken: 'tok-1',
};
it('puts the token in the fragment for the expo.dev page, where no request carries it', () => {
expect(formatPreviewUrl(pageConfig, PAGE_URL, 'tok-1')).toBe(`${PAGE_URL}#token=tok-1`);
});
it('puts the token on the query for a session that predates the expo.dev page', () => {
expect(formatPreviewUrl(serverConfig, SERVER_URL, 'tok-1')).toBe(`${SERVER_URL}/?token=tok-1`);
});
it('leaves the url alone when the preview is ungated', () => {
expect(formatPreviewUrl('https://preview.example.test', null)).toBe(
'https://preview.example.test'
);
expect(formatPreviewUrl('https://preview.example.test', undefined)).toBe(
'https://preview.example.test'
);
expect(formatPreviewUrl(pageConfig, PAGE_URL, null)).toBe(PAGE_URL);
expect(formatPreviewUrl(pageConfig, PAGE_URL, undefined)).toBe(PAGE_URL);
});
});
describe(sanitizeRemoteConfigForJson, () => {
const PREVIEW_URL = 'https://preview.example.test';
const PAGE_URL = 'https://expo.dev/simulator-preview/abc';
it('moves the token into the preview url and drops the standalone field', () => {
const sanitized = sanitizeRemoteConfigForJson({
__typename: 'ServeSimRunSessionRemoteConfig' as const,
@@ -273,6 +287,22 @@ describe(sanitizeRemoteConfigForJson, () => {
expect(JSON.stringify(sanitized)).not.toContain('previewToken');
});
it('puts the token on the api url query, for a caller that cannot send a header', () => {
const sanitized = sanitizeRemoteConfigForJson({
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
previewUrl: PAGE_URL,
previewToken: 'tok-1',
previewApiUrl: PREVIEW_URL,
});
expect(sanitized).toEqual({
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
previewUrl: `${PAGE_URL}#token=tok-1`,
previewApiUrl: `${PREVIEW_URL}/?token=tok-1`,
});
expect(JSON.stringify(sanitized)).not.toContain('previewToken');
});
it('does the same for a controller session', () => {
const sanitized = sanitizeRemoteConfigForJson({
__typename: 'ArgentRunSessionRemoteConfig' as const,
@@ -368,6 +398,48 @@ describe('gated preview links', () => {
}
});
it('prints the fragment link for every session the worker gave a preview api url', () => {
const PAGE = 'https://expo.dev/simulator-preview/abc';
const API = 'https://web-preview-abc.eas-simulator.ngrok.dev';
const sessions = [
{
__typename: 'ServeSimRunSessionRemoteConfig' as const,
previewUrl: PAGE,
previewToken: 'tok-1',
previewApiUrl: API,
},
{
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
previewUrl: PAGE,
previewToken: 'tok-1',
previewApiUrl: API,
},
{
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
agentDeviceRemoteSessionUrl: 'https://daemon.example.test',
agentDeviceRemoteSessionToken: 'daemon-token',
webPreviewUrl: PAGE,
webPreviewToken: 'tok-1',
previewApiUrl: API,
},
{
__typename: 'ArgentRunSessionRemoteConfig' as const,
toolsUrl: 'https://argent.example.test',
toolsAuthToken: 'argent-token',
webPreviewUrl: PAGE,
webPreviewToken: 'tok-1',
previewApiUrl: API,
},
{ ...iosAppiumConfig, webPreviewUrl: PAGE, webPreviewToken: 'tok-1', previewApiUrl: API },
];
for (const remoteConfig of sessions) {
const instructions = formatRemoteSessionInstructions(remoteConfig, 'env');
expect(instructions).toContain(`${PAGE}#token=tok-1`);
expect(instructions).not.toContain('?token=');
}
});
it('prints the plain url when the preview is ungated', () => {
const instructions = formatRemoteSessionInstructions(
{ __typename: 'ServeSimRunSessionRemoteConfig' as const, previewUrl: PREVIEW },
+40 -13
View File
@@ -221,11 +221,29 @@ function getControllerEnvironmentVariables(
type RemoteSessionInstructionsConfigType = 'env' | 'dotenv';
function previewUrlIsWebsitePage(remoteConfig: DeviceRunSessionRemoteConfig): boolean {
return remoteConfig.previewApiUrl != null;
}
/**
* Preview link for a session. A gated serve-sim needs the session token, and a browser cannot send
* a header on a page load, so it rides the query. serve-sim swaps it for a cookie on the first load.
* Preview link for a session. A gated preview needs the session token, and a browser cannot send
* a header on a page load. The expo.dev preview page reads it from the fragment, which no request
* carries; a preview server reads it from the query and swaps it for a cookie on the first load.
*/
export function formatPreviewUrl(url: string, token: string | null | undefined): string {
export function formatPreviewUrl(
remoteConfig: DeviceRunSessionRemoteConfig,
url: string,
token: string | null | undefined
): string {
if (!token || !previewUrlIsWebsitePage(remoteConfig)) {
return formatPreviewApiUrl(url, token);
}
const withToken = new URL(url);
withToken.hash = new URLSearchParams({ token }).toString();
return withToken.toString();
}
function formatPreviewApiUrl(url: string, token: string | null | undefined): string {
if (!token) {
return url;
}
@@ -235,9 +253,9 @@ export function formatPreviewUrl(url: string, token: string | null | undefined):
}
/**
* Remote config for `--json`. The preview URL carries the token and the standalone token field is
* dropped, so a consumer gets one URL that works rather than a bare URL that 401s next to a secret
* it has to know to combine.
* Remote config for `--json`. Both URLs carry the token and the standalone token field is dropped,
* so a consumer gets URLs that work rather than bare URLs that 401 next to a secret it has to know
* to combine.
*/
export function sanitizeRemoteConfigForJson(
remoteConfig: DeviceRunSessionRemoteConfig
@@ -246,7 +264,13 @@ export function sanitizeRemoteConfigForJson(
case 'ServeSimRunSessionRemoteConfig':
case 'WebPreviewOnlyRunSessionRemoteConfig': {
const { previewToken, ...rest } = remoteConfig;
return { ...rest, previewUrl: formatPreviewUrl(remoteConfig.previewUrl, previewToken) };
return {
...rest,
previewUrl: formatPreviewUrl(remoteConfig, remoteConfig.previewUrl, previewToken),
previewApiUrl: remoteConfig.previewApiUrl
? formatPreviewApiUrl(remoteConfig.previewApiUrl, previewToken)
: remoteConfig.previewApiUrl,
};
}
case 'AgentDeviceRunSessionRemoteConfig':
case 'ArgentRunSessionRemoteConfig':
@@ -255,8 +279,11 @@ export function sanitizeRemoteConfigForJson(
return {
...rest,
webPreviewUrl: remoteConfig.webPreviewUrl
? formatPreviewUrl(remoteConfig.webPreviewUrl, webPreviewToken)
? formatPreviewUrl(remoteConfig, remoteConfig.webPreviewUrl, webPreviewToken)
: remoteConfig.webPreviewUrl,
previewApiUrl: remoteConfig.previewApiUrl
? formatPreviewApiUrl(remoteConfig.previewApiUrl, webPreviewToken)
: remoteConfig.previewApiUrl,
};
}
}
@@ -331,7 +358,7 @@ function formatControllerInstructions(
'',
'🌐 Open the following URL in your browser to preview the simulator:',
'',
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
formatPreviewUrl(remoteConfig, remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
);
}
return lines.join('\n');
@@ -369,7 +396,7 @@ function formatControllerInstructions(
'',
'🌐 Open the following URL in your browser to preview the simulator:',
'',
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
formatPreviewUrl(remoteConfig, remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
);
}
return lines.join('\n');
@@ -397,7 +424,7 @@ function formatControllerInstructions(
'',
'Open the simulator preview:',
'',
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
formatPreviewUrl(remoteConfig, remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
);
}
return lines.join('\n');
@@ -406,13 +433,13 @@ function formatControllerInstructions(
return [
'🌐 Open the following URL in your browser to access the simulator:',
'',
formatPreviewUrl(remoteConfig.previewUrl, remoteConfig.previewToken),
formatPreviewUrl(remoteConfig, remoteConfig.previewUrl, remoteConfig.previewToken),
].join('\n');
case 'WebPreviewOnlyRunSessionRemoteConfig':
return [
'🌐 Open the following URL in your browser to access the simulator:',
'',
formatPreviewUrl(remoteConfig.previewUrl, remoteConfig.previewToken),
formatPreviewUrl(remoteConfig, remoteConfig.previewUrl, remoteConfig.previewToken),
].join('\n');
}
}
@@ -35,9 +35,6 @@ jest.mock('../../../graphql/mutations/SubmissionMutation', () => ({
jest.mock('../../../user/User', () => ({
getUserAsync: jest.fn(() => mockJester),
}));
jest.mock('../../../user/actions', () => ({
ensureLoggedInAsync: jest.fn(() => mockJester),
}));
jest.mock('../../../project/projectUtils');
jest.mock('../../ArchiveSource', () => {
return {
@@ -6,11 +6,6 @@ const userStub: Actor = {
id: 'userId',
email: '[email protected]',
username: 'username',
primaryAccount: {
id: 'account_id_777',
name: 'username',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_777',
@@ -26,11 +21,6 @@ const ssoUserStub: Actor = {
__typename: 'SSOUser',
id: 'ssoUserId',
username: 'ssoUsername',
primaryAccount: {
id: 'account_id_888',
name: 'ssoUsername',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_888',
-9
View File
@@ -1,9 +0,0 @@
import { Actor } from './User';
import { AccountFragment } from '../graphql/generated';
export function ensureActorHasPrimaryAccount(user: Actor): AccountFragment {
if (user.__typename === 'User' || user.__typename === 'SSOUser') {
return user.primaryAccount;
}
throw new Error('This action is not supported for robot users.');
}
+3
View File
@@ -35,6 +35,8 @@ function createWebSocketConstructor(): WebSocketConstructor {
return httpsProxyAgent ? createProxiedWebSocketConstructor(httpsProxyAgent) : WebSocket;
}
const LOGS_WEBSOCKET_TIMEOUT_MS = 5_000;
export function createRealtimeLogsClient(
graphqlClient: ExpoGraphqlClient
): RealtimeLogsClient | null {
@@ -49,6 +51,7 @@ export function createRealtimeLogsClient(
client = new Centrifuge(getEASLogsWebsocketUrl(), {
websocket: createWebSocketConstructor(),
getToken: getTokenAsync,
timeout: LOGS_WEBSOCKET_TIMEOUT_MS,
});
client.on('error', ({ error }) => {
Log.debug(`Realtime logs connection error: ${error.message}`);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "24.3.0",
"version": "24.5.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "24.4.0",
"version": "24.7.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,9 +26,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "24.4.0",
"@expo/build-tools": "24.7.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/logger": "24.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "24.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "24.3.0",
"version": "24.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/logger": "24.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "24.4.0",
"version": "24.7.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,15 +19,15 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "24.4.0",
"@expo/build-tools": "24.7.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/logger": "24.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.3.0",
"@expo/steps": "24.5.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@hapi/boom": "10.0.1",
Generated
+37 -17
View File
@@ -2291,14 +2291,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:24.4.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:24.7.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:24.0.0"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:24.0.0"
"@expo/package-manager": "npm:1.9.10"
@@ -2306,7 +2306,7 @@ __metadata:
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.3.0"
"@expo/steps": "npm:24.5.0"
"@expo/template-file": "npm:24.0.0"
"@expo/turtle-spawn": "npm:24.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -2340,6 +2340,7 @@ __metadata:
nock: "npm:13.4.0"
node-fetch: "npm:^2.7.0"
node-forge: "npm:^1.3.1"
node-pty: "npm:1.2.0-beta.15"
node-stream-zip: "npm:1.15.0"
nullthrows: "npm:^1.1.1"
plist: "npm:^3.1.0"
@@ -2559,7 +2560,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:24.3.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:24.5.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
@@ -2578,12 +2579,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:24.3.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:24.5.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2897,11 +2898,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:24.3.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:24.5.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/logger": "npm:24.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -2971,15 +2972,15 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:24.4.0"
"@expo/build-tools": "npm:24.7.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/logger": "npm:24.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.3.0"
"@expo/steps": "npm:24.5.0"
"@expo/template-file": "npm:24.0.0"
"@expo/turtle-spawn": "npm:24.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -9830,7 +9831,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:24.3.0"
"@expo/steps": "npm:24.5.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10414,9 +10415,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:24.4.0"
"@expo/build-tools": "npm:24.7.0"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/logger": "npm:24.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:24.0.0"
@@ -10471,8 +10472,8 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-json": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/eas-json": "npm:24.5.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:24.0.0"
@@ -10487,7 +10488,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.3.0"
"@expo/steps": "npm:24.5.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"
@@ -15369,6 +15370,15 @@ __metadata:
languageName: node
linkType: hard
"node-addon-api@npm:^7.1.0":
version: 7.1.1
resolution: "node-addon-api@npm:7.1.1"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/fb32a206276d608037fa1bcd7e9921e177fe992fc610d098aa3128baca3c0050fc1e014fa007e9b3874cf865ddb4f5bd9f43ccb7cbbbe4efaff6a83e920b17e9
languageName: node
linkType: hard
"node-domexception@npm:^1.0.0":
version: 1.0.0
resolution: "node-domexception@npm:1.0.0"
@@ -15528,6 +15538,16 @@ __metadata:
languageName: node
linkType: hard
"node-pty@npm:1.2.0-beta.15":
version: 1.2.0-beta.15
resolution: "node-pty@npm:1.2.0-beta.15"
dependencies:
node-addon-api: "npm:^7.1.0"
node-gyp: "npm:latest"
checksum: 10c0/2f2bcca18efe94f56fd5c9fc9611164f63af912b96286ea4d858f4886e3b25aa9c529e3d7680d47e5ddefc6c3e799786ed11dc4b80947e40438e00a67dbc2fbf
languageName: node
linkType: hard
"node-releases@npm:^2.0.1":
version: 2.0.1
resolution: "node-releases@npm:2.0.1"