mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc37c1c360 | ||
|
|
e78771e0f4 | ||
|
|
288bc44dfc | ||
|
|
d3db3926e3 | ||
|
|
5b87ba0adb | ||
|
|
0b2cea7bb7 | ||
|
|
eab4fc3cd5 | ||
|
|
e2e8cd245c | ||
|
|
80d46f0783 | ||
|
|
3b6b63a273 | ||
|
|
8373ae353c | ||
|
|
994794dbad | ||
|
|
d103da3bc5 | ||
|
|
ee3c62683b | ||
|
|
8ac9d5f101 | ||
|
|
1eaa799b9f | ||
|
|
bb0f2dfb21 | ||
|
|
5409c03d58 | ||
|
|
a941930ea7 | ||
|
|
74572101ac | ||
|
|
7b050cae3f | ||
|
|
d193860668 | ||
|
|
1da0be8529 | ||
|
|
08b4346620 | ||
|
|
4d6d9b1fc4 |
No files matched your search
@@ -6,19 +6,58 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Shorten the local egress banner printed by `eas simulator:start` and stop mentioning `eas simulator:egress` in interactive mode, where the client runs in the same terminal. ([#4383](https://github.com/expo/eas-cli/pull/4383) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.1.1](https://github.com/expo/eas-cli/releases/tag/v24.1.1) - 2026-09-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop telling interactive `eas simulator:start --egress local` users to run `eas simulator:egress` when the egress client already runs in the same terminal. ([#4382](https://github.com/expo/eas-cli/pull/4382) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.1.0](https://github.com/expo/eas-cli/releases/tag/v24.1.0) - 2026-09-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--egress-allow <host:port>` to `eas simulator:start` so local egress sessions can reach dev servers on this machine or its network. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli][build-tools] Forward `--egress-allow localhost:<port>` and `127.0.0.1:<port>` entries from the simulator host's loopback interface to this machine (like `adb reverse`), so dev server URLs that use `127.0.0.1` work through local egress. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Add the `eas/start_local_egress` step for iOS simulator sessions with local egress: the device host's system proxy points at a reverse tunnel to the EAS CLI machine, so HTTP(S) requests that honor it exit from that machine and third parties see its IP. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `--egress local` to `eas simulator:start` and the `eas simulator:egress` command, which run the local egress client for the session. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Require the serve-sim session token for device run session previews, so the tunnel no longer exposes the shell-exec route. ([#4306](https://github.com/expo/eas-cli/pull/4306) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Align the `eas observe:*` commands with the renamed Observe GraphQL schema. `--json` output now uses `name`/`value` instead of `metricName`/`metricValue` (metrics) and `name` instead of `eventName` (log events and event-name summaries), and `eas observe:events` now lists user-defined events only — exceptions moved to the new `eas observe:errors`. ([#4338](https://github.com/expo/eas-cli/pull/4338) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `lcd_width`, `lcd_height`, and `lcd_density` inputs to configure the Android emulator display. ([#4349](https://github.com/expo/eas-cli/pull/4349) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Add `eas channel:protect` and `eas channel:unprotect` to manage EAS Update channel protection, and show protection in channel list and view output. ([#4319](https://github.com/expo/eas-cli/pull/4319) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [eas-cli] Add `eas observe:errors` to display error and exception issue groups (grouped by fingerprint), with `--fingerprint <fingerprint>` to drill into a group's individual occurrences and stack traces. ([#4339](https://github.com/expo/eas-cli/pull/4339) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `eas observe:event` to display a single Observe event (metric, log, or error) by its ID. ([#4252](https://github.com/expo/eas-cli/pull/4252) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Start Argent remote sessions with `bun x` so they work when the `bunx` symlink is missing. ([491b372a](https://github.com/expo/eas-cli/commit/491b372a) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Reduce `expo-device-hub` preview resolution from 1280 px to 960 px to match `serve-sim` and lower streaming bandwidth. ([#4326](https://github.com/expo/eas-cli/pull/4326) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [build-tools] Install FFmpeg before launching `expo-device-hub` in Android device sessions. ([#4332](https://github.com/expo/eas-cli/pull/4332) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Make ASC API key issuer identifier optional in GraphQL and credentials types. ([#4248](https://github.com/expo/eas-cli/pull/4248) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [23.2.0](https://github.com/expo/eas-cli/releases/tag/v23.2.0) - 2026-08-31
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "23.2.0",
|
||||
"PRODUCTION": "23.2.0"
|
||||
"STAGING": "24.1.1",
|
||||
"PRODUCTION": "24.1.1"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "23.2.0",
|
||||
"version": "24.1.2",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "23.2.0",
|
||||
"version": "24.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -39,17 +39,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "23.0.0",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/downloader": "24.0.0",
|
||||
"@expo/eas-build-job": "24.0.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "23.1.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": "23.1.0",
|
||||
"@expo/template-file": "23.0.0",
|
||||
"@expo/turtle-spawn": "23.1.0",
|
||||
"@expo/steps": "24.0.0",
|
||||
"@expo/template-file": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -11,6 +11,19 @@ jest.mock('../utils/stepMetrics');
|
||||
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
|
||||
|
||||
describe(CustomBuildContext, () => {
|
||||
it('copies the MCP server URL from the build context', () => {
|
||||
const ctx = new BuildContext(createTestIosJob(), {
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
});
|
||||
|
||||
expect(new CustomBuildContext(ctx).mcpServerUrl).toBe('ws://localhost:8787');
|
||||
});
|
||||
|
||||
it('should not lose workflowInterpolationContext', () => {
|
||||
const contextUploadArtifact = jest.fn();
|
||||
const ctx = new BuildContext(
|
||||
|
||||
@@ -4,9 +4,11 @@ import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
|
||||
import { runGenericJobAsync } from '../generic';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
|
||||
import { stopLocalEgressResourcesAsync } from '../steps/utils/localEgress';
|
||||
|
||||
jest.mock('../customBuildContext');
|
||||
jest.mock('../utils/outputs');
|
||||
jest.mock('../steps/utils/localEgress', () => ({ stopLocalEgressResourcesAsync: jest.fn() }));
|
||||
jest.mock('../common/projectSources');
|
||||
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
|
||||
jest.mock('../steps/easFunctionGroups', () => ({
|
||||
@@ -58,6 +60,25 @@ describe(runGenericJobAsync, () => {
|
||||
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each(['success', 'failure'])(
|
||||
'releases local egress after workflow %s, before uploading outputs',
|
||||
async outcome => {
|
||||
const executeAsync =
|
||||
outcome === 'success'
|
||||
? jest.fn().mockResolvedValue(undefined)
|
||||
: jest.fn().mockRejectedValue(new Error(outcome));
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue({ executeAsync }),
|
||||
}));
|
||||
const { runResult } = await runGenericJobAsync(mockCtx);
|
||||
expect(runResult.ok).toBe(outcome === 'success');
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledWith(mockCtx.logger);
|
||||
expect(jest.mocked(stopLocalEgressResourcesAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockUploadJobOutputsToWwwAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
ErrorCode,
|
||||
Job,
|
||||
Platform,
|
||||
SystemError,
|
||||
Workflow,
|
||||
} from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -45,7 +47,7 @@ describe('projectSources', () => {
|
||||
expect(logger.info).toHaveBeenCalledWith('Normalizing project source permissions');
|
||||
});
|
||||
|
||||
it('should use the refreshed repository URL', async () => {
|
||||
it.each([false, true])('uses the correct repository URL (local: %s)', async isLocal => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
@@ -76,7 +78,7 @@ describe('projectSources', () => {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
EAS_BUILD_RUNNER: isLocal ? 'local-build-plugin' : 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
@@ -105,108 +107,88 @@ describe('projectSources', () => {
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
repositoryUrl: isLocal
|
||||
? 'https://x-access-token:[email protected]/expo/eas-cli.git'
|
||||
: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
if (isLocal) {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should fallback to the original repository URL if the refresh fails', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
it.each(['http', 'network', 'json', 'schema'])(
|
||||
'throws a system error if refresh fails (%s)',
|
||||
async failure => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
type: Workflow.MANAGED,
|
||||
mode: BuildMode.BUILD,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
platform: Platform.IOS,
|
||||
secrets: {
|
||||
robotAccessToken,
|
||||
environmentSecrets: [],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
type: Workflow.MANAGED,
|
||||
mode: BuildMode.BUILD,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
platform: Platform.IOS,
|
||||
secrets: {
|
||||
robotAccessToken,
|
||||
environmentSecrets: [],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const cause = new Error('Source request failed');
|
||||
fetchMock.mockImplementation(async () => {
|
||||
if (failure === 'network') {
|
||||
throw cause;
|
||||
}
|
||||
return {
|
||||
ok: failure !== 'http',
|
||||
status: failure === 'http' ? 500 : 200,
|
||||
json: async () => {
|
||||
if (failure === 'json') {
|
||||
throw cause;
|
||||
}
|
||||
return { data: { repository_url: 'https://github.com/expo/eas-cli.git' } };
|
||||
},
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
if (failure === 'network') {
|
||||
await expect(result).rejects.toMatchObject({ cause });
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
text: async () => 'Failed to generate repository URL',
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
json: async () => ({
|
||||
// repositoryUrl is the right key
|
||||
repository_url: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
}),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
json: () => Promise.reject(new Error('Failed to generate repository URL')),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenCalledTimes(3);
|
||||
}, 15_000);
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
},
|
||||
15_000
|
||||
);
|
||||
|
||||
it('should retry fetching the repository URL', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
@@ -281,7 +263,7 @@ describe('projectSources', () => {
|
||||
);
|
||||
}, 15_000);
|
||||
|
||||
it(`should fallback to the original repository URL if we're missing some config`, async () => {
|
||||
it(`throws a system error if refresh configuration is missing`, async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
const logger = createMockLogger();
|
||||
@@ -319,12 +301,11 @@ describe('projectSources', () => {
|
||||
}
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Failed to refresh project archive, falling back to the original one'
|
||||
);
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
cause: new Error('EAS_BUILD_ID is not set'),
|
||||
});
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('uploadProjectMetadataAsFireAndForget', () => {
|
||||
@@ -404,6 +385,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
// Call prepareProjectSourcesAsync and don't await metadata upload
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
@@ -517,6 +502,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
const startTime = Date.now();
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
@@ -581,6 +570,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
// Should not throw even though upload will fail
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.not.toThrow();
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { ArchiveSource, ArchiveSourceSchemaZ, ArchiveSourceType, Job } from '@expo/eas-build-job';
|
||||
import {
|
||||
ArchiveSource,
|
||||
ArchiveSourceSchemaZ,
|
||||
ArchiveSourceType,
|
||||
Job,
|
||||
SystemError,
|
||||
} from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -26,13 +32,12 @@ Promise<{ handled: boolean }> {
|
||||
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
|
||||
|
||||
if (!projectArchiveResult.ok) {
|
||||
ctx.logger.error(
|
||||
{ err: projectArchiveResult.reason },
|
||||
'Failed to refresh project archive, falling back to the original one'
|
||||
);
|
||||
throw new SystemError('Failed to fetch project sources. Re-run the job.', {
|
||||
cause: projectArchiveResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
projectArchive = projectArchiveResult.value ?? ctx.job.projectArchive;
|
||||
projectArchive = projectArchiveResult.value;
|
||||
}
|
||||
|
||||
switch (projectArchive.type) {
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface BuildContextOptions {
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
mcpServerUrl?: string;
|
||||
}
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
@@ -84,6 +85,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
) => void;
|
||||
public readonly skipNativeBuild?: boolean;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public artifacts: Artifacts = {};
|
||||
|
||||
private readonly _isLocal: boolean;
|
||||
@@ -112,6 +114,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
this._metadata = options.metadata;
|
||||
this.skipNativeBuild = options.skipNativeBuild;
|
||||
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
|
||||
this.mcpServerUrl = options.mcpServerUrl;
|
||||
|
||||
const environmentSecrets = this.getEnvironmentSecrets(job);
|
||||
this._env = {
|
||||
|
||||
@@ -63,6 +63,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
public readonly logger: bunyan;
|
||||
public readonly graphqlClient: Client;
|
||||
public readonly runtimeApi: BuilderRuntimeApi;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public job: TJob;
|
||||
public metadata?: Metadata;
|
||||
|
||||
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.runtimeApi = {
|
||||
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
|
||||
};
|
||||
this.mcpServerUrl = buildCtx.mcpServerUrl;
|
||||
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
|
||||
this.startTime = new Date();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { BuildContext } from './context';
|
||||
import { CustomBuildContext } from './customBuildContext';
|
||||
import { getEasFunctionGroups } from './steps/easFunctionGroups';
|
||||
import { getEasFunctions } from './steps/easFunctions';
|
||||
import { stopLocalEgressResourcesAsync } from './steps/utils/localEgress';
|
||||
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
|
||||
import { retryAsync } from './utils/retry';
|
||||
|
||||
@@ -82,7 +83,17 @@ export async function runGenericJobAsync(
|
||||
}
|
||||
});
|
||||
|
||||
const runResult = await asyncResult(workflow.executeAsync());
|
||||
const runResult = await asyncResult(
|
||||
(async () => {
|
||||
try {
|
||||
await workflow.executeAsync();
|
||||
} finally {
|
||||
// The session-owning step may never run if simulator boot/setup fails,
|
||||
// or may time out while resource acquisition is still in flight.
|
||||
await stopLocalEgressResourcesAsync(ctx.logger);
|
||||
}
|
||||
})()
|
||||
);
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
|
||||
const results = await Promise.allSettled([
|
||||
|
||||
@@ -51,6 +51,7 @@ import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAp
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
|
||||
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
@@ -103,6 +104,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
createStartLocalEgressBuildFunction(),
|
||||
createStartIosSimulatorRecordingsBuildFunction(),
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
registerLocalEgressResources,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
jest.mock('../../utils/argentArtifacts');
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/argentEvents', () => ({
|
||||
ARGENT_EVENT_LOG_FILENAME: 'events.jsonl',
|
||||
startArgentEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({
|
||||
startAppiumEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
...jest.requireActual('../../utils/localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn(),
|
||||
monitorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
getDeviceRunSessionIdOrThrow: () => 'session-id',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.test',
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
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() }),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
|
||||
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
|
||||
const fields = {
|
||||
egressUrl: handoff.url,
|
||||
egressToken: handoff.token,
|
||||
egressFingerprint: handoff.fingerprint,
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
const controllers = [
|
||||
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
|
||||
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
|
||||
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
|
||||
] as const;
|
||||
|
||||
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
|
||||
const stopPreview = jest.fn();
|
||||
const stopEgress = jest.fn();
|
||||
let lifetime: AbortSignal;
|
||||
async function run(signal?: AbortSignal): Promise<void> {
|
||||
await createFunction(ctx).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
signal,
|
||||
} as never
|
||||
);
|
||||
}
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
lifetime = registerLocalEgressResources(stopEgress);
|
||||
stopEgress.mockResolvedValue(undefined);
|
||||
stopPreview.mockResolvedValue(undefined);
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/appium-egress-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'readdir').mockResolvedValue(['tool-server.json'] as never);
|
||||
jest
|
||||
.spyOn(fs.promises, 'readFile')
|
||||
.mockResolvedValue(JSON.stringify({ port: 1234, pid: 124, token: 'controller-token' }));
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'sim' }] as never);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
|
||||
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
stopAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
expect(lifetime.aborted).toBe(false);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
|
||||
await run();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
logger,
|
||||
remoteConfig: expect.objectContaining({
|
||||
...fields,
|
||||
[controllerField]: expect.any(String),
|
||||
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
|
||||
}),
|
||||
});
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
|
||||
port: handoff.port,
|
||||
env,
|
||||
logger,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('leaves ordinary remote config unchanged and does not monitor', async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
stopEgress.mockClear();
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
await run();
|
||||
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
|
||||
expect(remoteConfig).toHaveProperty(controllerField);
|
||||
expect(Object.keys(remoteConfig).some(key => key.startsWith('egress'))).toBe(false);
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['startup', 'report', 'wait', 'teardown'])(
|
||||
'releases egress after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'startup') {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
|
||||
} else {
|
||||
stopPreview.mockRejectedValue(error);
|
||||
}
|
||||
await expect(run()).rejects.toThrow(error);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
|
||||
phase === 'startup' || phase === 'report' ? 0 : 1
|
||||
);
|
||||
}
|
||||
);
|
||||
it('passes cancellation to the monitor and releases the lifetime', async () => {
|
||||
const abort = new AbortController();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
|
||||
await run(abort.signal);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: abort.signal })
|
||||
);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('does not report or start a monitor after cancellation', async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
await expect(run(abort.signal)).rejects.toThrow();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,31 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
it('passes profile and LCD inputs to emulator creation', async () => {
|
||||
const systemImagePackage = 'system-images;android-35;default;x86_64';
|
||||
await createStep({
|
||||
device_identifier: 'medium_phone',
|
||||
system_image_package: systemImagePackage,
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 262,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('sdkmanager', [systemImagePackage], expect.anything());
|
||||
expect(mockedAndroidUtils.createAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'medium_phone',
|
||||
systemImagePackage,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.createAsync.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('retries base emulator startup with increasing readiness timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-1111'))
|
||||
|
||||
+4
-1
@@ -142,7 +142,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
const enableEventLogIndex = spawnCalls.findIndex(
|
||||
([command, args]) =>
|
||||
command === 'bunx' && Array.isArray(args) && args.includes('tool-server-event-log')
|
||||
command === 'bun' &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === 'x' &&
|
||||
args.includes('tool-server-event-log')
|
||||
);
|
||||
expect(enableEventLogIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[enableEventLogIndex]).toBeLessThan(
|
||||
|
||||
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting bunx resolve it.')
|
||||
expect.stringContaining('Continuing and letting bun x resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepContext } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
configureSystemProxyAsync,
|
||||
downloadChiselAsync,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import { findAvailablePortAsync, startNgrokTunnelAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartLocalEgressBuildFunction } from '../startLocalEgress';
|
||||
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
...jest.requireActual('../../utils/localEgress'),
|
||||
configureSystemProxyAsync: jest.fn(),
|
||||
downloadChiselAsync: jest.fn(),
|
||||
startChiselServerAsync: jest.fn(),
|
||||
writeLocalEgressHandoffAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
findAvailablePortAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.com',
|
||||
}));
|
||||
|
||||
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() };
|
||||
|
||||
async function start(signal?: AbortSignal): Promise<void> {
|
||||
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
|
||||
env: {},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
describe('local egress acquisition', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
server.stopAsync.mockResolvedValue(undefined);
|
||||
tunnel.stopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
|
||||
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
|
||||
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
|
||||
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
|
||||
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('stops the detached server and removes credentials when ngrok acquisition fails', async () => {
|
||||
jest.mocked(startNgrokTunnelAsync).mockRejectedValue(new Error('listener quota'));
|
||||
await expect(start()).rejects.toThrow('listener quota');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('attempts every release even when one resource cannot stop', async () => {
|
||||
await start();
|
||||
tunnel.stopAsync.mockRejectedValue(new Error('ngrok close failed'));
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith(LOCAL_EGRESS_HANDOFF_PATH, { force: true });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not stop a local egress resource.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'disposes late listeners without delaying cleanup (close fails: %s)',
|
||||
async closeFails => {
|
||||
let finishTunnel!: (value: typeof tunnel) => void;
|
||||
jest.mocked(startNgrokTunnelAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishTunnel = resolve;
|
||||
})
|
||||
);
|
||||
const started = start();
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
const stopped = stopLocalEgressResourcesAsync(logger);
|
||||
await Promise.all([stopped, rejected]);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.stopAsync).not.toHaveBeenCalled();
|
||||
if (closeFails) {
|
||||
tunnel.stopAsync.mockRejectedValue(new Error('late close failed'));
|
||||
}
|
||||
finishTunnel(tunnel);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
if (closeFails) {
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not release a cancelled local egress acquisition.'
|
||||
);
|
||||
}
|
||||
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['job', 'step'])('finishes %s cancellation when ngrok never settles', async source => {
|
||||
jest.mocked(startNgrokTunnelAsync).mockReturnValue(new Promise(() => {}));
|
||||
const controller = new AbortController();
|
||||
const started = start(controller.signal);
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
if (source === 'step') {
|
||||
controller.abort(new Error('cancelled'));
|
||||
}
|
||||
const stopped = source === 'job' ? stopLocalEgressResourcesAsync(logger) : Promise.resolve();
|
||||
const outcome = await Promise.race([
|
||||
Promise.all([stopped, rejected]).then(() => 'stopped'),
|
||||
new Promise(resolve => setImmediate(() => resolve('still waiting'))),
|
||||
]);
|
||||
expect(outcome).toBe('stopped');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles ngrok rejection after finalization without an unhandled rejection', async () => {
|
||||
let rejectTunnel!: (error: Error) => void;
|
||||
jest.mocked(startNgrokTunnelAsync).mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectTunnel = reject;
|
||||
})
|
||||
);
|
||||
const started = start();
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await Promise.all([stopLocalEgressResourcesAsync(logger), rejected]);
|
||||
rejectTunnel(new Error('late connection failure'));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch a server after a download finishes following cancellation', async () => {
|
||||
let finishDownload!: (value: string) => void;
|
||||
jest.mocked(downloadChiselAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishDownload = resolve;
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const started = start(controller.signal);
|
||||
const rejected = expect(started).rejects.toThrow('cancelled');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
controller.abort(new Error('cancelled'));
|
||||
await rejected;
|
||||
finishDownload('/tmp/chisel');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startChiselServerAsync).not.toHaveBeenCalled();
|
||||
expect(
|
||||
jest
|
||||
.mocked(fs.promises.rm)
|
||||
.mock.calls.filter(([target]) => target === '/tmp/egress-acquisition-test')
|
||||
).toHaveLength(2);
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
+19
@@ -52,6 +52,25 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
stopAsync,
|
||||
});
|
||||
|
||||
await runAsync(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
|
||||
@@ -13,6 +13,10 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
@@ -25,7 +29,6 @@ import {
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -66,7 +69,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
@@ -121,13 +124,16 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -173,7 +179,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
}
|
||||
await daemonProcess.stopAsync();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,21 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_width',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_height',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_density',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'count',
|
||||
required: false,
|
||||
@@ -86,6 +101,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const systemImagePackage = `${inputs.system_image_package.value}`;
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
|
||||
const lcdWidth = inputs.lcd_width.value as number | undefined;
|
||||
const lcdHeight = inputs.lcd_height.value as number | undefined;
|
||||
const lcdDensity = inputs.lcd_density.value as number | undefined;
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
@@ -129,6 +147,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
lcdWidth: lcdWidth ?? null,
|
||||
lcdHeight: lcdHeight ?? null,
|
||||
lcdDensity: lcdDensity ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,10 @@ import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
@@ -28,7 +32,6 @@ import {
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -62,7 +65,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
@@ -138,7 +141,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
@@ -149,6 +154,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -177,7 +183,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
@@ -28,7 +32,6 @@ import {
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -79,7 +82,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
@@ -112,24 +115,30 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
|
||||
'bun',
|
||||
[
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
'bun',
|
||||
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
|
||||
// Keep Argent itself in foreground mode under the detached bunx process. This preserves
|
||||
// the bunx -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
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 argentServer = spawnDetached({
|
||||
command: 'bunx',
|
||||
command: 'bun',
|
||||
args: [
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
@@ -209,13 +218,16 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -252,7 +264,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
}
|
||||
await argentServer.stopAsync();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -294,7 +306,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 bunx resolve it.`
|
||||
`Continuing and letting bun x resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
LOCAL_EGRESS_PROXY_PORT,
|
||||
configureSystemProxyAsync,
|
||||
createChiselAuthfileContents,
|
||||
downloadChiselAsync,
|
||||
generateEgressCredentials,
|
||||
registerLocalEgressResources,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../utils/localEgress';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type NgrokTunnelHandle,
|
||||
findAvailablePortAsync,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
startNgrokTunnelAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
/**
|
||||
* The downloader and ngrok SDK do not accept an AbortSignal. Stop awaiting them
|
||||
* on cancellation, while retaining handlers that dispose any eventual result.
|
||||
*/
|
||||
function awaitLocalEgressAcquisitionAsync<T>(
|
||||
pending: Promise<T>,
|
||||
signal: AbortSignal,
|
||||
disposeAbandonedAsync: (result?: T) => Promise<void>,
|
||||
logger: bunyan
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let abandoned = false;
|
||||
const abort = (): void => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
abandoned = true;
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
void pending
|
||||
.then(
|
||||
async result => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (abandoned) {
|
||||
await disposeAbandonedAsync(result);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
},
|
||||
async error => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (abandoned) {
|
||||
await disposeAbandonedAsync();
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(err =>
|
||||
logger.warn({ err }, 'Could not release a cancelled local egress acquisition.')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
|
||||
* Must run before `eas/start_ios_simulator`: the simulator reads the system proxy
|
||||
* at boot. Nothing else on the host changes, so requests from libraries that
|
||||
* bypass the system proxy are not covered; the shared session monitor reports
|
||||
* them. The shared session cleanup releases the resources started here when
|
||||
* the session ends, with a job finalizer as a fallback.
|
||||
*/
|
||||
export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_local_egress',
|
||||
name: 'Start local egress',
|
||||
__metricsId: 'eas/start_local_egress',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env, signal }) => {
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
let workDir: string | undefined;
|
||||
let server: DetachedProcessHandle | undefined;
|
||||
let tunnel: NgrokTunnelHandle | undefined;
|
||||
let finishSetup!: () => void;
|
||||
const setupFinished = new Promise<void>(resolve => {
|
||||
finishSetup = resolve;
|
||||
});
|
||||
// Register before acquisition. Cancellation ends setup promptly; pending
|
||||
// SDK calls retain their own late-result disposal handlers.
|
||||
const lifetimeSignal = registerLocalEgressResources(async () => {
|
||||
await setupFinished;
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => tunnel?.stopAsync()),
|
||||
Promise.resolve().then(() => server?.stopAsync()),
|
||||
]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, 'Could not stop a local egress resource.');
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
fs.promises.rm(LOCAL_EGRESS_HANDOFF_PATH, { force: true }),
|
||||
workDir ? fs.promises.rm(workDir, { recursive: true, force: true }) : undefined,
|
||||
]);
|
||||
});
|
||||
const startupSignal = signal ? AbortSignal.any([signal, lifetimeSignal]) : lifetimeSignal;
|
||||
|
||||
try {
|
||||
startupSignal.throwIfAborted();
|
||||
workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-local-egress-'));
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(`Downloading the reverse tunnel server (chisel ${CHISEL_VERSION}).`);
|
||||
const downloadDir = workDir;
|
||||
const chiselPath = await awaitLocalEgressAcquisitionAsync(
|
||||
downloadChiselAsync({ destinationDir: downloadDir, logger }),
|
||||
startupSignal,
|
||||
async () => await fs.promises.rm(downloadDir, { recursive: true, force: true }),
|
||||
logger
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const credentials = generateEgressCredentials();
|
||||
const authfilePath = path.join(workDir, 'authfile.json');
|
||||
await fs.promises.writeFile(
|
||||
authfilePath,
|
||||
createChiselAuthfileContents({ ...credentials, port: LOCAL_EGRESS_PROXY_PORT }),
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const controlPort = await findAvailablePortAsync();
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(`Starting the reverse tunnel server on 127.0.0.1:${controlPort}.`);
|
||||
const started = await startChiselServerAsync({
|
||||
chiselPath,
|
||||
controlPort,
|
||||
authfilePath,
|
||||
env,
|
||||
signal: startupSignal,
|
||||
});
|
||||
server = started.process;
|
||||
startupSignal.throwIfAborted();
|
||||
tunnel = await awaitLocalEgressAcquisitionAsync(
|
||||
startNgrokTunnelAsync({
|
||||
port: controlPort,
|
||||
subdomainPrefix: 'egress',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger,
|
||||
}),
|
||||
startupSignal,
|
||||
async listener => await listener?.stopAsync(),
|
||||
logger
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const { service } = await configureSystemProxyAsync({
|
||||
env,
|
||||
logger,
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
signal: startupSignal,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
await writeLocalEgressHandoffAsync({
|
||||
url: tunnel.url,
|
||||
token: credentials.password,
|
||||
fingerprint: started.fingerprint,
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(
|
||||
`Local egress is configured on network service "${service}". HTTP(S) and WebSocket ` +
|
||||
'requests that honor the system proxy (WebKit, URLSession and other CFNetwork clients) ' +
|
||||
'fail until the EAS CLI egress client connects, then exit from that machine. Requests ' +
|
||||
'from libraries that bypass the system proxy are not covered and exit from this worker. ' +
|
||||
'Connection sampling may report these bypasses, but can miss short connections and unconnected UDP traffic.'
|
||||
);
|
||||
} catch (error) {
|
||||
finishSetup();
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
throw error;
|
||||
} finally {
|
||||
finishSetup();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -37,7 +40,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
@@ -61,10 +64,15 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
logger.info(`Preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
try {
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { previewUrl: webPreview.previewUrl },
|
||||
remoteConfig: {
|
||||
previewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -78,6 +86,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
} finally {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_PROXY_PORT,
|
||||
buildEgressRemoteConfigFields,
|
||||
buildNetworksetupProxyArgs,
|
||||
collectSimulatorProcessIds,
|
||||
createChiselAuthfileContents,
|
||||
getChiselAssetName,
|
||||
getChiselDownloadUrl,
|
||||
parseChiselFingerprint,
|
||||
parseDefaultRouteInterface,
|
||||
parseDirectSimulatorConnections,
|
||||
parseExitIpResponse,
|
||||
parseNetworkServiceNameForDevice,
|
||||
readLocalEgressHandoffAsync,
|
||||
registerLocalEgressResources,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../localEgress';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({ spawnDetached: jest.fn() }));
|
||||
|
||||
describe(getChiselAssetName, () => {
|
||||
it.each([
|
||||
['darwin', 'arm64', `chisel_${CHISEL_VERSION}_darwin_arm64.gz`],
|
||||
['darwin', 'x64', `chisel_${CHISEL_VERSION}_darwin_amd64.gz`],
|
||||
['linux', 'x64', `chisel_${CHISEL_VERSION}_linux_amd64.gz`],
|
||||
['linux', 'arm64', `chisel_${CHISEL_VERSION}_linux_arm64.gz`],
|
||||
] as const)('maps %s/%s to %s', (platform, arch, expected) => {
|
||||
expect(getChiselAssetName({ platform, arch })).toBe(expected);
|
||||
});
|
||||
|
||||
it('rejects platforms without a pinned binary', () => {
|
||||
expect(() => getChiselAssetName({ platform: 'win32', arch: 'x64' })).toThrow(
|
||||
'Local egress is not supported on win32/x64'
|
||||
);
|
||||
expect(() => getChiselAssetName({ platform: 'linux', arch: 'ia32' })).toThrow(
|
||||
'Local egress is not supported on linux/ia32'
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads from the pinned GitHub release', () => {
|
||||
expect(getChiselDownloadUrl(`chisel_${CHISEL_VERSION}_darwin_arm64.gz`)).toBe(
|
||||
`https://github.com/jpillora/chisel/releases/download/v${CHISEL_VERSION}/chisel_${CHISEL_VERSION}_darwin_arm64.gz`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createChiselAuthfileContents, () => {
|
||||
it('allows reverse remotes on the loopback proxy port and unprivileged loopback ports only', () => {
|
||||
const contents = createChiselAuthfileContents({ user: 'eas', password: 'pw', port: 8899 });
|
||||
const parsed = JSON.parse(contents) as Record<string, string[]>;
|
||||
expect(parsed['eas:pw']).toHaveLength(2);
|
||||
expect(parsed['eas:pw'][0]).toBe('^R:127\\.0\\.0\\.1:8899$');
|
||||
|
||||
const patterns = parsed['eas:pw'].map(source => new RegExp(source));
|
||||
const allowed = (remote: string): boolean => patterns.some(pattern => pattern.test(remote));
|
||||
expect(allowed('R:127.0.0.1:8899')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:1024')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:3000')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:8081')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:65535')).toBe(true);
|
||||
// Privileged and out-of-range ports, other interfaces, and forward remotes stay denied.
|
||||
expect(allowed('R:127.0.0.1:1023')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:80')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:0')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:65536')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:88990')).toBe(false);
|
||||
expect(allowed('R:0.0.0.0:8899')).toBe(false);
|
||||
expect(allowed('R:0.0.0.0:3000')).toBe(false);
|
||||
expect(allowed('R:[::1]:3000')).toBe(false);
|
||||
expect(allowed('R:127x0x0x1:8899')).toBe(false);
|
||||
expect(allowed('R:socks')).toBe(false);
|
||||
expect(allowed('example.com:443')).toBe(false);
|
||||
expect(allowed('127.0.0.1:3000')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseChiselFingerprint, () => {
|
||||
it('extracts the fingerprint chisel prints at startup', () => {
|
||||
const output =
|
||||
'2026/09/07 22:00:00 server: Fingerprint eZm5d2ZQ+Zb2aS0lJm7A5GkWm9G9ZqnPz2W0i2Y6Q1c=\n' +
|
||||
'2026/09/07 22:00:00 server: Reverse tunnelling enabled\n' +
|
||||
'2026/09/07 22:00:00 server: Listening on http://127.0.0.1:52001\n';
|
||||
expect(parseChiselFingerprint(output)).toBe('eZm5d2ZQ+Zb2aS0lJm7A5GkWm9G9ZqnPz2W0i2Y6Q1c=');
|
||||
});
|
||||
|
||||
it('returns null before the fingerprint line appears', () => {
|
||||
expect(parseChiselFingerprint('')).toBeNull();
|
||||
expect(parseChiselFingerprint('2026/09/07 22:00:00 server: starting')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseDefaultRouteInterface, () => {
|
||||
it('reads the interface from `route -n get default`', () => {
|
||||
const output =
|
||||
' route to: default\n' +
|
||||
'destination: default\n' +
|
||||
' mask: default\n' +
|
||||
' gateway: 172.20.10.1\n' +
|
||||
' interface: en0\n' +
|
||||
' flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>\n';
|
||||
expect(parseDefaultRouteInterface(output)).toBe('en0');
|
||||
});
|
||||
|
||||
it('returns null when there is no default route', () => {
|
||||
expect(
|
||||
parseDefaultRouteInterface('route: writing to routing socket: not in table\n')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseNetworkServiceNameForDevice, () => {
|
||||
const listOutput =
|
||||
'An asterisk (*) denotes that a network service is disabled.\n' +
|
||||
'(1) Thunderbolt Ethernet Slot 0\n' +
|
||||
'(Hardware Port: Thunderbolt Ethernet Slot 0, Device: en8)\n' +
|
||||
'\n' +
|
||||
'(2) Thunderbolt Bridge\n' +
|
||||
'(Hardware Port: Thunderbolt Bridge, Device: bridge0)\n' +
|
||||
'\n' +
|
||||
'(3) Wi-Fi\n' +
|
||||
'(Hardware Port: Wi-Fi, Device: en0)\n' +
|
||||
'\n' +
|
||||
'(4) iPhone USB\n' +
|
||||
'(Hardware Port: iPhone USB, Device: en9)\n';
|
||||
|
||||
it('returns the service whose device matches', () => {
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'en0')).toBe('Wi-Fi');
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'en8')).toBe('Thunderbolt Ethernet Slot 0');
|
||||
});
|
||||
|
||||
it('returns null for an unknown device', () => {
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'utun3')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildNetworksetupProxyArgs, () => {
|
||||
it('sets both the HTTP and HTTPS proxy to the loopback egress port', () => {
|
||||
expect(
|
||||
buildNetworksetupProxyArgs({ service: 'Ethernet', host: '127.0.0.1', port: 8899 })
|
||||
).toEqual([
|
||||
['-setwebproxy', 'Ethernet', '127.0.0.1', '8899'],
|
||||
['-setsecurewebproxy', 'Ethernet', '127.0.0.1', '8899'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(collectSimulatorProcessIds, () => {
|
||||
const runtimeRoot =
|
||||
'/Library/Developer/CoreSimulator/Volumes/iOS_23F77/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.5.simruntime/Contents/Resources/RuntimeRoot';
|
||||
const psOutput =
|
||||
' 1 0 /sbin/launchd\n' +
|
||||
' 501 1 /usr/local/bin/node\n' +
|
||||
` 600 1 ${runtimeRoot}/sbin/launchd_sim\n` +
|
||||
' 610 600 /Users/expo/Library/Developer/CoreSimulator/Devices/ABC/data/Containers/Bundle/Application/DEF/App.app/App\n' +
|
||||
` 611 600 ${runtimeRoot}/System/Library/ExtensionKit/Extensions/NetworkingExtension.appex/com.apple.WebKit.Networking\n` +
|
||||
' 620 610 /usr/bin/helper\n' +
|
||||
' 700 1 /usr/sbin/lsof\n';
|
||||
|
||||
it('returns every descendant of launchd_sim and nothing else', () => {
|
||||
expect(collectSimulatorProcessIds(psOutput).sort((a, b) => a - b)).toEqual([610, 611, 620]);
|
||||
});
|
||||
|
||||
it('returns nothing when no simulator is running', () => {
|
||||
expect(
|
||||
collectSimulatorProcessIds(' 1 0 /sbin/launchd\n 501 1 /usr/local/bin/node\n')
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseDirectSimulatorConnections, () => {
|
||||
const lsofOutput = [
|
||||
'p610',
|
||||
'cApp',
|
||||
'f10',
|
||||
'PTCP',
|
||||
'n127.0.0.1:52344->127.0.0.1:8899',
|
||||
'TST=ESTABLISHED',
|
||||
'f11',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52345->93.184.216.34:443',
|
||||
'TST=ESTABLISHED',
|
||||
'f12',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52346->104.16.0.1:443',
|
||||
'TST=SYN_SENT',
|
||||
'f13',
|
||||
'PTCP',
|
||||
'n*:8080',
|
||||
'TST=LISTEN',
|
||||
'f14',
|
||||
'PUDP',
|
||||
'n*:5353',
|
||||
'f15',
|
||||
'PUDP',
|
||||
'n192.168.64.2:60000->1.1.1.1:443',
|
||||
'f16',
|
||||
'PTCP',
|
||||
'n[::1]:52347->[::1]:8899',
|
||||
'TST=ESTABLISHED',
|
||||
'f17',
|
||||
'PTCP',
|
||||
'n[fd00::2]:52348->[2606:4700:4700::1111]:443',
|
||||
'TST=ESTABLISHED',
|
||||
'p700',
|
||||
'cnode',
|
||||
'f20',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52349->93.184.216.34:443',
|
||||
'TST=ESTABLISHED',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
it('reports simulator connections to non-loopback peers only', () => {
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([610]))).toEqual([
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '93.184.216.34:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '104.16.0.1:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'UDP', remote: '1.1.1.1:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '[2606:4700:4700::1111]:443' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores processes outside the simulator', () => {
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([700]))).toEqual([
|
||||
{ pid: 700, command: 'node', protocol: 'TCP', remote: '93.184.216.34:443' },
|
||||
]);
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([999]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty listing', () => {
|
||||
expect(parseDirectSimulatorConnections('', new Set([610]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildEgressRemoteConfigFields, () => {
|
||||
it('returns nothing without a handoff', () => {
|
||||
expect(buildEgressRemoteConfigFields(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('maps the handoff to the remoteConfig field names', () => {
|
||||
expect(
|
||||
buildEgressRemoteConfigFields({
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp=',
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
})
|
||||
).toEqual({
|
||||
egressUrl: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
egressToken: 'pw',
|
||||
egressFingerprint: 'fp=',
|
||||
egressPort: LOCAL_EGRESS_PROXY_PORT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseExitIpResponse, () => {
|
||||
it('reads the ip field', () => {
|
||||
expect(parseExitIpResponse('{"ip":"64.114.211.44"}')).toBe('64.114.211.44');
|
||||
});
|
||||
|
||||
it('rejects responses without an ip', () => {
|
||||
expect(() => parseExitIpResponse('{"error":"rate limited"}')).toThrow(
|
||||
'Unexpected exit IP response'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress handoff', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'local-egress-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips through the handoff file', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
const handoff = {
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp=',
|
||||
port: 8899,
|
||||
};
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(readLocalEgressHandoffAsync(handoffPath)).resolves.toEqual(handoff);
|
||||
});
|
||||
|
||||
it('returns null when no egress step ran', async () => {
|
||||
await expect(
|
||||
readLocalEgressHandoffAsync(path.join(tempDir, 'missing.json'))
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a malformed handoff', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify({ url: 'x' }), 'utf8');
|
||||
await expect(readLocalEgressHandoffAsync(handoffPath)).rejects.toThrow('malformed');
|
||||
});
|
||||
});
|
||||
|
||||
describe(startChiselServerAsync, () => {
|
||||
const options = {
|
||||
chiselPath: '/tmp/chisel',
|
||||
controlPort: 52001,
|
||||
authfilePath: '/tmp/authfile.json',
|
||||
env: { PATH: '/bin', AUTH: 'unexpected:password' },
|
||||
};
|
||||
let output: string;
|
||||
let stopAsync: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
output = 'server: Fingerprint pinned-key=\n';
|
||||
stopAsync = jest.fn().mockResolvedValue(undefined);
|
||||
jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 12345,
|
||||
getOutput: () => output,
|
||||
stopAsync,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('waits for the child to bind its exact port and clears inherited AUTH', async () => {
|
||||
let resolved = false;
|
||||
const started = startChiselServerAsync(options).then(result => {
|
||||
resolved = true;
|
||||
return result;
|
||||
});
|
||||
output += 'server: Listening on http://127.0.0.1:520010\n';
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
expect(resolved).toBe(false);
|
||||
output += 'server: Listening on http://127.0.0.1:52001\n';
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await expect(started).resolves.toMatchObject({ fingerprint: 'pinned-key=' });
|
||||
expect(spawnDetached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ env: { PATH: '/bin', AUTH: '' } })
|
||||
);
|
||||
expect(options.env.AUTH).toBe('unexpected:password');
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects and stops a child that prints its fingerprint then fails to bind', async () => {
|
||||
const started = startChiselServerAsync(options);
|
||||
const rejected = expect(started).rejects.toThrow('address already in use');
|
||||
output += 'listen tcp 127.0.0.1:52001: bind: address already in use\n';
|
||||
jest.mocked(process.kill).mockImplementation(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops the child when startup is cancelled', async () => {
|
||||
const controller = new AbortController();
|
||||
const started = startChiselServerAsync({ ...options, signal: controller.signal });
|
||||
const rejected = expect(started).rejects.toThrow('cancelled');
|
||||
controller.abort(new Error('cancelled'));
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress resource lifetime', () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
it('aborts the lifetime and awaits the same cleanup for concurrent callers', async () => {
|
||||
let finish!: () => void;
|
||||
const cleanup = jest.fn(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
const signal = registerLocalEgressResources(cleanup);
|
||||
const first = stopLocalEgressResourcesAsync(logger);
|
||||
const second = stopLocalEgressResourcesAsync(logger);
|
||||
expect(signal.aborted).toBe(true);
|
||||
await Promise.resolve();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
let complete = false;
|
||||
void second.then(() => {
|
||||
complete = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(complete).toBe(false);
|
||||
finish();
|
||||
await Promise.all([first, second]);
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports cleanup failure without masking the job result', async () => {
|
||||
const error = new Error('cleanup failed');
|
||||
registerLocalEgressResources(async () => {
|
||||
throw error;
|
||||
});
|
||||
await expect(stopLocalEgressResourcesAsync(logger)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error },
|
||||
'Could not stop a local egress resource.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
@@ -33,6 +34,11 @@ jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
|
||||
jest.mock('../serveSimMetricsRecorder', () => {
|
||||
const actual = jest.requireActual('../serveSimMetricsRecorder');
|
||||
return { ...actual, readServeSimServersAsync: jest.fn(actual.readServeSimServersAsync) };
|
||||
});
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
@@ -130,6 +136,7 @@ describe(createServeSimArgs, () => {
|
||||
'4321',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
@@ -329,6 +336,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1', token: 'tok-1' }]);
|
||||
|
||||
const spawnPromise = Object.assign(Promise.resolve(undefined), {
|
||||
child: {
|
||||
@@ -417,6 +427,63 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('carries the serve-sim session token for Darwin', 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,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.previewUrl).toBe('https://preview.example.test');
|
||||
});
|
||||
|
||||
// serve-sim is always launched with --require-token, so a missing token means it is running
|
||||
// ungated on a public tunnel. Failing beats handing out a preview that is dead or unprotected.
|
||||
it('fails for Darwin when serve-sim reports no token', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);
|
||||
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
})
|
||||
).rejects.toThrow(/wrote no session token/);
|
||||
});
|
||||
|
||||
// expo-device-hub mints no token, so the Android preview must not require one.
|
||||
it('starts for Linux without a token, and does not gate expo-device-hub', async () => {
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(preview.previewToken).toBeUndefined();
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(args).not.toContain('--require-token');
|
||||
});
|
||||
|
||||
it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
|
||||
const packageVersion = '4.5.6';
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -101,6 +101,14 @@ describe(readServeSimServersAsync, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes the session token when the state file carries one', async () => {
|
||||
await writeServerStateAsync('E', { device: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' });
|
||||
|
||||
expect(await readServeSimServersAsync(stateDir)).toEqual([
|
||||
{ udid: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] when the state dir does not exist', async () => {
|
||||
expect(await readServeSimServersAsync(path.join(workDir, 'nope'))).toEqual([]);
|
||||
});
|
||||
@@ -120,6 +128,20 @@ describe(streamServeSimMetricsToFileAsync, () => {
|
||||
expect(String(jest.mocked(fetch).mock.calls[0][0])).toBe('https://sim.example/metrics');
|
||||
});
|
||||
|
||||
it('sends the session token as a bearer header when given one', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(sseResponse());
|
||||
await streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl: 'https://sim.example',
|
||||
serveSimToken: 'tok-abc',
|
||||
filePath: path.join(workDir, 'auth.ndjson'),
|
||||
signal: new AbortController().signal,
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
expect(jest.mocked(fetch).mock.calls[0][1]?.headers).toMatchObject({
|
||||
Authorization: 'Bearer tok-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nothing on a non-ok response', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(new Response('nope', { status: 502 }));
|
||||
const filePath = path.join(workDir, 'empty.ndjson');
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
import { type DetachedProcessHandle, spawnDetached } from './remoteDeviceRunSession';
|
||||
|
||||
/**
|
||||
* Local egress provides an HTTP(S) proxy through the machine running the EAS CLI.
|
||||
* Requests that use this proxy exit through that machine's network.
|
||||
*
|
||||
* Worker side: a chisel server in reverse mode listens on loopback and is exposed
|
||||
* through the session's ngrok domain. When the CLI's egress client connects, chisel
|
||||
* opens LOCAL_EGRESS_PROXY_PORT on this host and forwards every connection to the
|
||||
* HTTP proxy the CLI runs. The macOS system proxy points at that port. Until the
|
||||
* client connects, nothing listens on the port and proxied requests fail.
|
||||
*
|
||||
* Contract: HTTP(S) and WebSocket requests that honor the system proxy (WebKit,
|
||||
* URLSession and other CFNetwork clients) exit from the egress client's network
|
||||
* and fail while the client is disconnected. Requests from libraries that bypass
|
||||
* the system proxy are not covered and exit from this host. Nothing here enforces
|
||||
* routing; the session monitor reports such direct connections instead.
|
||||
*/
|
||||
|
||||
export const LOCAL_EGRESS_PROXY_HOST = '127.0.0.1';
|
||||
export const LOCAL_EGRESS_PROXY_PORT = 8899;
|
||||
export const LOCAL_EGRESS_USERNAME = 'eas';
|
||||
export const LOCAL_EGRESS_HANDOFF_PATH = path.join(os.tmpdir(), 'eas-simulator-local-egress.json');
|
||||
|
||||
export const CHISEL_VERSION = '1.12.0';
|
||||
// sha256 of the release .gz assets, cross-checked against chisel_1.12.0_checksums.txt.
|
||||
const CHISEL_SHA256_BY_ASSET: Record<string, string> = {
|
||||
'chisel_1.12.0_darwin_arm64.gz':
|
||||
'707a4b932eea214765146504a0df246cefc415b4297af65a80dd67cf69ba85a9',
|
||||
'chisel_1.12.0_darwin_amd64.gz':
|
||||
'4aeae36c867f11c8e8c3f2b913a0e063ea3c6d29e1c14a52ed2e6eef8cfc4395',
|
||||
'chisel_1.12.0_linux_amd64.gz':
|
||||
'f3f180f1d93aa72cce4e6386f98cc06569a0146fbd65eb4423cf83e6434bcfe6',
|
||||
'chisel_1.12.0_linux_arm64.gz':
|
||||
'2ec6152cd2c74fe0146d4d79e4e7aa174521368c56e433d55e023a92ea404ec3',
|
||||
};
|
||||
|
||||
const CHISEL_STARTUP_TIMEOUT_MS = 15_000;
|
||||
const EGRESS_MONITOR_INTERVAL_MS = 2_000;
|
||||
const EGRESS_ESCAPE_SCAN_INTERVAL_MS = 5_000;
|
||||
const EGRESS_ESCAPE_LOG_LIMIT = 50;
|
||||
|
||||
export type LocalEgressHandoff = {
|
||||
/** Public URL of the reverse tunnel server, reachable through ngrok. */
|
||||
url: string;
|
||||
/** Secret the client presents to the tunnel server, paired with LOCAL_EGRESS_USERNAME. */
|
||||
token: string;
|
||||
/** Fingerprint of the tunnel server key, for the client to pin. */
|
||||
fingerprint: string;
|
||||
/** Loopback port on this host that the client must serve. */
|
||||
port: number;
|
||||
};
|
||||
|
||||
export function getChiselAssetName({
|
||||
platform,
|
||||
arch,
|
||||
}: {
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
}): string {
|
||||
const os = platform === 'darwin' ? 'darwin' : platform === 'linux' ? 'linux' : null;
|
||||
const cpu = arch === 'arm64' ? 'arm64' : arch === 'x64' ? 'amd64' : null;
|
||||
if (!os || !cpu) {
|
||||
throw new SystemError(
|
||||
`Local egress is not supported on ${platform}/${arch}. The reverse tunnel binary is available for macOS and Linux on arm64 and x64.`
|
||||
);
|
||||
}
|
||||
return `chisel_${CHISEL_VERSION}_${os}_${cpu}.gz`;
|
||||
}
|
||||
|
||||
export function getChiselDownloadUrl(assetName: string): string {
|
||||
return `https://github.com/jpillora/chisel/releases/download/v${CHISEL_VERSION}/${assetName}`;
|
||||
}
|
||||
|
||||
async function sha256FileAsync(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
await pipeline(fs.createReadStream(filePath), hash);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export async function downloadChiselAsync({
|
||||
destinationDir,
|
||||
logger,
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
}: {
|
||||
destinationDir: string;
|
||||
logger: bunyan;
|
||||
platform?: NodeJS.Platform;
|
||||
arch?: string;
|
||||
}): Promise<string> {
|
||||
const assetName = getChiselAssetName({ platform, arch });
|
||||
const expectedSha256 = CHISEL_SHA256_BY_ASSET[assetName];
|
||||
if (!expectedSha256) {
|
||||
throw new SystemError(`No pinned checksum for ${assetName}.`);
|
||||
}
|
||||
const url = getChiselDownloadUrl(assetName);
|
||||
const archivePath = path.join(destinationDir, assetName);
|
||||
logger.info(`Downloading ${url}.`);
|
||||
await downloadFile(url, archivePath, { retry: 3, timeout: 60_000 });
|
||||
|
||||
const actualSha256 = await sha256FileAsync(archivePath);
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new SystemError(
|
||||
`Checksum mismatch for ${assetName}: expected ${expectedSha256}, got ${actualSha256}. ` +
|
||||
'The download may be corrupted or tampered with; the session was not started.'
|
||||
);
|
||||
}
|
||||
|
||||
const binaryPath = path.join(destinationDir, 'chisel');
|
||||
await pipeline(
|
||||
fs.createReadStream(archivePath),
|
||||
zlib.createGunzip(),
|
||||
fs.createWriteStream(binaryPath)
|
||||
);
|
||||
await fs.promises.chmod(binaryPath, 0o755);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
export function generateEgressCredentials(): { user: string; password: string } {
|
||||
return { user: LOCAL_EGRESS_USERNAME, password: randomBytes(24).toString('base64url') };
|
||||
}
|
||||
|
||||
// Matches 1024-65535: every unprivileged TCP port.
|
||||
const UNPRIVILEGED_PORT_PATTERN =
|
||||
'(102[4-9]|10[3-9][0-9]|1[1-9][0-9]{2}|[2-9][0-9]{3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])';
|
||||
|
||||
/**
|
||||
* chisel authfile: one user, allowed to open reverse remotes on loopback only.
|
||||
* The proxy port carries proxied requests. Any unprivileged loopback port may be
|
||||
* forwarded too: the EAS CLI opens one per `--egress-allow localhost:<port>`
|
||||
* entry so `127.0.0.1:<port>` inside the simulator reaches that port on the
|
||||
* developer's machine, like `adb reverse`. iOS never sends loopback-literal
|
||||
* requests to the system proxy, so the proxy alone cannot serve them.
|
||||
* Reverse remotes are matched as `R:<interface>:<port>`.
|
||||
*/
|
||||
export function createChiselAuthfileContents({
|
||||
user,
|
||||
password,
|
||||
port,
|
||||
}: {
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
}): string {
|
||||
const escapedHost = LOCAL_EGRESS_PROXY_HOST.replace(/\./g, '\\.');
|
||||
return JSON.stringify({
|
||||
[`${user}:${password}`]: [
|
||||
`^R:${escapedHost}:${port}$`,
|
||||
`^R:${escapedHost}:${UNPRIVILEGED_PORT_PATTERN}$`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChiselFingerprint(output: string): string | null {
|
||||
const match = /Fingerprint\s+(\S+)/.exec(output);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startChiselServerAsync({
|
||||
chiselPath,
|
||||
controlPort,
|
||||
authfilePath,
|
||||
env,
|
||||
signal,
|
||||
}: {
|
||||
chiselPath: string;
|
||||
controlPort: number;
|
||||
authfilePath: string;
|
||||
env: BuildStepEnv;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ process: DetachedProcessHandle; fingerprint: string }> {
|
||||
const server = spawnDetached({
|
||||
command: chiselPath,
|
||||
args: [
|
||||
'server',
|
||||
'--host',
|
||||
LOCAL_EGRESS_PROXY_HOST,
|
||||
'--port',
|
||||
String(controlPort),
|
||||
'--reverse',
|
||||
'--authfile',
|
||||
authfilePath,
|
||||
],
|
||||
// AUTH adds an unrestricted user even when --authfile is supplied.
|
||||
env: { ...env, AUTH: '' },
|
||||
});
|
||||
|
||||
try {
|
||||
const deadline = Date.now() + CHISEL_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
if (server.pid === undefined || !isProcessRunning(server.pid)) {
|
||||
break;
|
||||
}
|
||||
const output = server.getOutput();
|
||||
const fingerprint = parseChiselFingerprint(output);
|
||||
// Chisel prints the fingerprint before binding. This line comes from the
|
||||
// child only after its listener succeeds, unlike a probe of a reused port.
|
||||
const listening = output
|
||||
.split('\n')
|
||||
.some(line =>
|
||||
line
|
||||
.trimEnd()
|
||||
.endsWith(`server: Listening on http://${LOCAL_EGRESS_PROXY_HOST}:${controlPort}`)
|
||||
);
|
||||
if (fingerprint && listening) {
|
||||
return { process: server, fingerprint };
|
||||
}
|
||||
await sleepAsync(250);
|
||||
}
|
||||
throw new SystemError(
|
||||
`The reverse tunnel server did not start within ${CHISEL_STARTUP_TIMEOUT_MS / 1000}s. Output:\n${
|
||||
server.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
await server.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDefaultRouteInterface(routeOutput: string): string | null {
|
||||
const match = /^\s*interface:\s*(\S+)/m.exec(routeOutput);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `networksetup -listnetworkserviceorder` prints each service as two lines:
|
||||
* "(1) Wi-Fi" followed by "(Hardware Port: Wi-Fi, Device: en0)". Return the
|
||||
* service name whose device matches.
|
||||
*/
|
||||
export function parseNetworkServiceNameForDevice(
|
||||
listOutput: string,
|
||||
device: string
|
||||
): string | null {
|
||||
const lines = listOutput.split('\n').map(line => line.trim());
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const hardwarePortMatch = /^\(Hardware Port: (.+), Device: (\S+)\)$/.exec(lines[i]);
|
||||
if (!hardwarePortMatch || hardwarePortMatch[2] !== device) {
|
||||
continue;
|
||||
}
|
||||
const serviceLineMatch = /^\(\d+\) (.+)$/.exec(lines[i - 1] ?? '');
|
||||
return serviceLineMatch?.[1] ?? hardwarePortMatch[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveActiveNetworkServiceNameAsync({
|
||||
env,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const routeResult = await spawn('route', ['-n', 'get', 'default'], { env, stdio: 'pipe' });
|
||||
const device = parseDefaultRouteInterface(routeResult.stdout);
|
||||
if (!device) {
|
||||
throw new SystemError(
|
||||
'Could not determine the default network interface of the device host, so the system proxy ' +
|
||||
'cannot be configured. Output of `route -n get default`:\n' +
|
||||
(routeResult.stdout || '<empty>')
|
||||
);
|
||||
}
|
||||
const listResult = await spawn('networksetup', ['-listnetworkserviceorder'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const service = parseNetworkServiceNameForDevice(listResult.stdout, device);
|
||||
if (!service) {
|
||||
throw new SystemError(
|
||||
`Could not find the network service for interface ${device}, so the system proxy cannot be ` +
|
||||
'configured. Output of `networksetup -listnetworkserviceorder`:\n' +
|
||||
(listResult.stdout || '<empty>')
|
||||
);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
export function buildNetworksetupProxyArgs({
|
||||
service,
|
||||
host,
|
||||
port,
|
||||
}: {
|
||||
service: string;
|
||||
host: string;
|
||||
port: number;
|
||||
}): string[][] {
|
||||
return [
|
||||
['-setwebproxy', service, host, String(port)],
|
||||
['-setsecurewebproxy', service, host, String(port)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the macOS system HTTP and HTTPS proxy at the loopback egress port. The
|
||||
* simulator reads these settings when it boots, so this must run before
|
||||
* `start_ios_simulator`. Existing bypass and automatic proxy settings are left
|
||||
* unchanged. Requests that bypass this proxy can leave from this host's address.
|
||||
*/
|
||||
export async function configureSystemProxyAsync({
|
||||
env,
|
||||
logger,
|
||||
port,
|
||||
signal,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
port: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ service: string }> {
|
||||
if (process.env.ENVIRONMENT === 'development') {
|
||||
logger.info('Job running outside of EAS, not changing the system proxy.');
|
||||
return { service: '<development>' };
|
||||
}
|
||||
const service = await resolveActiveNetworkServiceNameAsync({ env });
|
||||
for (const args of buildNetworksetupProxyArgs({ service, host: LOCAL_EGRESS_PROXY_HOST, port })) {
|
||||
signal?.throwIfAborted();
|
||||
await spawn('networksetup', args, { env, logger, signal });
|
||||
}
|
||||
logger.info(`System proxy for "${service}" set to ${LOCAL_EGRESS_PROXY_HOST}:${port}.`);
|
||||
return { service };
|
||||
}
|
||||
|
||||
export async function writeLocalEgressHandoffAsync(
|
||||
handoff: LocalEgressHandoff,
|
||||
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
|
||||
): Promise<void> {
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify(handoff), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readLocalEgressHandoffAsync(
|
||||
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
|
||||
): Promise<LocalEgressHandoff | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.promises.readFile(handoffPath, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<LocalEgressHandoff>;
|
||||
if (
|
||||
typeof parsed.url !== 'string' ||
|
||||
typeof parsed.token !== 'string' ||
|
||||
typeof parsed.fingerprint !== 'string' ||
|
||||
typeof parsed.port !== 'number'
|
||||
) {
|
||||
throw new SystemError(`Local egress handoff at ${handoffPath} is malformed.`);
|
||||
}
|
||||
return {
|
||||
url: parsed.url,
|
||||
token: parsed.token,
|
||||
fingerprint: parsed.fingerprint,
|
||||
port: parsed.port,
|
||||
};
|
||||
}
|
||||
|
||||
/** remoteConfig fields the CLI needs to start the egress client. */
|
||||
export function buildEgressRemoteConfigFields(
|
||||
handoff: LocalEgressHandoff | null
|
||||
): Record<string, string | number> {
|
||||
if (!handoff) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
egressUrl: handoff.url,
|
||||
egressToken: handoff.token,
|
||||
egressFingerprint: handoff.fingerprint,
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
}
|
||||
|
||||
// The egress step returns before the session ends, so the resources it created
|
||||
// are held here and released by the session step or the job's finally block.
|
||||
let activeLocalEgressResources:
|
||||
| { stopAsync: () => Promise<void>; controller: AbortController; stopping?: Promise<void> }
|
||||
| undefined;
|
||||
|
||||
export function registerLocalEgressResources(stopAsync: () => Promise<void>): AbortSignal {
|
||||
if (activeLocalEgressResources) {
|
||||
throw new SystemError('Local egress resources are already registered for this job.');
|
||||
}
|
||||
const controller = new AbortController();
|
||||
activeLocalEgressResources = { stopAsync, controller };
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export async function stopLocalEgressResourcesAsync(logger: bunyan): Promise<void> {
|
||||
const resources = activeLocalEgressResources;
|
||||
if (!resources) {
|
||||
return;
|
||||
}
|
||||
resources.controller.abort();
|
||||
resources.stopping ??= Promise.resolve()
|
||||
.then(resources.stopAsync)
|
||||
.catch(err => logger.warn({ err }, 'Could not stop a local egress resource.'))
|
||||
.finally(() => {
|
||||
activeLocalEgressResources = undefined;
|
||||
});
|
||||
await resources.stopping;
|
||||
}
|
||||
|
||||
export async function isPortListeningAsync({
|
||||
host,
|
||||
port,
|
||||
timeoutMs = 1_000,
|
||||
}: {
|
||||
host: string;
|
||||
port: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<boolean> {
|
||||
return await new Promise<boolean>(resolve => {
|
||||
const socket = net.connect({ host, port });
|
||||
const finish = (listening: boolean): void => {
|
||||
socket.destroy();
|
||||
resolve(listening);
|
||||
};
|
||||
socket.setTimeout(timeoutMs, () => finish(false));
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
export function parseExitIpResponse(body: string): string {
|
||||
const parsed = JSON.parse(body) as { ip?: unknown };
|
||||
if (typeof parsed.ip !== 'string' || parsed.ip.length === 0) {
|
||||
throw new SystemError(`Unexpected exit IP response: ${body}`);
|
||||
}
|
||||
return parsed.ip;
|
||||
}
|
||||
|
||||
export async function fetchExitIpThroughProxyAsync({
|
||||
port,
|
||||
env,
|
||||
}: {
|
||||
port: number;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const result = await spawn(
|
||||
'curl',
|
||||
[
|
||||
'-sS',
|
||||
'--max-time',
|
||||
'10',
|
||||
'-x',
|
||||
`http://${LOCAL_EGRESS_PROXY_HOST}:${port}`,
|
||||
'https://api.ipify.org?format=json',
|
||||
],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
return parseExitIpResponse(result.stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes inside the simulator run on the host as descendants of launchd_sim,
|
||||
* so `ps -axo pid=,ppid=,comm=` ancestry identifies them. Returns every
|
||||
* descendant pid; launchd_sim itself opens no network connections.
|
||||
*/
|
||||
export function collectSimulatorProcessIds(psOutput: string): number[] {
|
||||
const childrenByParent = new Map<number, number[]>();
|
||||
const roots: number[] = [];
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+(\d+)\s+(\S.*)$/.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number(match[1]);
|
||||
const parentPid = Number(match[2]);
|
||||
const command = match[3].trim();
|
||||
const siblings = childrenByParent.get(parentPid) ?? [];
|
||||
siblings.push(pid);
|
||||
childrenByParent.set(parentPid, siblings);
|
||||
if (path.basename(command) === 'launchd_sim') {
|
||||
roots.push(pid);
|
||||
}
|
||||
}
|
||||
|
||||
const descendants: number[] = [];
|
||||
const seen = new Set<number>(roots);
|
||||
const queue = [...roots];
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
for (const child of childrenByParent.get(queue[i]) ?? []) {
|
||||
if (seen.has(child)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(child);
|
||||
descendants.push(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
return descendants;
|
||||
}
|
||||
|
||||
export type DirectSimulatorConnection = {
|
||||
pid: number;
|
||||
command: string;
|
||||
protocol: string;
|
||||
/** Remote peer as lsof prints it, e.g. `93.184.216.34:443` or `[2606::1]:443`. */
|
||||
remote: string;
|
||||
};
|
||||
|
||||
function remoteHostOf(remote: string): string {
|
||||
if (remote.startsWith('[')) {
|
||||
const end = remote.indexOf(']');
|
||||
return end === -1 ? remote : remote.slice(1, end);
|
||||
}
|
||||
const colon = remote.lastIndexOf(':');
|
||||
return colon === -1 ? remote : remote.slice(0, colon);
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return (
|
||||
host === 'localhost' || host.startsWith('127.') || host === '::1' || host === '::ffff:127.0.0.1'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `lsof -nP -i -F pcPnT` output. Returns the TCP and UDP connections that
|
||||
* simulator processes hold to peers outside loopback. Those did not go through
|
||||
* the proxy on 127.0.0.1, so they exit from this host instead of the egress
|
||||
* client. Listening sockets, unconnected UDP sockets and connections to
|
||||
* loopback (the proxy itself) are ignored.
|
||||
*/
|
||||
export function parseDirectSimulatorConnections(
|
||||
lsofOutput: string,
|
||||
simulatorPids: ReadonlySet<number>
|
||||
): DirectSimulatorConnection[] {
|
||||
const connections: DirectSimulatorConnection[] = [];
|
||||
let pid: number | null = null;
|
||||
let command = '';
|
||||
let protocol: string | null = null;
|
||||
let name: string | null = null;
|
||||
let tcpState: string | null = null;
|
||||
|
||||
const flush = (): void => {
|
||||
if (pid !== null && simulatorPids.has(pid) && protocol && name) {
|
||||
const arrow = name.indexOf('->');
|
||||
const remote = arrow === -1 ? null : name.slice(arrow + 2);
|
||||
const active =
|
||||
protocol !== 'TCP' ||
|
||||
tcpState === null ||
|
||||
tcpState === 'ESTABLISHED' ||
|
||||
tcpState === 'SYN_SENT';
|
||||
if (remote && active && !isLoopbackHost(remoteHostOf(remote))) {
|
||||
connections.push({ pid, command, protocol, remote });
|
||||
}
|
||||
}
|
||||
protocol = null;
|
||||
name = null;
|
||||
tcpState = null;
|
||||
};
|
||||
|
||||
for (const line of lsofOutput.split('\n')) {
|
||||
const field = line[0];
|
||||
const value = line.slice(1);
|
||||
switch (field) {
|
||||
case 'p':
|
||||
flush();
|
||||
pid = Number(value);
|
||||
command = '';
|
||||
break;
|
||||
case 'c':
|
||||
command = value;
|
||||
break;
|
||||
case 'f':
|
||||
flush();
|
||||
break;
|
||||
case 'P':
|
||||
protocol = value;
|
||||
break;
|
||||
case 'n':
|
||||
name = value;
|
||||
break;
|
||||
case 'T':
|
||||
if (value.startsWith('ST=')) {
|
||||
tcpState = value.slice('ST='.length);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return connections;
|
||||
}
|
||||
|
||||
async function findDirectSimulatorConnectionsAsync({
|
||||
env,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
}): Promise<DirectSimulatorConnection[]> {
|
||||
const ps = await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' });
|
||||
const pids = collectSimulatorProcessIds(ps.stdout);
|
||||
if (pids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
let lsofOutput: string;
|
||||
try {
|
||||
const lsof = await spawn('lsof', ['-nP', '-i', '-F', 'pcPnT', '-a', '-p', pids.join(',')], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
lsofOutput = lsof.stdout;
|
||||
} catch (err) {
|
||||
// lsof exits with 1 when none of the processes holds a matching socket, and
|
||||
// when one of them exited between `ps` and `lsof`. Its stdout is still valid.
|
||||
const result = err as { status?: number | null; stdout?: string };
|
||||
if (result.status !== 1) {
|
||||
throw err;
|
||||
}
|
||||
lsofOutput = result.stdout ?? '';
|
||||
}
|
||||
return parseDirectSimulatorConnections(lsofOutput, new Set(pids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log proxy listener availability, the exit IP observed by a worker request
|
||||
* through it, and simulator connections that bypassed the proxy. Neither check
|
||||
* verifies that proxied simulator requests reach the egress client. Never
|
||||
* rejects: it runs in the background for the whole session.
|
||||
*/
|
||||
export async function monitorLocalEgressAsync({
|
||||
port,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
let connected = false;
|
||||
let lastEscapeScanAt = 0;
|
||||
let escapeScanBroken = false;
|
||||
const reportedEscapes = new Set<string>();
|
||||
const lifetimeSignal = activeLocalEgressResources?.controller.signal;
|
||||
try {
|
||||
while (!signal.aborted && !lifetimeSignal?.aborted) {
|
||||
const listening = await isPortListeningAsync({ host: LOCAL_EGRESS_PROXY_HOST, port });
|
||||
if (listening && !connected) {
|
||||
connected = true;
|
||||
logger.info('Local egress proxy listener is available.');
|
||||
try {
|
||||
const exitIp = await fetchExitIpThroughProxyAsync({ port, env });
|
||||
logger.info(
|
||||
`Worker proxy exit-IP check observed ${exitIp}. This does not verify simulator routing.`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'The local egress proxy listener is available, but the worker exit-IP check through it failed.'
|
||||
);
|
||||
}
|
||||
} else if (!listening && connected) {
|
||||
connected = false;
|
||||
logger.warn(
|
||||
'Local egress proxy listener is unavailable. Proxied HTTP(S) requests fail until it returns.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!escapeScanBroken && Date.now() - lastEscapeScanAt >= EGRESS_ESCAPE_SCAN_INTERVAL_MS) {
|
||||
lastEscapeScanAt = Date.now();
|
||||
try {
|
||||
for (const connection of await findDirectSimulatorConnectionsAsync({ env })) {
|
||||
const key = `${connection.pid}|${connection.protocol}|${connection.remote}`;
|
||||
if (reportedEscapes.has(key)) {
|
||||
continue;
|
||||
}
|
||||
reportedEscapes.add(key);
|
||||
if (reportedEscapes.size > EGRESS_ESCAPE_LOG_LIMIT) {
|
||||
if (reportedEscapes.size === EGRESS_ESCAPE_LOG_LIMIT + 1) {
|
||||
logger.warn(
|
||||
`Local egress: more than ${EGRESS_ESCAPE_LOG_LIMIT} direct connections were reported; further ones are not logged.`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
logger.warn(
|
||||
`Local egress: ${connection.command} (pid ${connection.pid}) opened a direct ${connection.protocol} connection to ${connection.remote}, bypassing the system proxy. That traffic exits from this worker, not from the egress client.`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
escapeScanBroken = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not inspect simulator connections. Direct connections that bypass the proxy will not be reported.'
|
||||
);
|
||||
}
|
||||
}
|
||||
await sleepAsync(EGRESS_MONITOR_INTERVAL_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Local egress monitoring stopped unexpectedly.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type BuildFunction, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import {
|
||||
buildEgressRemoteConfigFields,
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from './localEgress';
|
||||
import { uploadRemoteSessionConfigAsync } from './remoteDeviceRunSession';
|
||||
|
||||
/** Release the pre-boot egress resources even if controller startup or teardown fails. */
|
||||
export function withLocalEgressSession(
|
||||
fn: NonNullable<BuildFunction['fn']>
|
||||
): NonNullable<BuildFunction['fn']> {
|
||||
return async (context, args) => {
|
||||
try {
|
||||
args.signal?.throwIfAborted();
|
||||
await fn(context, args);
|
||||
} finally {
|
||||
await stopLocalEgressResourcesAsync(context.logger);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** All simulator controllers publish the same egress handoff and run the same monitor. */
|
||||
export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
...options
|
||||
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
|
||||
env: BuildStepEnv;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
|
||||
const localEgress = await readLocalEgressHandoffAsync();
|
||||
signal?.throwIfAborted();
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
...options,
|
||||
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
|
||||
});
|
||||
if (localEgress && !signal?.aborted) {
|
||||
options.logger.info(
|
||||
'Local egress: waiting for the EAS CLI egress client to connect. Proxied HTTP(S) ' +
|
||||
'requests are unavailable until it does.'
|
||||
);
|
||||
// The monitor also observes the registered resources' lifetime signal, which
|
||||
// withLocalEgressSession aborts on success, failure, or cancellation.
|
||||
void monitorLocalEgressAsync({
|
||||
port: localEgress.port,
|
||||
env,
|
||||
logger: options.logger,
|
||||
signal: signal ?? new AbortController().signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
@@ -652,6 +653,7 @@ export function createServeSimArgs({
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
@@ -705,7 +707,7 @@ export function createExpoDeviceHubArgs({
|
||||
];
|
||||
}
|
||||
|
||||
async function findAvailablePortAsync(): Promise<number> {
|
||||
export async function findAvailablePortAsync(): Promise<number> {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -737,7 +739,7 @@ export async function waitForWebPreviewReadyAsync({
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<void> {
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
@@ -754,8 +756,8 @@ export async function waitForWebPreviewReadyAsync({
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return;
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
@@ -770,6 +772,8 @@ export async function waitForWebPreviewReadyAsync({
|
||||
|
||||
export type DeviceWebPreviewHandle = {
|
||||
previewUrl: string;
|
||||
/** Session token gating the preview. Only serve-sim mints one. */
|
||||
previewToken?: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -785,6 +789,7 @@ async function startWebPreviewWithTunnelAsync(
|
||||
serverName,
|
||||
packageSpec,
|
||||
createArgs,
|
||||
readPreviewTokenAsync,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
@@ -793,6 +798,7 @@ async function startWebPreviewWithTunnelAsync(
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[]) => string[];
|
||||
readPreviewTokenAsync?: (device: string) => Promise<string>;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const port = await findAvailablePortAsync();
|
||||
@@ -806,7 +812,13 @@ async function startWebPreviewWithTunnelAsync(
|
||||
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
await waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs });
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
const previewToken = await readPreviewTokenAsync?.(device);
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
@@ -816,6 +828,7 @@ async function startWebPreviewWithTunnelAsync(
|
||||
});
|
||||
return {
|
||||
previewUrl: tunnel.url,
|
||||
previewToken,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
for (const result of results) {
|
||||
@@ -831,6 +844,14 @@ async function startWebPreviewWithTunnelAsync(
|
||||
}
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
return servers.find(server => server.udid === udid)?.token;
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
@@ -857,6 +878,20 @@ export async function startServeSimWithTunnelAsync(
|
||||
packageSpec: createServeSimPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) =>
|
||||
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
|
||||
readPreviewTokenAsync: async device => {
|
||||
const previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
// A serve-sim that does not know --require-token fails earlier, in the readiness check, so
|
||||
// reaching here means it started and left no token in its state file.
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
);
|
||||
}
|
||||
return previewToken;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ const POLL_INTERVAL_MS = 2_000;
|
||||
const MAX_CONSECUTIVE_STREAM_FAILURES_PER_DEVICE = 10;
|
||||
const END_STREAM_TIMEOUT_MS = 2_000;
|
||||
// serve-sim's own per-device registry while serving: `${tmpdir}/serve-sim/server-<udid>.json`.
|
||||
const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
|
||||
export const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
|
||||
|
||||
type ServeSimServer = { udid: string; url: string };
|
||||
type ServeSimServer = { udid: string; url: string; token?: string };
|
||||
|
||||
type ServeSimMetricsSession = {
|
||||
logger: bunyan;
|
||||
@@ -130,6 +130,7 @@ async function pollServeSimMetricsAsync(session: ServeSimMetricsSession): Promis
|
||||
logger.info(`Collecting serve-sim metrics for ${server.udid}.`);
|
||||
const donePromise = streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl: server.url,
|
||||
serveSimToken: server.token,
|
||||
filePath,
|
||||
signal: streamSignal,
|
||||
logger,
|
||||
@@ -172,9 +173,14 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
|
||||
const state = JSON.parse(await readFile(path.join(stateDir, entry), 'utf-8')) as {
|
||||
device?: unknown;
|
||||
url?: unknown;
|
||||
token?: unknown;
|
||||
};
|
||||
if (typeof state.device === 'string' && typeof state.url === 'string') {
|
||||
servers.push({ udid: state.device, url: state.url });
|
||||
servers.push({
|
||||
udid: state.device,
|
||||
url: state.url,
|
||||
...(typeof state.token === 'string' ? { token: state.token } : {}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
@@ -185,11 +191,13 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
|
||||
|
||||
export async function streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl,
|
||||
serveSimToken,
|
||||
filePath,
|
||||
signal,
|
||||
logger,
|
||||
}: {
|
||||
serveSimUrl: string;
|
||||
serveSimToken?: string;
|
||||
filePath: string;
|
||||
signal: AbortSignal;
|
||||
logger: bunyan;
|
||||
@@ -201,7 +209,10 @@ export async function streamServeSimMetricsToFileAsync({
|
||||
let receivedData = false;
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), { signal });
|
||||
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), {
|
||||
signal,
|
||||
...(serveSimToken ? { headers: { Authorization: `Bearer ${serveSimToken}` } } : {}),
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
logger.warn(`serve-sim /metrics responded ${response.status} for ${serveSimUrl}.`);
|
||||
return { receivedData, metadata };
|
||||
|
||||
@@ -99,12 +99,18 @@ export namespace AndroidEmulatorUtils {
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier,
|
||||
lcdWidth,
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
systemImagePackage: string;
|
||||
deviceIdentifier: AndroidDeviceName | null;
|
||||
lcdWidth: number | null;
|
||||
lcdHeight: number | null;
|
||||
lcdDensity: number | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
@@ -176,6 +182,13 @@ export namespace AndroidEmulatorUtils {
|
||||
}
|
||||
}
|
||||
|
||||
if (lcdWidth !== null && lcdHeight !== null && lcdDensity !== null) {
|
||||
logger.info(
|
||||
`Setting screen resolution to ${lcdWidth}x${lcdHeight} and density to ${lcdDensity} ppi.`
|
||||
);
|
||||
configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${lcdHeight}\nhw.lcd.width=${lcdWidth}\nhw.lcd.density=${lcdDensity}\n`;
|
||||
}
|
||||
|
||||
const shouldAdjustHeapSize =
|
||||
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== '0';
|
||||
|
||||
@@ -71,6 +71,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -95,6 +98,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -158,6 +164,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -209,6 +218,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: envForAttempt,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -290,6 +302,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger,
|
||||
});
|
||||
@@ -361,6 +376,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
|
||||
import {
|
||||
AndroidDeviceName,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -48,6 +52,120 @@ describe('AndroidEmulatorUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.createAsync, () => {
|
||||
it('applies an explicit LCD configuration after screen scaling', async () => {
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
`${avdDirectory}/config.ini`,
|
||||
[
|
||||
'hw.ramSize=1536',
|
||||
'hw.lcd.height=2424',
|
||||
'hw.lcd.width=1080',
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
].join('\n')
|
||||
);
|
||||
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
const write = jest.fn();
|
||||
const end = jest.fn();
|
||||
avdManagerPromise.child = { stdin: { write, end } };
|
||||
mockedSpawn.mockReturnValue(avdManagerPromise);
|
||||
const logger = createMockLogger();
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: 'system-images;android-35;default;x86_64',
|
||||
deviceIdentifier: 'medium_phone' as AndroidDeviceName,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'avdmanager',
|
||||
[
|
||||
'create',
|
||||
'avd',
|
||||
'--name',
|
||||
deviceName,
|
||||
'--package',
|
||||
'system-images;android-35;default;x86_64',
|
||||
'--force',
|
||||
'--device',
|
||||
'medium_phone',
|
||||
],
|
||||
{ env: expect.any(Object), stdio: 'pipe' }
|
||||
);
|
||||
expect(write).toHaveBeenCalledWith('no');
|
||||
expect(end).toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Setting scaled screen resolution: hw.lcd.height to 1270 and hw.lcd.width to 566.'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Setting screen resolution to 720x1600 and density to 262 ppi.'
|
||||
);
|
||||
await expect(fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8')).resolves.toBe(
|
||||
[
|
||||
'hw.ramSize=1536',
|
||||
'hw.lcd.height=2424',
|
||||
'hw.lcd.width=1080',
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
'hw.ramSize=2048',
|
||||
'',
|
||||
'hw.lcd.density=220',
|
||||
'',
|
||||
'hw.lcd.height=1270',
|
||||
'hw.lcd.width=566',
|
||||
'',
|
||||
'hw.lcd.height=1600',
|
||||
'hw.lcd.width=720',
|
||||
'hw.lcd.density=262',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply an LCD configuration when it is omitted', async () => {
|
||||
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
|
||||
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
avdManagerPromise.child = { stdin: { write: jest.fn(), end: jest.fn() } };
|
||||
mockedSpawn.mockReturnValue(avdManagerPromise);
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
const config = await fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8');
|
||||
expect(config).toContain(initialConfig);
|
||||
expect(config).not.toContain('hw.lcd.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
|
||||
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "23.1.0",
|
||||
"version": "24.0.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "23.1.0"
|
||||
"@expo/steps": "24.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "23.0.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "23.0.0",
|
||||
"version": "24.0.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "23.1.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "23.1.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
+579
-332
File diff suppressed because it is too large.
Load diff
Generated
+2027
-1723
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "23.2.0",
|
||||
"version": "24.1.2",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -44,15 +44,15 @@
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.22",
|
||||
"@expo/apple-utils": "2.2.0",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/eas-json": "23.1.0",
|
||||
"@expo/eas-build-job": "24.0.0",
|
||||
"@expo/eas-json": "24.0.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "23.1.0",
|
||||
"@expo/steps": "24.0.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
|
||||
@@ -24,17 +25,24 @@ jest.mock('../../analytics/AnalyticsManager', () => {
|
||||
});
|
||||
jest.mock('../../log');
|
||||
|
||||
let originalProcessArgv: string[];
|
||||
const mockRequestId = uuidv4();
|
||||
|
||||
beforeAll(() => {
|
||||
originalProcessArgv = process.argv;
|
||||
process.argv = [];
|
||||
});
|
||||
/**
|
||||
* A Config that is never loaded. Letting oclif load one discovers and requires
|
||||
* every command in the package, which takes seconds under coverage with a cold
|
||||
* transform cache and has timed out on slow CI runners. Nothing here needs the
|
||||
* command table or hooks.
|
||||
*/
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({
|
||||
failures: [],
|
||||
successes: [],
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
process.argv = originalProcessArgv;
|
||||
});
|
||||
let mockConfig: Config;
|
||||
|
||||
const analytics: AnalyticsWithOrchestration = {
|
||||
logEvent: jest.fn((): void => {}),
|
||||
@@ -44,42 +52,57 @@ const analytics: AnalyticsWithOrchestration = {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockConfig = getMockOclifConfig();
|
||||
});
|
||||
|
||||
/**
|
||||
* The config oclif handed to the most recently run command. Command.run returns
|
||||
* a pre-built Config untouched only when it was created by the same @oclif/core
|
||||
* copy the command extends; otherwise it silently builds and fully loads a new
|
||||
* one, bringing back the command discovery this suite avoids.
|
||||
*/
|
||||
let lastCommandConfig: Config | undefined;
|
||||
|
||||
const createTestEasCommand = (): any => {
|
||||
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
|
||||
createAnalyticsAsync.mockResolvedValue(analytics);
|
||||
const EasCommand = require('../EasCommand').default;
|
||||
|
||||
class TestEasCommand extends EasCommand {
|
||||
async runAsync(): Promise<void> {}
|
||||
async runAsync(): Promise<void> {
|
||||
lastCommandConfig = this.config;
|
||||
}
|
||||
}
|
||||
|
||||
TestEasCommand.id = 'testEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
|
||||
return TestEasCommand;
|
||||
};
|
||||
|
||||
// These tests go through the static Command.run rather than constructing an
|
||||
// instance and calling run() like other command tests, because they cover
|
||||
// oclif's catch and finally wiring, which only runs inside the static path.
|
||||
describe('EasCommand', () => {
|
||||
describe('without exceptions', () => {
|
||||
// The first test in this suite should have an increased timeout
|
||||
// because of the implementation of Command from @oclif/command.
|
||||
// It seems that loading config takes significant amount of time
|
||||
// and I'm not sure how to mock it.
|
||||
//
|
||||
// See https://github.com/oclif/command/blob/master/src/command.ts#L80
|
||||
// and look for "Config.load"
|
||||
it('ensures the user data is read', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
const SessionManager = jest.requireMock('../../user/SessionManager').default;
|
||||
const sessionManagerSpy = jest.spyOn(SessionManager.prototype, 'getUserAsync');
|
||||
expect(sessionManagerSpy).toBeCalledTimes(1);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
it('runs with the unloaded config instead of loading one', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
expect(lastCommandConfig).toBe(mockConfig);
|
||||
expect(mockConfig.plugins.size).toBe(0);
|
||||
});
|
||||
|
||||
it('initializes analytics', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
|
||||
expect(createAnalyticsAsync).toHaveBeenCalled();
|
||||
@@ -87,14 +110,14 @@ describe('EasCommand', () => {
|
||||
|
||||
it('flushes analytics', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
expect(analytics.flushAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes Sentry', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
const Sentry = jest.requireMock('../../sentry').default;
|
||||
expect(Sentry.flush).toHaveBeenCalled();
|
||||
@@ -102,7 +125,7 @@ describe('EasCommand', () => {
|
||||
|
||||
it('logs events', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('action', {
|
||||
action: `eas ${TestEasCommand.id}`,
|
||||
@@ -114,7 +137,7 @@ describe('EasCommand', () => {
|
||||
it('flushes analytics', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
try {
|
||||
await TestEasCommand.run().then(() => {
|
||||
await TestEasCommand.run([], mockConfig).then(() => {
|
||||
throw new Error('foo');
|
||||
});
|
||||
} catch {}
|
||||
@@ -140,7 +163,7 @@ describe('EasCommand', () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(sentryScope.setTag).toHaveBeenCalledWith('command', TestEasCommand.id);
|
||||
@@ -167,7 +190,7 @@ describe('EasCommand', () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
|
||||
@@ -184,7 +207,7 @@ describe('EasCommand', () => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith('Unexpected, internal error message');
|
||||
@@ -204,7 +227,7 @@ describe('EasCommand', () => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith('Unexpected GraphQL error message');
|
||||
@@ -237,7 +260,7 @@ describe('EasCommand', () => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith(
|
||||
@@ -273,7 +296,7 @@ describe('EasCommand', () => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith(
|
||||
@@ -294,7 +317,7 @@ describe('EasCommand', () => {
|
||||
throw new Error('Error message');
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('testEasCommand command failed.');
|
||||
@@ -310,7 +333,7 @@ describe('EasCommand', () => {
|
||||
throw new CombinedError({ graphQLErrors });
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
|
||||
@@ -340,7 +363,7 @@ describe('EasCommand', () => {
|
||||
throw new CombinedError({ graphQLErrors });
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppObserveErrorSeverity } from '../../../graphql/generated';
|
||||
import {
|
||||
fetchObserveErrorGroupsAsync,
|
||||
fetchObserveErrorOccurrencesAsync,
|
||||
} from '../../../observe/fetchErrors';
|
||||
import {
|
||||
buildObserveErrorGroupsJson,
|
||||
buildObserveErrorGroupsTable,
|
||||
buildObserveErrorOccurrencesJson,
|
||||
buildObserveErrorOccurrencesTable,
|
||||
} from '../../../observe/formatErrors';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveErrors from '../errors';
|
||||
|
||||
jest.mock('../../../observe/fetchErrors');
|
||||
jest.mock('../../../observe/formatErrors', () => ({
|
||||
buildObserveErrorGroupsTable: jest.fn().mockReturnValue('errors-table'),
|
||||
buildObserveErrorGroupsJson: jest.fn().mockReturnValue({ errors: [], isTruncated: false }),
|
||||
buildObserveErrorOccurrencesTable: jest.fn().mockReturnValue('occurrences-table'),
|
||||
buildObserveErrorOccurrencesJson: jest
|
||||
.fn()
|
||||
.mockReturnValue({ occurrences: [], pageInfo: { hasNextPage: false, endCursor: null } }),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchErrorGroupsAsync = jest.mocked(fetchObserveErrorGroupsAsync);
|
||||
const mockFetchErrorOccurrencesAsync = jest.mocked(fetchObserveErrorOccurrencesAsync);
|
||||
const mockBuildTable = jest.mocked(buildObserveErrorGroupsTable);
|
||||
const mockBuildJson = jest.mocked(buildObserveErrorGroupsJson);
|
||||
const mockBuildOccurrencesTable = jest.mocked(buildObserveErrorOccurrencesTable);
|
||||
const mockBuildOccurrencesJson = jest.mocked(buildObserveErrorOccurrencesJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveErrors, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchErrorGroupsAsync.mockResolvedValue({ groups: [], isTruncated: false });
|
||||
mockFetchErrorOccurrencesAsync.mockResolvedValue({
|
||||
occurrences: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false, endCursor: null } as any,
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveErrors {
|
||||
const command = new ObserveErrors(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('fetches error groups and renders a table by default', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
expect(mockFetchErrorGroupsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildTable).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON with --json', async () => {
|
||||
await createCommand(['--json', '--non-interactive']).runAsync();
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ errors: [], isTruncated: false });
|
||||
});
|
||||
|
||||
it('maps the --severity flag to the AppObserveErrorSeverity enum', async () => {
|
||||
await createCommand(['--severity', 'fatal']).runAsync();
|
||||
const options = mockFetchErrorGroupsAsync.mock.calls[0][2];
|
||||
expect(options.severity).toBe(AppObserveErrorSeverity.Fatal);
|
||||
});
|
||||
|
||||
it('fetches occurrences (not groups) and renders their table when --fingerprint is set', async () => {
|
||||
await createCommand(['--fingerprint', 'fp-1']).runAsync();
|
||||
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchErrorOccurrencesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchErrorOccurrencesAsync.mock.calls[0][2]).toMatchObject({ fingerprint: 'fp-1' });
|
||||
expect(mockBuildOccurrencesTable).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits occurrences JSON with --fingerprint --json', async () => {
|
||||
await createCommand(['--fingerprint', 'fp-1', '--json', '--non-interactive']).runAsync();
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildOccurrencesJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
occurrences: [],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards --limit to the occurrences fetch when --fingerprint is set', async () => {
|
||||
await createCommand(['--fingerprint', 'fp-1', '--limit', '20']).runAsync();
|
||||
expect(mockFetchErrorOccurrencesAsync.mock.calls[0][2]).toMatchObject({
|
||||
fingerprint: 'fp-1',
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects --limit without --fingerprint', async () => {
|
||||
await expect(createCommand(['--limit', '20']).runAsync()).rejects.toThrow(
|
||||
/--after or --limit can only be used in combination with the --fingerprint flag/
|
||||
);
|
||||
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchErrorOccurrencesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --after without --fingerprint', async () => {
|
||||
await expect(createCommand(['--after', 'cursor-1']).runAsync()).rejects.toThrow(
|
||||
/--after or --limit can only be used in combination with the --fingerprint flag/
|
||||
);
|
||||
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message when errors are not available on the plan', async () => {
|
||||
mockFetchErrorGroupsAsync.mockRejectedValueOnce(
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'Subscription to EAS is required for this feature.',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}
|
||||
),
|
||||
],
|
||||
})
|
||||
);
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow(/Subscription to EAS is required/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
|
||||
import {
|
||||
buildObserveCustomEventDetail,
|
||||
buildObserveCustomEventJson,
|
||||
} from '../../../observe/formatCustomEvents';
|
||||
import { buildObserveErrorDetail, buildObserveErrorJson } from '../../../observe/formatErrors';
|
||||
import { buildObserveEventDetail, buildObserveEventJson } from '../../../observe/formatEvents';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveEvent from '../event';
|
||||
|
||||
jest.mock('../../../observe/formatEvents', () => ({
|
||||
buildObserveEventDetail: jest.fn().mockReturnValue('metric-detail'),
|
||||
buildObserveEventJson: jest.fn().mockReturnValue({ id: 'metric-json' }),
|
||||
}));
|
||||
jest.mock('../../../observe/formatCustomEvents', () => ({
|
||||
buildObserveCustomEventDetail: jest.fn().mockReturnValue('log-detail'),
|
||||
buildObserveCustomEventJson: jest.fn().mockReturnValue({ id: 'log-json' }),
|
||||
}));
|
||||
jest.mock('../../../observe/formatErrors', () => ({
|
||||
buildObserveErrorDetail: jest.fn().mockReturnValue('error-detail'),
|
||||
buildObserveErrorJson: jest.fn().mockReturnValue({ id: 'error-json' }),
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
|
||||
ObserveQuery: {
|
||||
logByIdAsync: jest.fn(),
|
||||
metricByIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockLogByIdAsync = jest.mocked(ObserveQuery.logByIdAsync);
|
||||
const mockMetricByIdAsync = jest.mocked(ObserveQuery.metricByIdAsync);
|
||||
const mockBuildObserveEventDetail = jest.mocked(buildObserveEventDetail);
|
||||
const mockBuildObserveEventJson = jest.mocked(buildObserveEventJson);
|
||||
const mockBuildObserveCustomEventDetail = jest.mocked(buildObserveCustomEventDetail);
|
||||
const mockBuildObserveCustomEventJson = jest.mocked(buildObserveCustomEventJson);
|
||||
const mockBuildObserveErrorDetail = jest.mocked(buildObserveErrorDetail);
|
||||
const mockBuildObserveErrorJson = jest.mocked(buildObserveErrorJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
// A log event ID (user event or error) is a UUID; a metric event ID is
|
||||
// base64url-encoded JSON.
|
||||
const UUID_ID = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const BASE64_ID = 'eyJhIjoxfQ';
|
||||
const INVALID_ID = 'not a valid id';
|
||||
|
||||
const userEventLog = { id: UUID_ID, __typename: 'AppObserveUserEvent' } as any;
|
||||
const errorLog = { id: UUID_ID, __typename: 'AppObserveError' } as any;
|
||||
|
||||
describe(ObserveEvent, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockLogByIdAsync.mockResolvedValue(null);
|
||||
mockMetricByIdAsync.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveEvent {
|
||||
const command = new ObserveEvent(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
function planGateError(): CombinedError {
|
||||
const serverMessage =
|
||||
'Subscription to EAS is required for this feature. ' +
|
||||
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
|
||||
return new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(serverMessage, null, null, null, null, null, {
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it('looks up a UUID id with the log query only', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(userEventLog);
|
||||
await createCommand([UUID_ID]).runAsync();
|
||||
expect(mockLogByIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
id: UUID_ID,
|
||||
});
|
||||
expect(mockMetricByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders an error detail when a UUID id resolves to an error', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(errorLog);
|
||||
await createCommand([UUID_ID]).runAsync();
|
||||
expect(mockBuildObserveErrorDetail).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildObserveCustomEventDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('looks up a base64 id with the metric query only', async () => {
|
||||
mockMetricByIdAsync.mockResolvedValue({ id: BASE64_ID } as any);
|
||||
await createCommand([BASE64_ID]).runAsync();
|
||||
expect(mockMetricByIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
id: BASE64_ID,
|
||||
});
|
||||
expect(mockLogByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveEventDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('errors immediately for an ID that is neither a UUID nor base64, without querying', async () => {
|
||||
await expect(createCommand([INVALID_ID]).runAsync()).rejects.toThrow(
|
||||
/is not a valid Observe event ID/
|
||||
);
|
||||
expect(mockLogByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockMetricByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits typed JSON for a metric event with --json', async () => {
|
||||
mockMetricByIdAsync.mockResolvedValue({ id: BASE64_ID } as any);
|
||||
await createCommand([BASE64_ID, '--json', '--non-interactive']).runAsync();
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildObserveEventJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
type: 'metric',
|
||||
event: { id: 'metric-json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('emits typed JSON for a log event with --json', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(userEventLog);
|
||||
await createCommand([UUID_ID, '--json', '--non-interactive']).runAsync();
|
||||
expect(mockBuildObserveCustomEventJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
type: 'log',
|
||||
event: { id: 'log-json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('emits typed JSON for an error event with --json', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(errorLog);
|
||||
await createCommand([UUID_ID, '--json', '--non-interactive']).runAsync();
|
||||
expect(mockBuildObserveErrorJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
event: { id: 'error-json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws not-found when a well-formed ID resolves to nothing', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(null);
|
||||
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
|
||||
new RegExp(`No Observe event found with ID "${UUID_ID}"`)
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message when the lookup is not available on the plan', async () => {
|
||||
mockLogByIdAsync.mockRejectedValueOnce(planGateError());
|
||||
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
|
||||
/Subscription to EAS is required/
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps an unexpected server error with an ID-specific message and preserves the request ID', async () => {
|
||||
const serverError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError('unexpected server error', null, null, null, null, null, {
|
||||
requestId: 'req-123',
|
||||
}),
|
||||
],
|
||||
});
|
||||
mockLogByIdAsync.mockRejectedValue(serverError);
|
||||
|
||||
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
|
||||
new RegExp(
|
||||
`Could not retrieve Observe event with ID "${UUID_ID}"[\\s\\S]*unexpected server error \\(Request ID: req-123\\)`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import { AppObserveErrorSeverity } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
fetchObserveErrorGroupsAsync,
|
||||
fetchObserveErrorOccurrencesAsync,
|
||||
} from '../../observe/fetchErrors';
|
||||
import {
|
||||
ObserveAfterFlag,
|
||||
ObserveAppVersionFlag,
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
import {
|
||||
buildObserveErrorGroupsJson,
|
||||
buildObserveErrorGroupsTable,
|
||||
buildObserveErrorOccurrencesJson,
|
||||
buildObserveErrorOccurrencesTable,
|
||||
} from '../../observe/formatErrors';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { observePlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const SEVERITY_BY_FLAG: Record<string, AppObserveErrorSeverity> = {
|
||||
fatal: AppObserveErrorSeverity.Fatal,
|
||||
error: AppObserveErrorSeverity.Error,
|
||||
};
|
||||
|
||||
const DEFAULT_OCCURRENCES_LIMIT = 10;
|
||||
|
||||
export default class ObserveErrors extends EasCommand {
|
||||
static override description =
|
||||
'display error and exception issue groups (grouped by fingerprint) for the app';
|
||||
|
||||
static override flags = {
|
||||
...ObservePlatformFlag,
|
||||
fingerprint: Flags.string({
|
||||
description:
|
||||
'Show individual occurrences (with stack traces) for this error group fingerprint instead of the grouped summary',
|
||||
required: false,
|
||||
}),
|
||||
severity: Flags.option({
|
||||
description: 'Filter by severity (ignored when --fingerprint is set)',
|
||||
options: Object.keys(SEVERITY_BY_FLAG),
|
||||
required: false,
|
||||
})(),
|
||||
...ObserveAfterFlag,
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_OCCURRENCES_LIMIT,
|
||||
limit: 100,
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveAppVersionFlag,
|
||||
...ObserveBuildNumberFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveErrors);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveErrors,
|
||||
loggedInOnlyContextDefinition: ObserveErrors.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (!flags.fingerprint && (flags.after || flags.limit)) {
|
||||
throw new Error(
|
||||
'--after or --limit can only be used in combination with the --fingerprint flag.'
|
||||
);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
if (flags.fingerprint) {
|
||||
const { occurrences, pageInfo } = await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveErrorOccurrencesAsync(graphqlClient, projectId, {
|
||||
fingerprint: flags.fingerprint as string,
|
||||
startTime,
|
||||
endTime,
|
||||
platforms: observePlatformsFromFlag(flags.platform),
|
||||
appVersion: flags['app-version'],
|
||||
buildNumber: flags['build-number'],
|
||||
updateId: flags['update-id'],
|
||||
environment: flags.environment,
|
||||
limit: flags.limit ?? DEFAULT_OCCURRENCES_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveErrorOccurrencesJson(occurrences, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveErrorOccurrencesTable(occurrences, pageInfo, {
|
||||
fingerprint: flags.fingerprint,
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { groups, isTruncated } = await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveErrorGroupsAsync(graphqlClient, projectId, {
|
||||
startTime,
|
||||
endTime,
|
||||
platforms: observePlatformsFromFlag(flags.platform),
|
||||
appVersion: flags['app-version'],
|
||||
buildNumber: flags['build-number'],
|
||||
updateId: flags['update-id'],
|
||||
environment: flags.environment,
|
||||
...(flags.severity && { severity: SEVERITY_BY_FLAG[flags.severity] }),
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveErrorGroupsJson(groups, isTruncated));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveErrorGroupsTable(groups, { daysBack, startTime, endTime }));
|
||||
if (isTruncated) {
|
||||
Log.warn('Result is truncated; not all error groups are shown.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import { validate as isUuid } from 'uuid';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { GraphqlError } from '../../graphql/client';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
buildObserveCustomEventDetail,
|
||||
buildObserveCustomEventJson,
|
||||
} from '../../observe/formatCustomEvents';
|
||||
import { buildObserveErrorDetail, buildObserveErrorJson } from '../../observe/formatErrors';
|
||||
import { buildObserveEventDetail, buildObserveEventJson } from '../../observe/formatEvents';
|
||||
import { ObserveProjectIdFlag } from '../../observe/flags';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ObserveEvent extends EasCommand {
|
||||
static override description =
|
||||
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`.';
|
||||
|
||||
static override args = {
|
||||
id: Args.string({
|
||||
description: 'ID of the event to display',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveEvent);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveEvent,
|
||||
loggedInOnlyContextDefinition: ObserveEvent.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const id = args.id;
|
||||
|
||||
// A log event ID (user event or error) is a UUID; a metric event ID is
|
||||
// base64url-encoded JSON. Route to the matching query so we never issue the
|
||||
// query that is guaranteed to fail for this ID, and reject anything that is
|
||||
// neither up front.
|
||||
if (isUuid(id)) {
|
||||
const log = await fetchObserveEventAsync(id, () =>
|
||||
ObserveQuery.logByIdAsync(graphqlClient, { appId: projectId, id })
|
||||
);
|
||||
if (!log) {
|
||||
throw eventNotFoundError(id);
|
||||
}
|
||||
if (log.__typename === 'AppObserveError') {
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ type: 'error', event: buildObserveErrorJson(log) });
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveErrorDetail(log));
|
||||
}
|
||||
} else if (json) {
|
||||
printJsonOnlyOutput({ type: 'log', event: buildObserveCustomEventJson(log) });
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveCustomEventDetail(log));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsesAsBase64(id)) {
|
||||
const event = await fetchObserveEventAsync(id, () =>
|
||||
ObserveQuery.metricByIdAsync(graphqlClient, { appId: projectId, id })
|
||||
);
|
||||
if (!event) {
|
||||
throw eventNotFoundError(id);
|
||||
}
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ type: 'metric', event: buildObserveEventJson(event) });
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveEventDetail(event));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new EasCommandError(
|
||||
`"${id}" is not a valid Observe event ID. IDs come from \`eas observe:events\` or \`eas observe:session\`.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A metric event ID is canonical base64url (of JSON). Round-tripping rejects
|
||||
* strings that only coincidentally contain base64 characters, so a value that
|
||||
* is neither a UUID nor this is treated as an invalid ID.
|
||||
*/
|
||||
function parsesAsBase64(id: string): boolean {
|
||||
if (id.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return Buffer.from(id, 'base64url').toString('base64url') === id;
|
||||
}
|
||||
|
||||
function eventNotFoundError(id: string): EasCommandError {
|
||||
return new EasCommandError(
|
||||
`No Observe event found with ID "${id}". IDs come from \`eas observe:events\` or \`eas observe:session\`, and events age out of retention.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an Observe event lookup, translating plan-gate rejections to their
|
||||
* upgrade message and any other server error into an actionable message that
|
||||
* preserves the underlying error and request ID for support.
|
||||
*/
|
||||
async function fetchObserveEventAsync<T>(id: string, fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await withObservePlanGateHandlingAsync(fn);
|
||||
} catch (error) {
|
||||
if (error instanceof EasCommandError) {
|
||||
throw error;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Could not retrieve Observe event with ID "${id}". ` +
|
||||
'The ID may be invalid or the event may not exist (events also age out of retention). ' +
|
||||
'Verify it was copied in full from `eas observe:events` or `eas observe:session`.' +
|
||||
`\n\n${describeObserveServerError(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a human-readable description from a GraphQL/server error, including
|
||||
* the request ID(s) so a support request can reference them. Falls back to the
|
||||
* error's own message for non-GraphQL errors.
|
||||
*/
|
||||
function describeObserveServerError(error: unknown): string {
|
||||
if (error instanceof GraphqlError && error.graphQLErrors.length > 0) {
|
||||
return error.graphQLErrors
|
||||
.map(graphQLError => {
|
||||
const message = graphQLError.message.replace('[GraphQL] ', '');
|
||||
const requestId = graphQLError.extensions?.requestId;
|
||||
return requestId ? `${message} (Request ID: ${String(requestId)})` : message;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import Log from '../../../log';
|
||||
import { runLocalEgressAsync } from '../../../simulator/egress';
|
||||
import { loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import SimulatorEgress from '../egress';
|
||||
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/egress', () => ({
|
||||
...jest.requireActual('../../../simulator/egress'),
|
||||
runLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const shellSession = {
|
||||
EAS_SIMULATOR_SESSION_ID: 'session-b',
|
||||
EAS_SIMULATOR_EGRESS_ALLOW: '',
|
||||
EAS_SIMULATOR_EGRESS_URL: 'https://egress-b.example.test',
|
||||
EAS_SIMULATOR_EGRESS_TOKEN: 'token-b',
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT: 'fingerprint-b',
|
||||
EAS_SIMULATOR_EGRESS_PORT: '8899',
|
||||
};
|
||||
const fileSession = {
|
||||
EAS_SIMULATOR_SESSION_ID: 'session-a',
|
||||
EAS_SIMULATOR_EGRESS_ALLOW: 'localhost:3000',
|
||||
EAS_SIMULATOR_EGRESS_URL: 'https://egress-a.example.test',
|
||||
EAS_SIMULATOR_EGRESS_TOKEN: 'token-a',
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT: 'fingerprint-a',
|
||||
EAS_SIMULATOR_EGRESS_PORT: '8898',
|
||||
};
|
||||
|
||||
function createCommand(argv: string[]): SimulatorEgress {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({ failures: [], successes: [] });
|
||||
const command = new SimulatorEgress(argv, config);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({ projectDir: '/test/project' });
|
||||
return command;
|
||||
}
|
||||
|
||||
describe(SimulatorEgress, () => {
|
||||
const previousEnv = Object.fromEntries(
|
||||
Object.keys(shellSession).map(key => [key, process.env[key]])
|
||||
);
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.assign(process.env, shellSession);
|
||||
jest.mocked(loadSimulatorEnvAsync).mockImplementation(async () => {
|
||||
Object.assign(process.env, fileSession);
|
||||
});
|
||||
jest.mocked(runLocalEgressAsync).mockResolvedValue();
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the complete exported session tuple without loading a stale dotenv session in env mode', async () => {
|
||||
await createCommand(['--config-type', 'env']).runAsync();
|
||||
|
||||
expect(loadSimulatorEnvAsync).not.toHaveBeenCalled();
|
||||
expect(runLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: shellSession.EAS_SIMULATOR_EGRESS_URL,
|
||||
token: shellSession.EAS_SIMULATOR_EGRESS_TOKEN,
|
||||
fingerprint: shellSession.EAS_SIMULATOR_EGRESS_FINGERPRINT,
|
||||
port: 8899,
|
||||
allow: [],
|
||||
})
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('session-b'));
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('eas simulator:stop --id session-b')
|
||||
);
|
||||
});
|
||||
|
||||
it.each([[], ['--config-type', 'dotenv']])(
|
||||
'loads the managed session by default or with explicit dotenv selection (%j)',
|
||||
async (...argv) => {
|
||||
await createCommand(argv).runAsync();
|
||||
|
||||
expect(loadSimulatorEnvAsync).toHaveBeenCalledWith('/test/project');
|
||||
expect(runLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: fileSession.EAS_SIMULATOR_EGRESS_URL,
|
||||
token: fileSession.EAS_SIMULATOR_EGRESS_TOKEN,
|
||||
fingerprint: fileSession.EAS_SIMULATOR_EGRESS_FINGERPRINT,
|
||||
port: 8898,
|
||||
allow: ['localhost:3000'],
|
||||
})
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('session-a'));
|
||||
}
|
||||
);
|
||||
|
||||
it('does not fill missing exported credentials from a stale file in env mode', async () => {
|
||||
delete process.env.EAS_SIMULATOR_EGRESS_TOKEN;
|
||||
|
||||
await expect(createCommand(['--config-type', 'env']).runAsync()).rejects.toThrow(
|
||||
'no egress client to run'
|
||||
);
|
||||
|
||||
expect(loadSimulatorEnvAsync).not.toHaveBeenCalled();
|
||||
expect(runLocalEgressAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AppPlatform,
|
||||
CreateDeviceRunSessionMutation,
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionEgress,
|
||||
DeviceRunSessionResourceClass,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
@@ -17,6 +18,7 @@ import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSession
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { runLocalEgressAsync } from '../../../simulator/egress';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_HEADER,
|
||||
@@ -38,6 +40,8 @@ jest.mock('../../../log', () => ({
|
||||
log: jest.fn(),
|
||||
newLine: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
withTick: jest.fn(),
|
||||
},
|
||||
link: jest.fn((url: string) => url),
|
||||
@@ -49,6 +53,10 @@ jest.mock('../../../simulator/env', () => ({
|
||||
}));
|
||||
jest.mock('../../../simulator/expoGo');
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../simulator/egress', () => ({
|
||||
...jest.requireActual('../../../simulator/egress'),
|
||||
runLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
const spinner = {
|
||||
@@ -856,6 +864,239 @@ describe(Simulator, () => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
const localEgressSessions: [
|
||||
string,
|
||||
DeviceRunSessionType,
|
||||
NonNullable<DeviceRunSessionById['remoteConfig']>,
|
||||
][] = [
|
||||
[
|
||||
'agent-device',
|
||||
DeviceRunSessionType.AgentDevice,
|
||||
{
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig',
|
||||
agentDeviceRemoteSessionUrl: 'https://agent.example.com',
|
||||
agentDeviceRemoteSessionToken: 'token',
|
||||
},
|
||||
],
|
||||
[
|
||||
'appium',
|
||||
DeviceRunSessionType.Appium,
|
||||
{
|
||||
__typename: 'AppiumRunSessionRemoteConfig',
|
||||
appiumUrl: 'https://appium.example.com',
|
||||
capabilities: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
'argent',
|
||||
DeviceRunSessionType.Argent,
|
||||
{
|
||||
__typename: 'ArgentRunSessionRemoteConfig',
|
||||
toolsUrl: 'https://argent.example.com',
|
||||
},
|
||||
],
|
||||
[
|
||||
'web-preview-only',
|
||||
DeviceRunSessionType.WebPreviewOnly,
|
||||
{
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://preview.example.com',
|
||||
},
|
||||
],
|
||||
[
|
||||
'web-preview-only',
|
||||
DeviceRunSessionType.ServeSim,
|
||||
{
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: 'https://preview.example.com',
|
||||
},
|
||||
],
|
||||
];
|
||||
const localEgressFields = {
|
||||
egressUrl: 'https://egress.example.com',
|
||||
egressToken: 'pw',
|
||||
egressFingerprint: 'fp',
|
||||
egressPort: 8899,
|
||||
};
|
||||
|
||||
it.each(localEgressSessions)(
|
||||
'creates iOS %s sessions with local egress and saves connection details',
|
||||
async (typeFlag, type, remoteConfig) => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type,
|
||||
remoteConfig: { ...remoteConfig, ...localEgressFields },
|
||||
})
|
||||
);
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--type',
|
||||
typeFlag,
|
||||
'--egress',
|
||||
'local',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
platform: AppPlatform.Ios,
|
||||
egress: DeviceRunSessionEgress.Local,
|
||||
})
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
expect.stringContaining("EAS_SIMULATOR_EGRESS_TOKEN='pw'")
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('eas simulator:egress'));
|
||||
expect(runLocalEgressAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('prints the shell credential command when local egress uses env output', async () => {
|
||||
const [typeFlag, type, remoteConfig] = localEgressSessions[1];
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type,
|
||||
remoteConfig: { ...remoteConfig, ...localEgressFields },
|
||||
})
|
||||
);
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--type',
|
||||
typeFlag,
|
||||
'--egress',
|
||||
'local',
|
||||
'--out-config-type',
|
||||
'env',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Run the egress client to connect the tunnel:\n\neas simulator:egress --config-type env\n'
|
||||
)
|
||||
);
|
||||
expect(Log.log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Run the egress client to connect the tunnel:\n\neas simulator:egress\n'
|
||||
)
|
||||
);
|
||||
// The instructions block is the only place the command is mentioned.
|
||||
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('in another process'));
|
||||
});
|
||||
|
||||
it.each(localEgressSessions)(
|
||||
'rejects local egress for Android %s before creating a session',
|
||||
async typeFlag => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'android',
|
||||
'--type',
|
||||
typeFlag,
|
||||
'--egress',
|
||||
'local',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'--egress local is only supported with --platform ios.'
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(localEgressSessions)(
|
||||
'stops %s after an interactive egress failure and removes its interrupt handler',
|
||||
async (typeFlag, type, remoteConfig) => {
|
||||
const failure = new Error('listen EADDRINUSE');
|
||||
jest.mocked(runLocalEgressAsync).mockRejectedValueOnce(failure);
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type,
|
||||
remoteConfig: { ...remoteConfig, ...localEgressFields },
|
||||
})
|
||||
);
|
||||
const listeners = process.listeners('SIGINT');
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--type',
|
||||
typeFlag,
|
||||
'--egress',
|
||||
'local',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toBe(failure);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(process.listeners('SIGINT')).toEqual(listeners);
|
||||
}
|
||||
);
|
||||
|
||||
it('passes normalized --egress-allow destinations to the egress client', async () => {
|
||||
const session = makeDeviceRunSession({
|
||||
remoteConfig: {
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig',
|
||||
agentDeviceRemoteSessionUrl: 'https://agent.example.com',
|
||||
agentDeviceRemoteSessionToken: 'token',
|
||||
egressUrl: 'https://egress.example.com',
|
||||
egressToken: 'pw',
|
||||
egressFingerprint: 'fp',
|
||||
egressPort: 8899,
|
||||
},
|
||||
});
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(session)
|
||||
.mockResolvedValueOnce({ ...session, status: DeviceRunSessionStatus.Stopped });
|
||||
jest.mocked(runLocalEgressAsync).mockResolvedValueOnce(undefined);
|
||||
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--egress',
|
||||
'local',
|
||||
'--egress-allow',
|
||||
'localhost:3000',
|
||||
'--egress-allow',
|
||||
'LOCALHOST:3000',
|
||||
'--egress-allow',
|
||||
'192.168.1.20:8080',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(runLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ allow: ['localhost:3000', '192.168.1.20:8080'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed --egress-allow values before creating a session', async () => {
|
||||
// If validation were skipped, fail fast instead of polling a live session.
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped })
|
||||
);
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--egress',
|
||||
'local',
|
||||
'--egress-allow',
|
||||
'localhost',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow('Invalid --egress-allow value "localhost"');
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires --egress for --egress-allow', async () => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--egress-allow', 'localhost:3000']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/--egress/);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts to select the platform when --platform is omitted', async () => {
|
||||
mockPromptAsync.mockResolvedValueOnce({ selectedPlatform: AppPlatform.Android });
|
||||
mockByIdAsync
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import Log from '../../log';
|
||||
import { readLocalEgressConfigFromEnv, runLocalEgressAsync } from '../../simulator/egress';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
|
||||
export default class SimulatorEgress extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:egress'];
|
||||
static override description = `[EXPERIMENTAL] run the local egress client for a simulator session to route proxied HTTP(S) requests through this machine`;
|
||||
|
||||
static override flags = {
|
||||
'config-type': Flags.option({
|
||||
description: `Read session credentials from ${SIMULATOR_DOTENV_FILE_NAME} (dotenv) or the current shell environment (env).`,
|
||||
options: ['dotenv', 'env'] as const,
|
||||
default: 'dotenv',
|
||||
})(),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorEgress);
|
||||
const { projectDir } = await this.getContextAsync(SimulatorEgress, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
if (flags['config-type'] === 'dotenv') {
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
}
|
||||
|
||||
const egress = readLocalEgressConfigFromEnv(process.env);
|
||||
const deviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
|
||||
Log.log(
|
||||
`Starting the local egress client${
|
||||
deviceRunSessionId ? ` for simulator session ${deviceRunSessionId}` : ''
|
||||
}. When connected, proxied HTTP(S) requests can use this machine's network.`
|
||||
);
|
||||
Log.log(
|
||||
'Press Ctrl+C to stop the egress client. The simulator session keeps running; ' +
|
||||
`proxied HTTP(S) requests are unavailable until the tunnel reconnects. Stop the session with \`eas simulator:stop${deviceRunSessionId ? ` --id ${deviceRunSessionId}` : ''}\`.`
|
||||
);
|
||||
Log.newLine();
|
||||
|
||||
const abortController = new AbortController();
|
||||
const handleSigint = (): void => {
|
||||
abortController.abort();
|
||||
};
|
||||
process.on('SIGINT', handleSigint);
|
||||
try {
|
||||
await runLocalEgressAsync({
|
||||
...egress,
|
||||
signal: abortController.signal,
|
||||
onConnected: () => {
|
||||
Log.succeed('Egress tunnel connected.');
|
||||
},
|
||||
onDisconnected: () => {
|
||||
Log.warn(
|
||||
'Egress tunnel disconnected; reconnecting. Proxied HTTP(S) requests are unavailable until it reconnects.'
|
||||
);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
process.removeListener('SIGINT', handleSigint);
|
||||
}
|
||||
Log.log('Egress client stopped.');
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
deviceRunSessionTypeToFlagValue,
|
||||
formatRemoteSessionInstructions,
|
||||
sanitizeRemoteConfigForJson,
|
||||
} from '../../simulator/utils';
|
||||
import { formatBytes } from '../../utils/files';
|
||||
import formatFields, { FormatFieldsItem } from '../../utils/formatFields';
|
||||
@@ -96,7 +97,9 @@ export default class SimulatorGet extends EasCommand {
|
||||
finishedAt: session.finishedAt ?? undefined,
|
||||
updatedAt: session.updatedAt,
|
||||
deviceRunSessionUrl,
|
||||
remoteConfig: session.remoteConfig,
|
||||
remoteConfig: session.remoteConfig
|
||||
? sanitizeRemoteConfigForJson(session.remoteConfig)
|
||||
: session.remoteConfig,
|
||||
artifacts: session.artifacts,
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../commandUtils/flags';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionEgress,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
JobRunStatus,
|
||||
@@ -21,6 +22,7 @@ import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQue
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { parseEgressAllowList, runLocalEgressAsync } from '../../simulator/egress';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
@@ -37,7 +39,9 @@ import {
|
||||
DeviceRunSessionRemoteConfig,
|
||||
formatRemoteSessionInstructions,
|
||||
formatSimulatorUnavailableMessage,
|
||||
getLocalEgressConfig,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
sanitizeRemoteConfigForJson,
|
||||
} from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
@@ -50,6 +54,7 @@ const OUT_CONFIG_TYPE_VALUES = {
|
||||
} as const;
|
||||
const PLATFORM_FLAG_VALUES = ['android', 'ios'] as const;
|
||||
type PlatformFlagValue = (typeof PLATFORM_FLAG_VALUES)[number];
|
||||
const EGRESS_FLAG_VALUES = ['local'] as const;
|
||||
const APP_PLATFORM_BY_FLAG_VALUE: Record<PlatformFlagValue, AppPlatform> = {
|
||||
android: AppPlatform.Android,
|
||||
ios: AppPlatform.Ios,
|
||||
@@ -132,6 +137,17 @@ export default class Simulator extends EasCommand {
|
||||
hidden: true,
|
||||
options: Object.values(DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES),
|
||||
})(),
|
||||
egress: Flags.option({
|
||||
description:
|
||||
'With "local", the simulator system proxy points at this machine: HTTP(S) and WebSocket requests that honor it (WebKit, URLSession) exit from this machine and fail while the egress client is disconnected. Requests from libraries that bypass the system proxy are not covered. The egress client must keep running for the life of the session. Only supported with --platform ios.',
|
||||
options: EGRESS_FLAG_VALUES,
|
||||
})(),
|
||||
'egress-allow': Flags.string({
|
||||
description:
|
||||
'Destination on this machine or its network that the simulator may reach through local egress, as an exact host:port (for example localhost:3000). Repeat for multiple destinations. A localhost or 127.0.0.1 entry also forwards that port from the simulator host to this machine (like adb reverse), so dev server URLs that use 127.0.0.1 work. Requires --egress local.',
|
||||
multiple: true,
|
||||
dependsOn: ['egress'],
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
description:
|
||||
'[default: true] Create a new simulator session even when an existing simulator session is present in the environment.',
|
||||
@@ -218,6 +234,16 @@ export default class Simulator extends EasCommand {
|
||||
}
|
||||
|
||||
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
|
||||
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
|
||||
if (egress && platform !== AppPlatform.Ios) {
|
||||
throw new EasCommandError('--egress local is only supported with --platform ios.');
|
||||
}
|
||||
let egressAllow: string[] = [];
|
||||
try {
|
||||
egressAllow = parseEgressAllowList(flags['egress-allow'] ?? []);
|
||||
} catch (err) {
|
||||
throw new EasCommandError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
if (platform === AppPlatform.Android) {
|
||||
Log.warn(
|
||||
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
|
||||
@@ -262,6 +288,7 @@ export default class Simulator extends EasCommand {
|
||||
...(launchArgs?.length ? { launchArgs } : {}),
|
||||
...(openUrl ? { openUrl } : {}),
|
||||
...(resourceClass ? { resourceClass } : {}),
|
||||
...(egress ? { egress } : {}),
|
||||
maxRunTimeMinutes: flags['max-duration-minutes'],
|
||||
maxIdleTimeMinutes: flags['max-idle-time-minutes'],
|
||||
});
|
||||
@@ -362,7 +389,7 @@ export default class Simulator extends EasCommand {
|
||||
|
||||
if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) {
|
||||
await writeSimulatorEnvSafelyAsync(projectDir, {
|
||||
...getRemoteSessionEnvironmentVariables(remoteConfig),
|
||||
...getRemoteSessionEnvironmentVariables(remoteConfig, { egressAllow }),
|
||||
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
|
||||
});
|
||||
}
|
||||
@@ -385,30 +412,79 @@ export default class Simulator extends EasCommand {
|
||||
name,
|
||||
type: flags.type,
|
||||
deviceRunSessionUrl,
|
||||
remoteConfig,
|
||||
remoteConfig: sanitizeRemoteConfigForJson(remoteConfig),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(formatRemoteSessionInstructions(remoteConfig, flags['out-config-type']));
|
||||
Log.log(
|
||||
formatRemoteSessionInstructions(remoteConfig, flags['out-config-type'], {
|
||||
egressAllow,
|
||||
egressClientRunsInline: !nonInteractive,
|
||||
})
|
||||
);
|
||||
Log.newLine();
|
||||
|
||||
const localEgress = getLocalEgressConfig(remoteConfig, egressAllow);
|
||||
|
||||
if (nonInteractive) {
|
||||
sessionInterrupt.dispose();
|
||||
// The instructions above already tell the reader to run eas simulator:egress.
|
||||
Log.log(
|
||||
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForSessionEndOrInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
deviceRunSessionUrl,
|
||||
projectDir,
|
||||
sessionInterrupt,
|
||||
});
|
||||
// In interactive mode the egress client runs right here, for as long as the
|
||||
// session does. A session with a disconnected tunnel still bills, so Ctrl+C stops both.
|
||||
let egressPromise: Promise<void> | undefined;
|
||||
let egressError: Error | undefined;
|
||||
const egressAbortController = new AbortController();
|
||||
if (localEgress) {
|
||||
sessionInterrupt.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
egressAbortController.abort();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
egressPromise = runLocalEgressAsync({
|
||||
...localEgress,
|
||||
signal: egressAbortController.signal,
|
||||
onConnected: () => {
|
||||
Log.succeed('Egress tunnel connected.');
|
||||
},
|
||||
onDisconnected: () => {
|
||||
Log.warn(
|
||||
'Egress tunnel disconnected; reconnecting. Proxied HTTP(S) requests are unavailable until it reconnects.'
|
||||
);
|
||||
},
|
||||
}).catch(err => {
|
||||
egressError = err instanceof Error ? err : new Error(String(err));
|
||||
Log.error(
|
||||
`${err instanceof Error ? err.message : String(err)} Stopping the simulator session.`
|
||||
);
|
||||
sessionInterrupt.abort();
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForSessionEndOrInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
deviceRunSessionUrl,
|
||||
projectDir,
|
||||
sessionInterrupt,
|
||||
});
|
||||
} finally {
|
||||
egressAbortController.abort();
|
||||
await egressPromise;
|
||||
}
|
||||
if (egressError) {
|
||||
throw egressError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +602,8 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
type SessionInterrupt = {
|
||||
signal: AbortSignal;
|
||||
abortPromise: Promise<void>;
|
||||
/** Stop the session as if the user had pressed Ctrl+C. */
|
||||
abort: () => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
@@ -557,6 +635,9 @@ function registerSessionInterrupt(deviceRunSessionId: string): SessionInterrupt
|
||||
return {
|
||||
signal,
|
||||
abortPromise,
|
||||
abort: () => {
|
||||
abortController.abort();
|
||||
},
|
||||
dispose: () => process.removeListener('SIGINT', sigintHandler),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@ export const distributionCertificateSchema: CredentialSchema<DistributionCertifi
|
||||
export type MinimalAscApiKey = {
|
||||
keyP8: string;
|
||||
keyId: string;
|
||||
issuerId: string;
|
||||
// Absent for individual API keys, which are valid only for submissions.
|
||||
issuerId?: string;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
roles?: UserRole[];
|
||||
@@ -115,7 +116,7 @@ export type MinimalAscApiKey = {
|
||||
export interface AscApiKeyPath {
|
||||
keyP8Path: string;
|
||||
keyId: string;
|
||||
issuerId: string;
|
||||
issuerId?: string;
|
||||
}
|
||||
|
||||
export const ascApiKeyIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'keyId'>> = {
|
||||
@@ -129,7 +130,7 @@ export const ascApiKeyIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'keyId'>
|
||||
],
|
||||
};
|
||||
|
||||
export const ascApiKeyIssuerIdSchema: CredentialSchema<Pick<MinimalAscApiKey, 'issuerId'>> = {
|
||||
export const ascApiKeyIssuerIdSchema: CredentialSchema<{ issuerId: string }> = {
|
||||
name: 'App Store Connect API Key',
|
||||
questions: [
|
||||
{
|
||||
|
||||
@@ -246,7 +246,11 @@ function displayAscApiKey(
|
||||
if (name) {
|
||||
fields.push({ label: 'Name', value: name });
|
||||
}
|
||||
fields.push({ label: 'Issuer ID', value: issuerIdentifier });
|
||||
if (issuerIdentifier) {
|
||||
fields.push({ label: 'Issuer ID', value: issuerIdentifier });
|
||||
} else {
|
||||
fields.push({ label: 'Key Type', value: 'Individual (submissions only)' });
|
||||
}
|
||||
if (roles) {
|
||||
fields.push({ label: 'Roles', value: roles.join(',') });
|
||||
}
|
||||
|
||||
+458
-272
File diff suppressed because it is too large.
Load diff
@@ -12,7 +12,7 @@ export const AppStoreConnectApiKeyQuery = {
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
ascApiKeyId: string
|
||||
): Promise<{
|
||||
issuerIdentifier: string;
|
||||
issuerIdentifier: string | undefined;
|
||||
keyIdentifier: string;
|
||||
keyP8: string;
|
||||
}> {
|
||||
@@ -42,7 +42,7 @@ export const AppStoreConnectApiKeyQuery = {
|
||||
const key = data.appStoreConnectApiKey.byId;
|
||||
|
||||
return {
|
||||
issuerIdentifier: key.issuerIdentifier,
|
||||
issuerIdentifier: key.issuerIdentifier ?? undefined,
|
||||
keyIdentifier: key.keyIdentifier,
|
||||
keyP8: key.keyP8,
|
||||
};
|
||||
|
||||
@@ -87,22 +87,47 @@ export const DeviceRunSessionQuery = {
|
||||
agentDeviceRemoteSessionUrl
|
||||
agentDeviceRemoteSessionToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
}
|
||||
... on ArgentRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
toolsUrl
|
||||
toolsAuthToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
}
|
||||
... on AppiumRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
appiumUrl
|
||||
capabilities
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
}
|
||||
... on ServeSimRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
previewUrl
|
||||
previewToken
|
||||
}
|
||||
... on WebPreviewOnlyRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
egressPort
|
||||
previewUrl: webPreviewUrl
|
||||
previewToken: webPreviewToken
|
||||
}
|
||||
}
|
||||
turtleJobRun {
|
||||
|
||||
@@ -5,6 +5,10 @@ import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AppObserveAppVersion,
|
||||
AppObserveError,
|
||||
AppObserveErrorGroup,
|
||||
AppObserveErrorOccurrencesFilter,
|
||||
AppObserveErrorOccurrencesOrderBy,
|
||||
AppObserveErrorsGroupsInput,
|
||||
AppObserveLogsOrderBy,
|
||||
AppObserveMetric,
|
||||
AppObserveMetricsListFilter,
|
||||
@@ -24,6 +28,8 @@ import { print } from 'graphql';
|
||||
import {
|
||||
AppObserveAppVersionFragmentNode,
|
||||
AppObserveErrorFragmentNode,
|
||||
AppObserveErrorGroupFragmentNode,
|
||||
AppObserveErrorOccurrenceFragmentNode,
|
||||
AppObserveMetricFragmentNode,
|
||||
AppObserveUserEventFragmentNode,
|
||||
} from '../types/Observe';
|
||||
@@ -156,6 +162,51 @@ type AppObserveNavigationRoutesQueryVariables = {
|
||||
orderBy?: AppObserveNavigationOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveErrorGroupsQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
errors: {
|
||||
groups: {
|
||||
isTruncated: boolean;
|
||||
groups: AppObserveErrorGroup[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveErrorGroupsQueryVariables = {
|
||||
appId: string;
|
||||
input: AppObserveErrorsGroupsInput;
|
||||
};
|
||||
|
||||
type AppObserveErrorOccurrencesQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
errors: {
|
||||
occurrences: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{ cursor: string; node: AppObserveError }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveErrorOccurrencesQueryVariables = {
|
||||
appId: string;
|
||||
filter: AppObserveErrorOccurrencesFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveErrorOccurrencesOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveSessionEventsQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
@@ -185,6 +236,35 @@ type AppObserveSessionEventsQueryVariables = {
|
||||
logsOrderBy?: AppObserveLogsOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveMetricByIdQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
metrics: {
|
||||
metric: AppObserveMetric | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveLogByIdQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
log: AppObserveSessionLog | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveByIdQueryVariables = {
|
||||
appId: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const ObserveQuery = {
|
||||
async appVersionsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
@@ -466,6 +546,99 @@ export const ObserveQuery = {
|
||||
};
|
||||
},
|
||||
|
||||
async errorGroupsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, input }: { appId: string; input: AppObserveErrorsGroupsInput }
|
||||
): Promise<{ groups: AppObserveErrorGroup[]; isTruncated: boolean }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveErrorGroupsQuery, AppObserveErrorGroupsQueryVariables>(
|
||||
gql`
|
||||
query AppObserveErrorGroups($appId: String!, $input: AppObserveErrorsGroupsInput!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
errors {
|
||||
groups(input: $input) {
|
||||
isTruncated
|
||||
groups {
|
||||
...AppObserveErrorGroupFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveErrorGroupFragmentNode)}
|
||||
`,
|
||||
{ appId, input }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.errors.groups;
|
||||
},
|
||||
|
||||
async errorOccurrencesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveErrorOccurrencesQueryVariables
|
||||
): Promise<{ occurrences: AppObserveError[]; pageInfo: PageInfo }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveErrorOccurrencesQuery, AppObserveErrorOccurrencesQueryVariables>(
|
||||
gql`
|
||||
query AppObserveErrorOccurrences(
|
||||
$appId: String!
|
||||
$filter: AppObserveErrorOccurrencesFilter
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveErrorOccurrencesOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
errors {
|
||||
occurrences(
|
||||
filter: $filter
|
||||
first: $first
|
||||
after: $after
|
||||
orderBy: $orderBy
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
...AppObserveErrorOccurrenceFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveErrorOccurrenceFragmentNode)}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { edges, pageInfo } = data.app.byId.observe.errors.occurrences;
|
||||
return {
|
||||
occurrences: edges.map(edge => edge.node),
|
||||
pageInfo,
|
||||
};
|
||||
},
|
||||
|
||||
async sessionEventsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveSessionEventsQueryVariables
|
||||
@@ -546,4 +719,75 @@ export const ObserveQuery = {
|
||||
logsPageInfo: logs.pageInfo,
|
||||
};
|
||||
},
|
||||
|
||||
async metricByIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, id }: AppObserveByIdQueryVariables
|
||||
): Promise<AppObserveMetric | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveMetricByIdQuery, AppObserveByIdQueryVariables>(
|
||||
gql`
|
||||
query AppObserveMetricById($appId: String!, $id: ID!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
metrics {
|
||||
metric(id: $id) {
|
||||
id
|
||||
...AppObserveMetricFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveMetricFragmentNode)}
|
||||
`,
|
||||
{ appId, id }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.metrics.metric ?? null;
|
||||
},
|
||||
|
||||
async logByIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, id }: AppObserveByIdQueryVariables
|
||||
): Promise<AppObserveSessionLog | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveLogByIdQuery, AppObserveByIdQueryVariables>(
|
||||
gql`
|
||||
query AppObserveLogById($appId: String!, $id: ID!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
log(id: $id) {
|
||||
id
|
||||
__typename
|
||||
... on AppObserveUserEvent {
|
||||
...AppObserveUserEventFragment
|
||||
}
|
||||
... on AppObserveError {
|
||||
...AppObserveErrorFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveUserEventFragmentNode)}
|
||||
${print(AppObserveErrorFragmentNode)}
|
||||
`,
|
||||
{ appId, id }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.log ?? null;
|
||||
},
|
||||
};
|
||||
@@ -56,6 +56,38 @@ export const AppObserveErrorFragmentNode = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveErrorOccurrenceFragmentNode = gql`
|
||||
fragment AppObserveErrorOccurrenceFragment on AppObserveError {
|
||||
id
|
||||
timestamp
|
||||
sessionId
|
||||
severityNumber
|
||||
severityText
|
||||
type
|
||||
message
|
||||
source
|
||||
fingerprint
|
||||
isFatal
|
||||
stacktrace
|
||||
body
|
||||
properties {
|
||||
key
|
||||
value
|
||||
type
|
||||
}
|
||||
appVersion
|
||||
appBuildNumber
|
||||
appUpdateId
|
||||
appEasBuildId
|
||||
deviceOs
|
||||
deviceOsVersion
|
||||
deviceModel
|
||||
environment
|
||||
easClientId
|
||||
countryCode
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveMetricFragmentNode = gql`
|
||||
fragment AppObserveMetricFragment on AppObserveMetric {
|
||||
id
|
||||
@@ -76,6 +108,23 @@ export const AppObserveMetricFragmentNode = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveErrorGroupFragmentNode = gql`
|
||||
fragment AppObserveErrorGroupFragment on AppObserveErrorGroup {
|
||||
fingerprint
|
||||
exceptionType
|
||||
exceptionMessage
|
||||
errorSource
|
||||
severity
|
||||
isFatal
|
||||
eventCount
|
||||
uniqueUserCount
|
||||
affectedSessionCount
|
||||
firstSeenAt
|
||||
lastSeenAt
|
||||
platforms
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveAppVersionFragmentNode = gql`
|
||||
fragment AppObserveAppVersionFragment on AppObserveAppVersion {
|
||||
appVersion
|
||||
|
||||
@@ -167,6 +167,7 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
'2025-01-15T10:10:00.000Z',
|
||||
]);
|
||||
expect(result.entries.map(e => e.source)).toEqual(['log', 'metric', 'log']);
|
||||
expect(result.entries.map(e => e.id)).toEqual(['evt-c-1', 'evt-m-1', 'evt-c-2']);
|
||||
});
|
||||
|
||||
it('includes error logs as log entries named by their exception type, surfacing message and properties', async () => {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { AppObserveError, AppObserveErrorGroup, PageInfo } from '../../graphql/generated';
|
||||
import {
|
||||
buildObserveErrorGroupsJson,
|
||||
buildObserveErrorGroupsTable,
|
||||
buildObserveErrorOccurrencesJson,
|
||||
buildObserveErrorOccurrencesTable,
|
||||
} from '../formatErrors';
|
||||
|
||||
function makeErrorGroup(overrides: Partial<AppObserveErrorGroup> = {}): AppObserveErrorGroup {
|
||||
return {
|
||||
__typename: 'AppObserveErrorGroup' as const,
|
||||
fingerprint: 'fp-1',
|
||||
exceptionType: 'TypeError',
|
||||
exceptionMessage: 'undefined is not a function',
|
||||
errorSource: 'js',
|
||||
severity: 'FATAL',
|
||||
isFatal: true,
|
||||
eventCount: 42,
|
||||
uniqueUserCount: 10,
|
||||
affectedSessionCount: 8,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
lastSeenAt: '2025-01-15T00:00:00.000Z',
|
||||
platforms: ['IOS'],
|
||||
...overrides,
|
||||
} as AppObserveErrorGroup;
|
||||
}
|
||||
|
||||
function makeErrorOccurrence(overrides: Partial<AppObserveError> = {}): AppObserveError {
|
||||
return {
|
||||
__typename: 'AppObserveError' as const,
|
||||
id: 'err-1',
|
||||
type: 'TypeError',
|
||||
message: 'undefined is not a function',
|
||||
source: 'js',
|
||||
fingerprint: 'fp-1',
|
||||
severityText: 'fatal',
|
||||
severityNumber: null,
|
||||
isFatal: true,
|
||||
stacktrace: 'at foo (app.js:1:1)\nat bar (app.js:2:2)',
|
||||
body: null,
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
appEasBuildId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
environment: 'production',
|
||||
easClientId: 'client-1',
|
||||
countryCode: 'US',
|
||||
properties: [],
|
||||
...overrides,
|
||||
} as AppObserveError;
|
||||
}
|
||||
|
||||
const noNextPage = {
|
||||
__typename: 'PageInfo' as const,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
endCursor: null,
|
||||
} as PageInfo;
|
||||
|
||||
describe(buildObserveErrorGroupsTable, () => {
|
||||
it('renders a placeholder when there are no error groups', () => {
|
||||
expect(buildObserveErrorGroupsTable([])).toContain('No errors found');
|
||||
});
|
||||
|
||||
it('renders the fingerprint, exception type, message, and counts', () => {
|
||||
const output = buildObserveErrorGroupsTable([makeErrorGroup()]);
|
||||
expect(output).toContain('fp-1');
|
||||
expect(output).toContain('TypeError');
|
||||
expect(output).toContain('undefined is not a function');
|
||||
expect(output).toContain('42');
|
||||
expect(output).toContain('--fingerprint');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveErrorOccurrencesTable, () => {
|
||||
it('renders a placeholder when there are no occurrences', () => {
|
||||
const output = buildObserveErrorOccurrencesTable([], noNextPage, { fingerprint: 'fp-1' });
|
||||
expect(output).toContain('No occurrences found');
|
||||
expect(output).toContain('fp-1');
|
||||
});
|
||||
|
||||
it('renders occurrence details including the stack trace', () => {
|
||||
const output = buildObserveErrorOccurrencesTable([makeErrorOccurrence()], noNextPage, {
|
||||
fingerprint: 'fp-1',
|
||||
});
|
||||
expect(output).toContain('Stack trace');
|
||||
expect(output).toContain('at foo (app.js:1:1)');
|
||||
expect(output).toContain('TypeError');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveErrorOccurrencesJson, () => {
|
||||
it('includes the stack trace and properties in the JSON', () => {
|
||||
const result = buildObserveErrorOccurrencesJson([makeErrorOccurrence()], noNextPage);
|
||||
expect(result.occurrences[0].stacktrace).toBe('at foo (app.js:1:1)\nat bar (app.js:2:2)');
|
||||
expect(result.occurrences[0].id).toBe('err-1');
|
||||
expect(result.pageInfo).toEqual({ hasNextPage: false, endCursor: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveErrorGroupsJson, () => {
|
||||
it('maps error-group fields into stable JSON keys', () => {
|
||||
const result = buildObserveErrorGroupsJson([makeErrorGroup()], false);
|
||||
expect(result).toEqual({
|
||||
isTruncated: false,
|
||||
errors: [
|
||||
{
|
||||
fingerprint: 'fp-1',
|
||||
type: 'TypeError',
|
||||
message: 'undefined is not a function',
|
||||
source: 'js',
|
||||
severity: 'FATAL',
|
||||
isFatal: true,
|
||||
eventCount: 42,
|
||||
uniqueUserCount: 10,
|
||||
affectedSessionCount: 8,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
lastSeenAt: '2025-01-15T00:00:00.000Z',
|
||||
platforms: ['IOS'],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
function makeMetricEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
|
||||
return {
|
||||
source: 'metric',
|
||||
id: 'evt-m-1',
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
appVersion: '1.0.0',
|
||||
@@ -31,6 +32,7 @@ function makeMetricEntry(overrides: Partial<SessionEventEntry> = {}): SessionEve
|
||||
function makeLogEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
|
||||
return {
|
||||
source: 'log',
|
||||
id: 'evt-c-1',
|
||||
timestamp: '2025-01-15T10:01:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
appVersion: '1.0.0',
|
||||
@@ -240,6 +242,7 @@ describe(buildObserveSessionEventsJson, () => {
|
||||
hasMoreMetricEvents: false,
|
||||
hasMoreLogEvents: true,
|
||||
});
|
||||
expect(result.entries[0].id).toBe('evt-m-1');
|
||||
});
|
||||
|
||||
it('returns null metadata when there were no entries', () => {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppObserveError,
|
||||
AppObserveErrorGroup,
|
||||
AppObserveErrorOccurrencesOrderByField,
|
||||
AppObserveErrorSeverity,
|
||||
AppObserveOrderDirection,
|
||||
AppObservePlatform,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
|
||||
export interface FetchObserveErrorGroupsOptions {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platforms?: AppObservePlatform[];
|
||||
appVersion?: string;
|
||||
buildNumber?: string;
|
||||
updateId?: string;
|
||||
environment?: string;
|
||||
severity?: AppObserveErrorSeverity;
|
||||
}
|
||||
|
||||
export async function fetchObserveErrorGroupsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchObserveErrorGroupsOptions
|
||||
): Promise<{ groups: AppObserveErrorGroup[]; isTruncated: boolean }> {
|
||||
return await ObserveQuery.errorGroupsAsync(graphqlClient, {
|
||||
appId,
|
||||
input: {
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.platforms?.length && { platforms: options.platforms }),
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
...(options.environment && { environment: options.environment }),
|
||||
...(options.severity && { severity: options.severity }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface FetchObserveErrorOccurrencesOptions {
|
||||
fingerprint: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platforms?: AppObservePlatform[];
|
||||
appVersion?: string;
|
||||
buildNumber?: string;
|
||||
updateId?: string;
|
||||
environment?: string;
|
||||
limit: number;
|
||||
after?: string;
|
||||
}
|
||||
|
||||
export async function fetchObserveErrorOccurrencesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchObserveErrorOccurrencesOptions
|
||||
): Promise<{ occurrences: AppObserveError[]; pageInfo: PageInfo }> {
|
||||
return await ObserveQuery.errorOccurrencesAsync(graphqlClient, {
|
||||
appId,
|
||||
filter: {
|
||||
fingerprint: options.fingerprint,
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.platforms?.length && { platforms: options.platforms }),
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
...(options.environment && { environment: options.environment }),
|
||||
},
|
||||
first: options.limit,
|
||||
...(options.after && { after: options.after }),
|
||||
orderBy: {
|
||||
field: AppObserveErrorOccurrencesOrderByField.Timestamp,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { fetchObserveEventsAsync, resolveOrderBy } from './fetchEvents';
|
||||
|
||||
export interface SessionEventEntry {
|
||||
source: 'metric' | 'log';
|
||||
id: string;
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
// Metric name, user-event name, or error type.
|
||||
@@ -40,6 +41,7 @@ export interface SessionEventEntry {
|
||||
function metricEventToEntry(event: AppObserveMetric): SessionEventEntry {
|
||||
return {
|
||||
source: 'metric',
|
||||
id: event.id,
|
||||
timestamp: event.timestamp,
|
||||
sessionId: event.sessionId ?? '',
|
||||
name: event.name,
|
||||
@@ -60,6 +62,7 @@ function metricEventToEntry(event: AppObserveMetric): SessionEventEntry {
|
||||
function userEventToEntry(event: AppObserveUserEvent): SessionEventEntry {
|
||||
return {
|
||||
source: 'log',
|
||||
id: event.id,
|
||||
timestamp: event.timestamp,
|
||||
sessionId: event.sessionId ?? '',
|
||||
name: event.name,
|
||||
@@ -85,6 +88,7 @@ function errorToEntry(event: AppObserveError): SessionEventEntry {
|
||||
];
|
||||
return {
|
||||
source: 'log',
|
||||
id: event.id,
|
||||
timestamp: event.timestamp,
|
||||
sessionId: event.sessionId ?? '',
|
||||
name: event.type ?? 'exception',
|
||||
|
||||
@@ -40,6 +40,66 @@ export interface ObserveCustomEventJson {
|
||||
easClientId: string;
|
||||
}
|
||||
|
||||
export function buildObserveCustomEventJson(event: AppObserveUserEvent): ObserveCustomEventJson {
|
||||
return {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
timestamp: event.timestamp,
|
||||
sessionId: event.sessionId ?? null,
|
||||
severityNumber: event.severityNumber ?? null,
|
||||
severityText: event.severityText ?? null,
|
||||
properties: event.properties.map(p => ({
|
||||
key: p.key,
|
||||
value: p.value,
|
||||
type: p.type,
|
||||
})),
|
||||
appVersion: event.appVersion,
|
||||
appBuildNumber: event.appBuildNumber,
|
||||
appUpdateId: event.appUpdateId ?? null,
|
||||
appEasBuildId: event.appEasBuildId ?? null,
|
||||
deviceModel: event.deviceModel,
|
||||
deviceOs: event.deviceOs,
|
||||
deviceOsVersion: event.deviceOsVersion,
|
||||
countryCode: event.countryCode ?? null,
|
||||
environment: event.environment ?? null,
|
||||
easClientId: event.easClientId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single user-defined (log) event as a vertical Field/Value detail
|
||||
* table, plus its properties, for `eas observe:event`.
|
||||
*/
|
||||
export function buildObserveCustomEventDetail(event: AppObserveUserEvent): string {
|
||||
const rows: string[][] = [
|
||||
['ID', event.id],
|
||||
['Type', 'Log'],
|
||||
['Event Name', event.name],
|
||||
['Timestamp', formatLogTimestamp(event.timestamp)],
|
||||
['Severity', formatSeverity(event)],
|
||||
['Session ID', event.sessionId ?? '-'],
|
||||
['App Version', `${event.appVersion} (${event.appBuildNumber})`],
|
||||
['Update ID', event.appUpdateId ?? '-'],
|
||||
['EAS Build ID', event.appEasBuildId ?? '-'],
|
||||
['Platform', `${event.deviceOs} ${event.deviceOsVersion}`],
|
||||
['Device', event.deviceModel],
|
||||
['Country', event.countryCode ?? '-'],
|
||||
['Environment', event.environment ?? '-'],
|
||||
['EAS Client ID', event.easClientId],
|
||||
];
|
||||
const lines = [chalk.bold('Log event'), '', renderTextTable(['Field', 'Value'], rows)];
|
||||
if (event.properties.length > 0) {
|
||||
const propertyRows = event.properties.map(p => [p.key, p.type, p.value]);
|
||||
lines.push(
|
||||
'',
|
||||
chalk.bold('Properties'),
|
||||
'',
|
||||
renderTextTable(['Key', 'Type', 'Value'], propertyRows)
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export interface BuildCustomEventsTableOptions {
|
||||
eventName?: string;
|
||||
daysBack?: number;
|
||||
@@ -109,29 +169,7 @@ export function buildObserveCustomEventsJson(
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
||||
} {
|
||||
return {
|
||||
events: events.map(event => ({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
timestamp: event.timestamp,
|
||||
sessionId: event.sessionId ?? null,
|
||||
severityNumber: event.severityNumber ?? null,
|
||||
severityText: event.severityText ?? null,
|
||||
properties: event.properties.map(p => ({
|
||||
key: p.key,
|
||||
value: p.value,
|
||||
type: p.type,
|
||||
})),
|
||||
appVersion: event.appVersion,
|
||||
appBuildNumber: event.appBuildNumber,
|
||||
appUpdateId: event.appUpdateId ?? null,
|
||||
appEasBuildId: event.appEasBuildId ?? null,
|
||||
deviceModel: event.deviceModel,
|
||||
deviceOs: event.deviceOs,
|
||||
deviceOsVersion: event.deviceOsVersion,
|
||||
countryCode: event.countryCode ?? null,
|
||||
environment: event.environment ?? null,
|
||||
easClientId: event.easClientId,
|
||||
})),
|
||||
events: events.map(buildObserveCustomEventJson),
|
||||
pageInfo: {
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor ?? null,
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { AppObserveError, AppObserveErrorGroup, PageInfo } from '../graphql/generated';
|
||||
import renderTextTable from '../utils/renderTextTable';
|
||||
import { buildTimeRangeDescription, formatLogTimestamp, formatTimestamp } from './formatUtils';
|
||||
|
||||
export interface ObserveErrorGroupJson {
|
||||
fingerprint: string | null;
|
||||
type: string | null;
|
||||
message: string | null;
|
||||
source: string | null;
|
||||
severity: string | null;
|
||||
isFatal: boolean | null;
|
||||
eventCount: number | null;
|
||||
uniqueUserCount: number | null;
|
||||
affectedSessionCount: number | null;
|
||||
firstSeenAt: string | null;
|
||||
lastSeenAt: string | null;
|
||||
platforms: string[];
|
||||
}
|
||||
|
||||
export interface ObserveErrorJson {
|
||||
id: string;
|
||||
type: string | null;
|
||||
message: string | null;
|
||||
source: string | null;
|
||||
fingerprint: string | null;
|
||||
severityNumber: number | null;
|
||||
severityText: string | null;
|
||||
isFatal: boolean | null;
|
||||
timestamp: string;
|
||||
sessionId: string | null;
|
||||
appVersion: string;
|
||||
appBuildNumber: string;
|
||||
appUpdateId: string | null;
|
||||
appEasBuildId: string | null;
|
||||
deviceModel: string;
|
||||
deviceOs: string;
|
||||
deviceOsVersion: string;
|
||||
countryCode: string | null;
|
||||
environment: string | null;
|
||||
easClientId: string;
|
||||
}
|
||||
|
||||
export function buildObserveErrorJson(event: AppObserveError): ObserveErrorJson {
|
||||
return {
|
||||
id: event.id,
|
||||
type: event.type ?? null,
|
||||
message: event.message ?? null,
|
||||
source: event.source ?? null,
|
||||
fingerprint: event.fingerprint ?? null,
|
||||
severityNumber: event.severityNumber ?? null,
|
||||
severityText: event.severityText ?? null,
|
||||
isFatal: event.isFatal ?? null,
|
||||
timestamp: event.timestamp,
|
||||
sessionId: event.sessionId ?? null,
|
||||
appVersion: event.appVersion,
|
||||
appBuildNumber: event.appBuildNumber,
|
||||
appUpdateId: event.appUpdateId ?? null,
|
||||
appEasBuildId: event.appEasBuildId ?? null,
|
||||
deviceModel: event.deviceModel,
|
||||
deviceOs: event.deviceOs,
|
||||
deviceOsVersion: event.deviceOsVersion,
|
||||
countryCode: event.countryCode ?? null,
|
||||
environment: event.environment ?? null,
|
||||
easClientId: event.easClientId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single error (exception) event as a vertical Field/Value detail
|
||||
* table, for `eas observe:event`.
|
||||
*/
|
||||
export function buildObserveErrorDetail(event: AppObserveError): string {
|
||||
const severity = event.isFatal ? 'fatal' : (event.severityText ?? '-');
|
||||
const rows: string[][] = [
|
||||
['ID', event.id],
|
||||
['Type', 'Error'],
|
||||
['Exception', event.type ?? '-'],
|
||||
['Message', event.message ?? '-'],
|
||||
['Source', event.source ?? '-'],
|
||||
['Fingerprint', event.fingerprint ?? '-'],
|
||||
['Severity', severity],
|
||||
['Timestamp', formatLogTimestamp(event.timestamp)],
|
||||
['Session ID', event.sessionId ?? '-'],
|
||||
['App Version', `${event.appVersion} (${event.appBuildNumber})`],
|
||||
['Update ID', event.appUpdateId ?? '-'],
|
||||
['EAS Build ID', event.appEasBuildId ?? '-'],
|
||||
['Platform', `${event.deviceOs} ${event.deviceOsVersion}`],
|
||||
['Device', event.deviceModel],
|
||||
['Country', event.countryCode ?? '-'],
|
||||
['Environment', event.environment ?? '-'],
|
||||
['EAS Client ID', event.easClientId],
|
||||
];
|
||||
return [chalk.bold('Error event'), '', renderTextTable(['Field', 'Value'], rows)].join('\n');
|
||||
}
|
||||
|
||||
export interface BuildErrorGroupsTableOptions {
|
||||
daysBack?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
function truncate(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
}
|
||||
|
||||
export function buildObserveErrorGroupsTable(
|
||||
groups: AppObserveErrorGroup[],
|
||||
options?: BuildErrorGroupsTableOptions
|
||||
): string {
|
||||
if (groups.length === 0) {
|
||||
return chalk.yellow('No errors found.');
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'Fingerprint',
|
||||
'Type',
|
||||
'Message',
|
||||
'Severity',
|
||||
'Events',
|
||||
'Users',
|
||||
'Sessions',
|
||||
'Last Seen',
|
||||
];
|
||||
const rows: string[][] = groups.map(group => [
|
||||
group.fingerprint,
|
||||
truncate(group.exceptionType ?? '-', 40),
|
||||
truncate(group.exceptionMessage ?? '-', 60),
|
||||
group.isFatal ? 'fatal' : (group.severity ?? '-').toString().toLowerCase(),
|
||||
(group.eventCount ?? 0).toLocaleString(),
|
||||
(group.uniqueUserCount ?? 0).toLocaleString(),
|
||||
(group.affectedSessionCount ?? 0).toLocaleString(),
|
||||
group.lastSeenAt ? formatTimestamp(group.lastSeenAt) : '-',
|
||||
]);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (options) {
|
||||
const timeDesc = buildTimeRangeDescription(options);
|
||||
lines.push(chalk.bold(`Errors ${timeDesc}`.trim()), '');
|
||||
}
|
||||
lines.push(renderTextTable(headers, rows));
|
||||
lines.push(
|
||||
'',
|
||||
'Pass --fingerprint <fingerprint> to see individual occurrences with stack traces.'
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveErrorGroupsJson(
|
||||
groups: AppObserveErrorGroup[],
|
||||
isTruncated: boolean
|
||||
): { errors: ObserveErrorGroupJson[]; isTruncated: boolean } {
|
||||
return {
|
||||
errors: groups.map(group => ({
|
||||
fingerprint: group.fingerprint ?? null,
|
||||
type: group.exceptionType ?? null,
|
||||
message: group.exceptionMessage ?? null,
|
||||
source: group.errorSource ?? null,
|
||||
severity: group.severity ?? null,
|
||||
isFatal: group.isFatal ?? null,
|
||||
eventCount: group.eventCount ?? null,
|
||||
uniqueUserCount: group.uniqueUserCount ?? null,
|
||||
affectedSessionCount: group.affectedSessionCount ?? null,
|
||||
firstSeenAt: group.firstSeenAt ?? null,
|
||||
lastSeenAt: group.lastSeenAt ?? null,
|
||||
platforms: group.platforms ?? [],
|
||||
})),
|
||||
isTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ObserveErrorOccurrenceJson {
|
||||
id: string;
|
||||
type: string | null;
|
||||
message: string | null;
|
||||
source: string | null;
|
||||
fingerprint: string | null;
|
||||
severityNumber: number | null;
|
||||
severityText: string | null;
|
||||
isFatal: boolean | null;
|
||||
stacktrace: string | null;
|
||||
body: string | null;
|
||||
timestamp: string;
|
||||
sessionId: string | null;
|
||||
appVersion: string;
|
||||
appBuildNumber: string;
|
||||
appUpdateId: string | null;
|
||||
appEasBuildId: string | null;
|
||||
deviceModel: string;
|
||||
deviceOs: string;
|
||||
deviceOsVersion: string;
|
||||
countryCode: string | null;
|
||||
environment: string | null;
|
||||
easClientId: string;
|
||||
properties: Array<{ key: string; value: string; type: string }>;
|
||||
}
|
||||
|
||||
export function buildObserveErrorOccurrenceJson(
|
||||
occurrence: AppObserveError
|
||||
): ObserveErrorOccurrenceJson {
|
||||
return {
|
||||
id: occurrence.id,
|
||||
type: occurrence.type ?? null,
|
||||
message: occurrence.message ?? null,
|
||||
source: occurrence.source ?? null,
|
||||
fingerprint: occurrence.fingerprint ?? null,
|
||||
severityNumber: occurrence.severityNumber ?? null,
|
||||
severityText: occurrence.severityText ?? null,
|
||||
isFatal: occurrence.isFatal ?? null,
|
||||
stacktrace: occurrence.stacktrace ?? null,
|
||||
body: occurrence.body ?? null,
|
||||
timestamp: occurrence.timestamp,
|
||||
sessionId: occurrence.sessionId ?? null,
|
||||
appVersion: occurrence.appVersion,
|
||||
appBuildNumber: occurrence.appBuildNumber,
|
||||
appUpdateId: occurrence.appUpdateId ?? null,
|
||||
appEasBuildId: occurrence.appEasBuildId ?? null,
|
||||
deviceModel: occurrence.deviceModel,
|
||||
deviceOs: occurrence.deviceOs,
|
||||
deviceOsVersion: occurrence.deviceOsVersion,
|
||||
countryCode: occurrence.countryCode ?? null,
|
||||
environment: occurrence.environment ?? null,
|
||||
easClientId: occurrence.easClientId,
|
||||
properties: occurrence.properties.map(p => ({ key: p.key, value: p.value, type: p.type })),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildObserveErrorOccurrencesJson(
|
||||
occurrences: AppObserveError[],
|
||||
pageInfo: PageInfo
|
||||
): {
|
||||
occurrences: ObserveErrorOccurrenceJson[];
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
||||
} {
|
||||
return {
|
||||
occurrences: occurrences.map(buildObserveErrorOccurrenceJson),
|
||||
pageInfo: {
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single error occurrence as a Field/Value detail table followed by
|
||||
* its stack trace (and body, if present), for `eas observe:errors --fingerprint`.
|
||||
*/
|
||||
function buildObserveErrorOccurrenceDetail(occurrence: AppObserveError): string {
|
||||
const severity = occurrence.isFatal ? 'fatal' : (occurrence.severityText ?? '-');
|
||||
const rows: string[][] = [
|
||||
['ID', occurrence.id],
|
||||
['Exception', occurrence.type ?? '-'],
|
||||
['Message', occurrence.message ?? '-'],
|
||||
['Source', occurrence.source ?? '-'],
|
||||
['Severity', severity],
|
||||
['Timestamp', formatLogTimestamp(occurrence.timestamp)],
|
||||
['Session ID', occurrence.sessionId ?? '-'],
|
||||
['App Version', `${occurrence.appVersion} (${occurrence.appBuildNumber})`],
|
||||
['Update ID', occurrence.appUpdateId ?? '-'],
|
||||
['Platform', `${occurrence.deviceOs} ${occurrence.deviceOsVersion}`],
|
||||
['Device', occurrence.deviceModel],
|
||||
['Country', occurrence.countryCode ?? '-'],
|
||||
['Environment', occurrence.environment ?? '-'],
|
||||
];
|
||||
const lines = [renderTextTable(['Field', 'Value'], rows)];
|
||||
if (occurrence.stacktrace) {
|
||||
lines.push('', chalk.bold('Stack trace'), '', occurrence.stacktrace);
|
||||
}
|
||||
if (occurrence.body) {
|
||||
lines.push('', chalk.bold('Body'), '', occurrence.body);
|
||||
}
|
||||
if (occurrence.properties.length > 0) {
|
||||
const propertyRows = occurrence.properties.map(p => [p.key, p.type, p.value]);
|
||||
lines.push(
|
||||
'',
|
||||
chalk.bold('Properties'),
|
||||
'',
|
||||
renderTextTable(['Key', 'Type', 'Value'], propertyRows)
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export interface BuildErrorOccurrencesOptions {
|
||||
fingerprint: string;
|
||||
daysBack?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
export function buildObserveErrorOccurrencesTable(
|
||||
occurrences: AppObserveError[],
|
||||
pageInfo: PageInfo,
|
||||
options: BuildErrorOccurrencesOptions
|
||||
): string {
|
||||
if (occurrences.length === 0) {
|
||||
return chalk.yellow(`No occurrences found for fingerprint "${options.fingerprint}".`);
|
||||
}
|
||||
|
||||
const timeDesc = buildTimeRangeDescription(options);
|
||||
const lines: string[] = [chalk.bold(`Occurrences of ${options.fingerprint} ${timeDesc}`.trim())];
|
||||
occurrences.forEach((occurrence, index) => {
|
||||
lines.push(
|
||||
'',
|
||||
chalk.bold(`Occurrence ${index + 1}`),
|
||||
'',
|
||||
buildObserveErrorOccurrenceDetail(occurrence)
|
||||
);
|
||||
});
|
||||
|
||||
if (pageInfo.hasNextPage && pageInfo.endCursor) {
|
||||
lines.push('', `Next page: --after ${pageInfo.endCursor}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import chalk from 'chalk';
|
||||
|
||||
import { AppObserveMetric, PageInfo } from '../graphql/generated';
|
||||
import renderTextTable from '../utils/renderTextTable';
|
||||
import { buildTimeRangeDescription, formatTimestamp } from './formatUtils';
|
||||
import { buildTimeRangeDescription, formatLogTimestamp, formatTimestamp } from './formatUtils';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
|
||||
export interface ObserveEventJson {
|
||||
@@ -27,6 +27,50 @@ function resolveCustomParams(event: AppObserveMetric): { [key: string]: any } |
|
||||
return event.customParams ?? null;
|
||||
}
|
||||
|
||||
export function buildObserveEventJson(event: AppObserveMetric): ObserveEventJson {
|
||||
return {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
value: event.value,
|
||||
appVersion: event.appVersion,
|
||||
appBuildNumber: event.appBuildNumber,
|
||||
appUpdateId: event.appUpdateId ?? null,
|
||||
deviceModel: event.deviceModel,
|
||||
deviceOs: event.deviceOs,
|
||||
deviceOsVersion: event.deviceOsVersion,
|
||||
countryCode: event.countryCode ?? null,
|
||||
sessionId: event.sessionId ?? null,
|
||||
easClientId: event.easClientId,
|
||||
timestamp: event.timestamp,
|
||||
customParams: resolveCustomParams(event),
|
||||
routeName: event.routeName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single metric event as a vertical Field/Value detail table, for
|
||||
* `eas observe:event`.
|
||||
*/
|
||||
export function buildObserveEventDetail(event: AppObserveMetric): string {
|
||||
const rows: string[][] = [
|
||||
['ID', event.id],
|
||||
['Type', 'Metric'],
|
||||
['Metric', getMetricDisplayName(event.name)],
|
||||
['Value', `${event.value.toFixed(2)}s`],
|
||||
['Timestamp', formatLogTimestamp(event.timestamp)],
|
||||
['Session ID', event.sessionId ?? '-'],
|
||||
['Route', event.routeName ?? '-'],
|
||||
['App Version', `${event.appVersion} (${event.appBuildNumber})`],
|
||||
['Update ID', event.appUpdateId ?? '-'],
|
||||
['Platform', `${event.deviceOs} ${event.deviceOsVersion}`],
|
||||
['Device', event.deviceModel],
|
||||
['Country', event.countryCode ?? '-'],
|
||||
['EAS Client ID', event.easClientId],
|
||||
['Custom Params', event.customParams ? JSON.stringify(event.customParams) : '-'],
|
||||
];
|
||||
return [chalk.bold('Metric event'), '', renderTextTable(['Field', 'Value'], rows)].join('\n');
|
||||
}
|
||||
|
||||
export interface BuildEventsTableOptions {
|
||||
metricName: string;
|
||||
daysBack?: number;
|
||||
@@ -92,23 +136,7 @@ export function buildObserveEventsJson(
|
||||
pageInfo: PageInfo
|
||||
): { events: ObserveEventJson[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } } {
|
||||
return {
|
||||
events: events.map(event => ({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
value: event.value,
|
||||
appVersion: event.appVersion,
|
||||
appBuildNumber: event.appBuildNumber,
|
||||
appUpdateId: event.appUpdateId ?? null,
|
||||
deviceModel: event.deviceModel,
|
||||
deviceOs: event.deviceOs,
|
||||
deviceOsVersion: event.deviceOsVersion,
|
||||
countryCode: event.countryCode ?? null,
|
||||
sessionId: event.sessionId ?? null,
|
||||
easClientId: event.easClientId,
|
||||
timestamp: event.timestamp,
|
||||
customParams: resolveCustomParams(event),
|
||||
routeName: event.routeName ?? null,
|
||||
})),
|
||||
events: events.map(buildObserveEventJson),
|
||||
pageInfo: {
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor ?? null,
|
||||
|
||||
@@ -0,0 +1,938 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import * as fs from 'fs-extra';
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
import dns from 'node:dns/promises';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
|
||||
import {
|
||||
EgressPolicyError,
|
||||
buildChiselClientArgs,
|
||||
classifyChiselClientLogLine,
|
||||
createEgressTargetResolver,
|
||||
getChiselAssetName,
|
||||
isChiselRemoteRejectionLine,
|
||||
isForbiddenEgressAddress,
|
||||
orderEgressAddresses,
|
||||
parseEgressAllowList,
|
||||
readLocalEgressConfigFromEnv,
|
||||
resolveEgressTargetAsync,
|
||||
runLocalEgressAsync,
|
||||
startLocalEgressProxyServerAsync,
|
||||
} from '../egress';
|
||||
import Log from '../../log';
|
||||
|
||||
jest.mock('../../log');
|
||||
jest.mock('@expo/spawn-async');
|
||||
jest.mock('fs-extra', () => ({
|
||||
...jest.requireActual('fs-extra'),
|
||||
pathExists: jest.fn(),
|
||||
}));
|
||||
|
||||
async function waitForAsync(predicate: () => boolean): Promise<void> {
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('Timed out waiting for socket cleanup.');
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
describe(isForbiddenEgressAddress, () => {
|
||||
it.each([
|
||||
'127.0.0.1',
|
||||
'127.255.255.254',
|
||||
'10.0.0.5',
|
||||
'172.16.0.1',
|
||||
'172.31.255.255',
|
||||
'192.168.1.20',
|
||||
'169.254.169.254',
|
||||
'100.64.0.1',
|
||||
'0.0.0.0',
|
||||
'224.0.0.1',
|
||||
'255.255.255.255',
|
||||
'::1',
|
||||
'::',
|
||||
'fe80::1',
|
||||
'fd12:3456::1',
|
||||
'fc00::1',
|
||||
'ff02::1',
|
||||
'::ffff:127.0.0.1',
|
||||
'::ffff:192.168.0.10',
|
||||
'::ffff:c0a8:a',
|
||||
'not-an-ip',
|
||||
])('forbids %s', address => {
|
||||
expect(isForbiddenEgressAddress(address)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'8.8.8.8',
|
||||
'1.1.1.1',
|
||||
'172.15.0.1',
|
||||
'172.32.0.1',
|
||||
'100.63.0.1',
|
||||
'100.128.0.1',
|
||||
'2606:4700:4700::1111',
|
||||
'2001:db8::1',
|
||||
'::ffff:8.8.8.8',
|
||||
])('allows %s', address => {
|
||||
expect(isForbiddenEgressAddress(address)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveEgressTargetAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each(['localhost', 'LOCALHOST', 'api.localhost', 'printer.local', '127.0.0.1', '[::1]'])(
|
||||
'refuses %s without resolving it',
|
||||
async host => {
|
||||
await expect(resolveEgressTargetAsync(host, 443)).rejects.toBeInstanceOf(EgressPolicyError);
|
||||
}
|
||||
);
|
||||
|
||||
it('returns a public IP literal unchanged', async () => {
|
||||
await expect(resolveEgressTargetAsync('1.1.1.1', 443)).resolves.toEqual(['1.1.1.1']);
|
||||
});
|
||||
|
||||
it('refuses a DNS name if any record is private', async () => {
|
||||
const lookup = jest.spyOn(dns, 'lookup').mockResolvedValue([
|
||||
{ address: '1.1.1.1', family: 4 },
|
||||
{ address: '192.168.1.10', family: 4 },
|
||||
] as never);
|
||||
await expect(resolveEgressTargetAsync('mixed.example', 443)).rejects.toBeInstanceOf(
|
||||
EgressPolicyError
|
||||
);
|
||||
expect(lookup).toHaveBeenCalledTimes(1);
|
||||
expect(lookup).toHaveBeenCalledWith('mixed.example', { all: true, verbatim: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe(orderEgressAddresses, () => {
|
||||
it('puts IPv4 addresses first and drops duplicates', () => {
|
||||
expect(
|
||||
orderEgressAddresses([
|
||||
{ address: '2600:1f13::1', family: 6 },
|
||||
{ address: '34.223.124.45', family: 4 },
|
||||
{ address: '34.223.124.45', family: 4 },
|
||||
{ address: '2600:1f13::2', family: 6 },
|
||||
])
|
||||
).toEqual(['34.223.124.45', '2600:1f13::1', '2600:1f13::2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildChiselClientArgs, () => {
|
||||
it('pins the server key, reconnects forever, and reverse-forwards the loopback proxy port', () => {
|
||||
expect(
|
||||
buildChiselClientArgs({
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
fingerprint: 'fp=',
|
||||
port: 8899,
|
||||
})
|
||||
).toEqual([
|
||||
'client',
|
||||
'--fingerprint',
|
||||
'fp=',
|
||||
'--keepalive',
|
||||
'25s',
|
||||
'--max-retry-count',
|
||||
'-1',
|
||||
'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
'R:127.0.0.1:8899:127.0.0.1:8899',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds one loopback reverse remote per forwarded port after the proxy remote', () => {
|
||||
expect(
|
||||
buildChiselClientArgs({
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
fingerprint: 'fp=',
|
||||
port: 8899,
|
||||
localPort: 51000,
|
||||
forwardPorts: [3000, 8082],
|
||||
}).slice(-3)
|
||||
).toEqual([
|
||||
'R:127.0.0.1:8899:127.0.0.1:51000',
|
||||
'R:127.0.0.1:3000:127.0.0.1:3000',
|
||||
'R:127.0.0.1:8082:127.0.0.1:8082',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isChiselRemoteRejectionLine, () => {
|
||||
it('recognizes denied and unbindable reverse remotes', () => {
|
||||
expect(
|
||||
isChiselRemoteRejectionLine("2026/09/10 client: access to 'R:127.0.0.1:8082' denied")
|
||||
).toBe(true);
|
||||
expect(
|
||||
isChiselRemoteRejectionLine(
|
||||
'2026/09/10 client: Connection error: listen tcp 127.0.0.1:8082: bind: address already in use'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(isChiselRemoteRejectionLine('2026/09/10 client: Connected (Latency 41ms)')).toBe(false);
|
||||
expect(
|
||||
isChiselRemoteRejectionLine('2026/09/10 client: Connection error: websocket: bad handshake')
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(classifyChiselClientLogLine, () => {
|
||||
it('recognizes chisel connection state lines', () => {
|
||||
expect(classifyChiselClientLogLine('2026/09/07 client: Connected (Latency 41ms)')).toBe(
|
||||
'connected'
|
||||
);
|
||||
expect(classifyChiselClientLogLine('2026/09/07 client: Disconnected')).toBe('disconnected');
|
||||
expect(classifyChiselClientLogLine('2026/09/07 client: Retrying in 1s...')).toBe(
|
||||
'disconnected'
|
||||
);
|
||||
expect(classifyChiselClientLogLine('2026/09/07 client: Fingerprint fp=')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe(getChiselAssetName, () => {
|
||||
it('maps supported platforms and rejects the rest', () => {
|
||||
expect(getChiselAssetName({ platform: 'darwin', arch: 'arm64' })).toBe(
|
||||
'chisel_1.12.0_darwin_arm64.gz'
|
||||
);
|
||||
expect(getChiselAssetName({ platform: 'linux', arch: 'x64' })).toBe(
|
||||
'chisel_1.12.0_linux_amd64.gz'
|
||||
);
|
||||
expect(() => getChiselAssetName({ platform: 'win32', arch: 'x64' })).toThrow(
|
||||
'not supported on win32/x64'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(readLocalEgressConfigFromEnv, () => {
|
||||
it('reads the four egress variables', () => {
|
||||
expect(
|
||||
readLocalEgressConfigFromEnv({
|
||||
EAS_SIMULATOR_EGRESS_URL: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
EAS_SIMULATOR_EGRESS_TOKEN: 'pw',
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT: 'fp=',
|
||||
EAS_SIMULATOR_EGRESS_PORT: '8899',
|
||||
})
|
||||
).toEqual({
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp=',
|
||||
port: 8899,
|
||||
allow: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the allowed local destinations written by --egress-allow', () => {
|
||||
expect(
|
||||
readLocalEgressConfigFromEnv({
|
||||
EAS_SIMULATOR_EGRESS_URL: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
EAS_SIMULATOR_EGRESS_TOKEN: 'pw',
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT: 'fp=',
|
||||
EAS_SIMULATOR_EGRESS_PORT: '8899',
|
||||
EAS_SIMULATOR_EGRESS_ALLOW: 'localhost:3000, 192.168.1.20:8080',
|
||||
}).allow
|
||||
).toEqual(['localhost:3000', '192.168.1.20:8080']);
|
||||
});
|
||||
|
||||
it('explains how to start a session with egress when the variables are missing', () => {
|
||||
expect(() => readLocalEgressConfigFromEnv({ EAS_SIMULATOR_SESSION_ID: 'abc' })).toThrow(
|
||||
'was not started with local egress'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseEgressAllowList, () => {
|
||||
it('normalizes exact host and port pairs and drops duplicates', () => {
|
||||
expect(
|
||||
parseEgressAllowList([
|
||||
'localhost:3000',
|
||||
' LOCALHOST:3000 ',
|
||||
'192.168.1.20:8080',
|
||||
'[::1]:3000',
|
||||
'[0:0:0:0:0:0:0:1]:3000',
|
||||
'dev-box.lan:443',
|
||||
])
|
||||
).toEqual(['localhost:3000', '192.168.1.20:8080', '[::1]:3000', 'dev-box.lan:443']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'localhost',
|
||||
'localhost:0',
|
||||
'localhost:70000',
|
||||
'*.test:80',
|
||||
'10.0.0.0/8:80',
|
||||
'http://localhost:3000',
|
||||
'[not-an-ip]:3000',
|
||||
':3000',
|
||||
',localhost:3000',
|
||||
'localhost,:3000',
|
||||
'user@localhost:3000',
|
||||
'localhost#ignored:3000',
|
||||
'localhost?ignored:3000',
|
||||
"local'host:3000",
|
||||
'local\\host:3000',
|
||||
])('rejects %s', entry => {
|
||||
expect(() => parseEgressAllowList([entry])).toThrow('Invalid --egress-allow value');
|
||||
});
|
||||
|
||||
it('normalizes IDN hostnames containing combining marks', () => {
|
||||
expect(parseEgressAllowList(['cafe\u0301.example:3000'])).toEqual(['xn--caf-dma.example:3000']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createEgressTargetResolver, () => {
|
||||
it.each([
|
||||
['[0:0:0:0:0:0:0:1]:3000', '[::1]', '::1'],
|
||||
['127.1:3000', '127.0.0.1', '127.0.0.1'],
|
||||
['[::ffff:127.0.0.1]:3000', '[::ffff:7f00:1]', '::ffff:7f00:1'],
|
||||
])('matches %s after HTTP URL normalization', async (entry, hostname, address) => {
|
||||
const resolve = createEgressTargetResolver({ allow: parseEgressAllowList([entry]) });
|
||||
await expect(resolve(hostname, 3000)).resolves.toEqual([address]);
|
||||
await expect(resolve(hostname, 3001)).rejects.toBeInstanceOf(EgressPolicyError);
|
||||
await expect(resolve('localhost', 3000)).rejects.toBeInstanceOf(EgressPolicyError);
|
||||
});
|
||||
|
||||
it('returns the default policy when nothing is allowed', () => {
|
||||
expect(createEgressTargetResolver({ allow: [] })).toBe(resolveEgressTargetAsync);
|
||||
});
|
||||
|
||||
it('maps an allowed localhost destination to loopback and reports it', async () => {
|
||||
const allowed: string[] = [];
|
||||
const resolve = createEgressTargetResolver({
|
||||
allow: ['localhost:3000', '192.168.1.20:8080'],
|
||||
onAllowed: destination => allowed.push(destination),
|
||||
});
|
||||
await expect(resolve('LocalHost', 3000)).resolves.toEqual(['127.0.0.1', '::1']);
|
||||
await expect(resolve('192.168.1.20', 8080)).resolves.toEqual(['192.168.1.20']);
|
||||
expect(allowed).toEqual(['localhost:3000', '192.168.1.20:8080']);
|
||||
});
|
||||
|
||||
it('keeps refusing the same host on other ports and unlisted private addresses', async () => {
|
||||
const resolve = createEgressTargetResolver({ allow: ['localhost:3000'] });
|
||||
await expect(resolve('localhost', 3001)).rejects.toBeInstanceOf(EgressPolicyError);
|
||||
await expect(resolve('127.0.0.1', 3000)).rejects.toBeInstanceOf(EgressPolicyError);
|
||||
await expect(resolve('192.168.1.20', 8080)).rejects.toBeInstanceOf(EgressPolicyError);
|
||||
await expect(resolve('93.184.216.34', 443)).resolves.toEqual(['93.184.216.34']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startLocalEgressProxyServerAsync, () => {
|
||||
// The default resolver refuses loopback, so tests inject one that maps a fake
|
||||
// public name to the local test servers.
|
||||
let targetHttpServer: http.Server;
|
||||
let targetHttpPort: number;
|
||||
let targetEchoServer: net.Server;
|
||||
let targetEchoPort: number;
|
||||
let proxy: Awaited<ReturnType<typeof startLocalEgressProxyServerAsync>>;
|
||||
const seenHostHeaders: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
targetHttpServer = http.createServer((req, res) => {
|
||||
seenHostHeaders.push(req.headers.host ?? '');
|
||||
if (req.url === '/truncate') {
|
||||
// Announce a body, send part of it, then drop the connection.
|
||||
res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '100' });
|
||||
res.write('partial');
|
||||
res.socket?.destroy();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end(`hello from ${req.method} ${req.url}`);
|
||||
});
|
||||
await new Promise<void>(resolve => targetHttpServer.listen(0, '127.0.0.1', resolve));
|
||||
targetHttpPort = (targetHttpServer.address() as net.AddressInfo).port;
|
||||
|
||||
targetEchoServer = net.createServer(socket => socket.pipe(socket));
|
||||
await new Promise<void>(resolve => targetEchoServer.listen(0, '127.0.0.1', resolve));
|
||||
targetEchoPort = (targetEchoServer.address() as net.AddressInfo).port;
|
||||
|
||||
proxy = await startLocalEgressProxyServerAsync({
|
||||
port: 0,
|
||||
resolveTargetAsync: async hostname => {
|
||||
if (hostname === 'target.test') {
|
||||
// The test servers listen on IPv4 loopback only, so the IPv6 loopback
|
||||
// attempt is refused immediately and proves the fallback to the next address.
|
||||
return ['::1', '127.0.0.1'];
|
||||
}
|
||||
throw new EgressPolicyError(hostname);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await proxy.closeAsync();
|
||||
await new Promise<void>(resolve => {
|
||||
targetHttpServer.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => {
|
||||
targetEchoServer.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function rawRequestAsync(request: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect({ host: '127.0.0.1', port: proxy.port });
|
||||
let response = '';
|
||||
socket.on('data', chunk => (response += chunk.toString()));
|
||||
socket.on('end', () => {
|
||||
resolve(response);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
socket.on('connect', () => socket.write(request));
|
||||
});
|
||||
}
|
||||
|
||||
it('uses the absolute URL authority instead of an inconsistent Host header', async () => {
|
||||
const response = await rawRequestAsync(
|
||||
`GET http://target.test:${targetHttpPort}/path?q=1 HTTP/1.1\r\nHost: wrong.test\r\nProxy-Connection: keep-alive\r\nConnection: close\r\n\r\n`
|
||||
);
|
||||
expect(response).toContain('HTTP/1.1 200');
|
||||
expect(response).toContain('hello from GET /path?q=1');
|
||||
expect(seenHostHeaders).toContain(`target.test:${targetHttpPort}`);
|
||||
});
|
||||
|
||||
it('does not log a completed response as failed when its operation is released', async () => {
|
||||
jest.mocked(Log.debug).mockClear();
|
||||
const response = await rawRequestAsync(
|
||||
`GET http://target.test:${targetHttpPort}/done HTTP/1.1\r\nHost: target.test\r\nConnection: close\r\n\r\n`
|
||||
);
|
||||
expect(response).toContain('hello from GET /done');
|
||||
// Releasing the operation aborts the shared signal after the socket closes.
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
const messages = jest.mocked(Log.debug).mock.calls.map(([message]) => String(message));
|
||||
expect(messages).toContain(`[egress] GET target.test:${targetHttpPort}`);
|
||||
expect(messages.filter(message => message.includes('failed:'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('still logs an upstream response that is cut short as failed', async () => {
|
||||
jest.mocked(Log.debug).mockClear();
|
||||
const response = await rawRequestAsync(
|
||||
`GET http://target.test:${targetHttpPort}/truncate HTTP/1.1\r\nHost: target.test\r\nConnection: close\r\n\r\n`
|
||||
);
|
||||
// Depending on timing the client sees a 502 or a truncated 200; never a complete body.
|
||||
expect(response).not.toContain('hello from');
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
const messages = jest.mocked(Log.debug).mock.calls.map(([message]) => String(message));
|
||||
expect(messages.some(message => message.includes('failed:'))).toBe(true);
|
||||
});
|
||||
|
||||
it('tunnels CONNECT requests to the resolved address', async () => {
|
||||
const response = await new Promise<string>((resolve, reject) => {
|
||||
const socket = net.connect({ host: '127.0.0.1', port: proxy.port });
|
||||
let buffer = '';
|
||||
socket.on('error', reject);
|
||||
socket.on('connect', () =>
|
||||
socket.write(`CONNECT target.test:${targetEchoPort} HTTP/1.1\r\nHost: target.test\r\n\r\n`)
|
||||
);
|
||||
socket.on('data', chunk => {
|
||||
buffer += chunk.toString();
|
||||
if (buffer.includes('200 Connection Established') && !buffer.includes('ping')) {
|
||||
socket.write('ping');
|
||||
} else if (buffer.includes('ping')) {
|
||||
socket.end();
|
||||
resolve(buffer);
|
||||
}
|
||||
});
|
||||
});
|
||||
expect(response).toContain('HTTP/1.1 200 Connection Established');
|
||||
expect(response.endsWith('ping')).toBe(true);
|
||||
expect(proxy.getStats().total).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('refuses destinations the policy rejects with 403', async () => {
|
||||
const connectResponse = await rawRequestAsync(
|
||||
'CONNECT internal.example:443 HTTP/1.1\r\nHost: internal.example\r\n\r\n'
|
||||
);
|
||||
expect(connectResponse).toContain('HTTP/1.1 403');
|
||||
|
||||
const httpResponse = await rawRequestAsync(
|
||||
'GET http://internal.example/ HTTP/1.1\r\nHost: internal.example\r\nConnection: close\r\n\r\n'
|
||||
);
|
||||
expect(httpResponse).toContain('HTTP/1.1 403');
|
||||
});
|
||||
|
||||
it('reaches an allowed local destination and keeps refusing the rest', async () => {
|
||||
const allowedProxy = await startLocalEgressProxyServerAsync({
|
||||
port: 0,
|
||||
resolveTargetAsync: createEgressTargetResolver({ allow: [`localhost:${targetHttpPort}`] }),
|
||||
});
|
||||
const requestAsync = (request: string): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const socket = net.connect({ host: '127.0.0.1', port: allowedProxy.port });
|
||||
let response = '';
|
||||
socket.on('data', chunk => (response += chunk.toString()));
|
||||
socket.on('end', () => {
|
||||
resolve(response);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
socket.on('connect', () => socket.write(request));
|
||||
});
|
||||
try {
|
||||
const allowedResponse = await requestAsync(
|
||||
`GET http://localhost:${targetHttpPort}/allowed HTTP/1.1\r\nHost: localhost:${targetHttpPort}\r\nConnection: close\r\n\r\n`
|
||||
);
|
||||
expect(allowedResponse).toContain('HTTP/1.1 200');
|
||||
expect(allowedResponse).toContain('hello from GET /allowed');
|
||||
|
||||
const otherPortResponse = await requestAsync(
|
||||
`CONNECT localhost:${targetEchoPort} HTTP/1.1\r\nHost: localhost\r\n\r\n`
|
||||
);
|
||||
expect(otherPortResponse).toContain('HTTP/1.1 403');
|
||||
|
||||
const ipResponse = await requestAsync(
|
||||
`GET http://127.0.0.1:${targetHttpPort}/ HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`
|
||||
);
|
||||
expect(ipResponse).toContain('HTTP/1.1 403');
|
||||
} finally {
|
||||
await allowedProxy.closeAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects requests without an absolute URL', async () => {
|
||||
const response = await rawRequestAsync(
|
||||
'GET /relative HTTP/1.1\r\nHost: target.test\r\nConnection: close\r\n\r\n'
|
||||
);
|
||||
expect(response).toContain('HTTP/1.1 400');
|
||||
});
|
||||
|
||||
it.each(['GET', 'CONNECT', 'upgrade'])(
|
||||
'releases a disconnected %s client before DNS completes and never connects afterward',
|
||||
async method => {
|
||||
let resolveDns!: (addresses: string[]) => void;
|
||||
let enteredDns!: () => void;
|
||||
const started = new Promise<void>(resolve => (enteredDns = resolve));
|
||||
const deferredProxy = await startLocalEgressProxyServerAsync({
|
||||
port: 0,
|
||||
resolveTargetAsync: () => {
|
||||
enteredDns();
|
||||
return new Promise(resolve => (resolveDns = resolve));
|
||||
},
|
||||
});
|
||||
const connection = jest.fn();
|
||||
targetHttpServer.on('connection', connection);
|
||||
const client = net.connect(deferredProxy.port, '127.0.0.1');
|
||||
try {
|
||||
const target = `target.test:${targetHttpPort}`;
|
||||
client.write(
|
||||
method === 'CONNECT'
|
||||
? `CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`
|
||||
: `GET http://${target}/ HTTP/1.1\r\nHost: ${target}\r\n${method === 'upgrade' ? 'Connection: Upgrade\r\nUpgrade: websocket\r\n' : ''}\r\n`
|
||||
);
|
||||
await started;
|
||||
client.destroy();
|
||||
await waitForAsync(() => deferredProxy.getStats().active === 0);
|
||||
resolveDns(['127.0.0.1']);
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
expect(connection).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.destroy();
|
||||
targetHttpServer.removeListener('connection', connection);
|
||||
await deferredProxy.closeAsync();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('closes established CONNECT sockets without waiting for idle timeout', async () => {
|
||||
const tunnelProxy = await startLocalEgressProxyServerAsync({
|
||||
port: 0,
|
||||
resolveTargetAsync: async () => ['127.0.0.1'],
|
||||
});
|
||||
const client = net.connect(tunnelProxy.port, '127.0.0.1');
|
||||
const reply = once(client, 'data');
|
||||
client.write(`CONNECT target.test:${targetEchoPort} HTTP/1.1\r\nHost: target.test\r\n\r\n`);
|
||||
await reply;
|
||||
const closed = once(client, 'close');
|
||||
await tunnelProxy.closeAsync();
|
||||
await closed;
|
||||
expect(tunnelProxy.getStats().active).toBe(0);
|
||||
await tunnelProxy.closeAsync();
|
||||
});
|
||||
|
||||
it('reframes chunked requests/responses and removes connection-nominated fields on keep-alive', async () => {
|
||||
const received: { headers: http.IncomingHttpHeaders; body: string }[] = [];
|
||||
const target = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => (body += chunk));
|
||||
req.on('end', () => {
|
||||
received.push({ headers: req.headers, body });
|
||||
res.writeHead(200, {
|
||||
Connection: 'keep-alive, X-Response-Hop',
|
||||
'X-Response-Hop': 'remove-me',
|
||||
'Set-Cookie': ['a=1', 'b=2'],
|
||||
});
|
||||
res.write('response-');
|
||||
res.end(body);
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => target.listen(0, '127.0.0.1', resolve));
|
||||
const targetPort = (target.address() as net.AddressInfo).port;
|
||||
const agent = new http.Agent({ keepAlive: true });
|
||||
try {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const result = await new Promise<{ body: string; headers: http.IncomingHttpHeaders }>(
|
||||
(resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port: proxy.port,
|
||||
agent,
|
||||
method: 'POST',
|
||||
path: `http://target.test:${targetPort}/`,
|
||||
headers: {
|
||||
Host: 'wrong.test',
|
||||
Connection: 'keep-alive, X-Request-Hop',
|
||||
'X-Request-Hop': 'remove-me',
|
||||
TE: 'trailers',
|
||||
},
|
||||
},
|
||||
res => {
|
||||
let body = '';
|
||||
res.on('data', chunk => (body += chunk));
|
||||
res.on('error', reject);
|
||||
res.on('end', () => {
|
||||
resolve({ body, headers: res.headers });
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write('chunk-');
|
||||
req.end(String(i));
|
||||
}
|
||||
);
|
||||
expect(result.body).toBe(`response-chunk-${i}`);
|
||||
expect(result.headers['x-response-hop']).toBeUndefined();
|
||||
expect(result.headers['set-cookie']).toEqual(['a=1', 'b=2']);
|
||||
}
|
||||
expect(received.map(({ body }) => body)).toEqual(['chunk-0', 'chunk-1']);
|
||||
for (const { headers } of received) {
|
||||
expect(headers.host).toBe(`target.test:${targetPort}`);
|
||||
expect(headers['x-request-hop']).toBeUndefined();
|
||||
expect(headers.te).toBeUndefined();
|
||||
}
|
||||
} finally {
|
||||
agent.destroy();
|
||||
target.closeAllConnections();
|
||||
await new Promise<void>(resolve =>
|
||||
target.close(() => {
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['GET', 'Transfer-Encoding: chunked\r\nConnection: close', '4\r\ntest\r\n0\r\n\r\n'],
|
||||
['DELETE', 'Transfer-Encoding: chunked\r\nConnection: close', '4\r\ntest\r\n0\r\n\r\n'],
|
||||
['GET', 'Content-Length: 4\r\nConnection: close, Content-Length', 'test'],
|
||||
])('preserves a raw %s body after rebuilding its framing (%s)', async (method, framing, body) => {
|
||||
const received: { body: string; headers: http.IncomingHttpHeaders }[] = [];
|
||||
const target = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => (body += chunk));
|
||||
req.on('end', () => {
|
||||
received.push({ body, headers: req.headers });
|
||||
res.end(`received:${body}`);
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => target.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const targetPort = (target.address() as net.AddressInfo).port;
|
||||
const response = await rawRequestAsync(
|
||||
`${method} http://target.test:${targetPort}/ HTTP/1.1\r\nHost: target.test\r\n${framing}\r\n\r\n${body}`
|
||||
);
|
||||
expect(response).toContain('HTTP/1.1 200');
|
||||
expect(response).toContain('received:test');
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].body).toBe('test');
|
||||
expect(received[0].headers['transfer-encoding']).toBe('chunked');
|
||||
} finally {
|
||||
target.closeAllConnections();
|
||||
await new Promise<void>(resolve => {
|
||||
target.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects transfer-coding chains it cannot decode without connecting upstream', async () => {
|
||||
const connection = jest.fn();
|
||||
targetHttpServer.on('connection', connection);
|
||||
try {
|
||||
const response = await rawRequestAsync(
|
||||
`GET http://target.test:${targetHttpPort}/ HTTP/1.1\r\nHost: target.test\r\nTransfer-Encoding: gzip, chunked\r\nConnection: close\r\n\r\n4\r\ntest\r\n0\r\n\r\n`
|
||||
);
|
||||
expect(response).toContain('HTTP/1.1 501');
|
||||
expect(connection).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
targetHttpServer.removeListener('connection', connection);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects upstream transfer-coding chains instead of forwarding undecoded bytes', async () => {
|
||||
const target = net.createServer(socket => {
|
||||
socket.once('data', () => {
|
||||
socket.end(
|
||||
'HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n\r\n4\r\ntest\r\n0\r\n\r\n'
|
||||
);
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => target.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const targetPort = (target.address() as net.AddressInfo).port;
|
||||
const response = await rawRequestAsync(
|
||||
`GET http://target.test:${targetPort}/ HTTP/1.1\r\nHost: target.test\r\nConnection: close\r\n\r\n`
|
||||
);
|
||||
expect(response).toContain('HTTP/1.1 502');
|
||||
expect(response).not.toContain('test');
|
||||
} finally {
|
||||
await new Promise<void>(resolve => {
|
||||
target.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('closes a truncated upstream response and releases its operation', async () => {
|
||||
const target = net.createServer(socket => {
|
||||
socket.once('data', () =>
|
||||
socket.end('HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\npartial')
|
||||
);
|
||||
});
|
||||
await new Promise<void>(resolve => target.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const targetPort = (target.address() as net.AddressInfo).port;
|
||||
const response = await rawRequestAsync(
|
||||
`GET http://target.test:${targetPort}/ HTTP/1.1\r\nHost: target.test\r\nConnection: close\r\n\r\n`
|
||||
);
|
||||
expect(response).toContain('Content-Length: 100');
|
||||
expect(response).toContain('partial');
|
||||
await waitForAsync(() => proxy.getStats().active === 0);
|
||||
} finally {
|
||||
await new Promise<void>(resolve =>
|
||||
target.close(() => {
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('destroys an in-flight upstream response when the downstream client disconnects', async () => {
|
||||
let upstreamClosed = false;
|
||||
const target = http.createServer((req, res) => {
|
||||
req.socket.once('close', () => (upstreamClosed = true));
|
||||
res.writeHead(200);
|
||||
res.write('partial');
|
||||
});
|
||||
await new Promise<void>(resolve => target.listen(0, '127.0.0.1', resolve));
|
||||
const targetPort = (target.address() as net.AddressInfo).port;
|
||||
const client = net.connect(proxy.port, '127.0.0.1');
|
||||
try {
|
||||
const reply = once(client, 'data');
|
||||
client.write(`GET http://target.test:${targetPort}/ HTTP/1.1\r\nHost: target.test\r\n\r\n`);
|
||||
await reply;
|
||||
client.destroy();
|
||||
await waitForAsync(() => upstreamClosed && proxy.getStats().active === 0);
|
||||
} finally {
|
||||
client.destroy();
|
||||
target.closeAllConnections();
|
||||
await new Promise<void>(resolve =>
|
||||
target.close(() => {
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes promptly while DNS is pending and does not connect after the late answer', async () => {
|
||||
let resolveDns!: (addresses: string[]) => void;
|
||||
const deferredProxy = await startLocalEgressProxyServerAsync({
|
||||
port: 0,
|
||||
resolveTargetAsync: () => new Promise(resolve => (resolveDns = resolve)),
|
||||
});
|
||||
const client = net.connect(deferredProxy.port, '127.0.0.1');
|
||||
client.write(`CONNECT target.test:${targetEchoPort} HTTP/1.1\r\nHost: target.test\r\n\r\n`);
|
||||
await waitForAsync(() => resolveDns !== undefined);
|
||||
const closed = once(client, 'close');
|
||||
await deferredProxy.closeAsync();
|
||||
await closed;
|
||||
expect(deferredProxy.getStats().active).toBe(0);
|
||||
resolveDns(['127.0.0.1']);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(deferredProxy.getStats().active).toBe(0);
|
||||
});
|
||||
|
||||
it('filters both upgrade handshakes and closes upgraded sockets during shutdown', async () => {
|
||||
let seenHeaders: http.IncomingHttpHeaders | undefined;
|
||||
const target = http.createServer();
|
||||
target.on('upgrade', (req, socket) => {
|
||||
seenHeaders = req.headers;
|
||||
socket.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade, X-Response-Hop\r\nUpgrade: websocket\r\nX-Response-Hop: remove-me\r\n\r\n'
|
||||
);
|
||||
socket.pipe(socket);
|
||||
});
|
||||
await new Promise<void>(resolve => target.listen(0, '127.0.0.1', resolve));
|
||||
const targetPort = (target.address() as net.AddressInfo).port;
|
||||
const tunnelProxy = await startLocalEgressProxyServerAsync({
|
||||
port: 0,
|
||||
resolveTargetAsync: async () => ['127.0.0.1'],
|
||||
});
|
||||
const client = net.connect(tunnelProxy.port, '127.0.0.1');
|
||||
try {
|
||||
const reply = once(client, 'data');
|
||||
client.write(
|
||||
`GET http://target.test:${targetPort}/ HTTP/1.1\r\nHost: wrong.test\r\nConnection: Upgrade, X-Request-Hop\r\nUpgrade: websocket\r\nX-Request-Hop: remove-me\r\n\r\n`
|
||||
);
|
||||
const [handshake] = await reply;
|
||||
expect(handshake.toString()).toContain('101 Switching Protocols');
|
||||
expect(handshake.toString()).not.toContain('X-Response-Hop');
|
||||
expect(seenHeaders?.host).toBe(`target.test:${targetPort}`);
|
||||
expect(seenHeaders?.['x-request-hop']).toBeUndefined();
|
||||
const echo = once(client, 'data');
|
||||
client.write('ping');
|
||||
expect((await echo)[0].toString()).toBe('ping');
|
||||
const closed = once(client, 'close');
|
||||
await tunnelProxy.closeAsync();
|
||||
await closed;
|
||||
expect(tunnelProxy.getStats().active).toBe(0);
|
||||
} finally {
|
||||
client.destroy();
|
||||
await tunnelProxy.closeAsync();
|
||||
await new Promise<void>(resolve =>
|
||||
target.close(() => {
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(runLocalEgressAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.mocked(spawnAsync).mockReset();
|
||||
jest.mocked(fs.pathExists).mockReset();
|
||||
});
|
||||
it('does not spawn a child when interrupted during the binary cache lookup', async () => {
|
||||
let completeLookup!: (exists: boolean) => void;
|
||||
const exists = jest
|
||||
.mocked(fs.pathExists)
|
||||
.mockImplementation(() => new Promise<boolean>(resolve => (completeLookup = resolve)));
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const running = runLocalEgressAsync({
|
||||
url: 'https://example.test',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp',
|
||||
port: 8899,
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
completeLookup(true);
|
||||
await running;
|
||||
expect(spawnAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
exists.mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
it('opens a loopback reverse remote for each forwarded --egress-allow port', async () => {
|
||||
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
|
||||
let child: ChildProcess | undefined;
|
||||
jest.mocked(spawnAsync).mockImplementation(() => {
|
||||
child = new ChildProcess();
|
||||
const promise = new Promise<never>((_resolve, reject) => {
|
||||
child!.kill = jest.fn(signal => {
|
||||
Object.defineProperty(child, 'signalCode', { value: signal, configurable: true });
|
||||
reject(new Error('terminated'));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
return Object.assign(promise, { child });
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const running = runLocalEgressAsync({
|
||||
url: 'https://example.test',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp',
|
||||
port: 8899,
|
||||
allow: ['localhost:8082', '127.0.0.1:3000', 'localhost:80', '192.168.1.20:8080'],
|
||||
signal: controller.signal,
|
||||
});
|
||||
try {
|
||||
await waitForAsync(() => child !== undefined);
|
||||
const args = jest.mocked(spawnAsync).mock.calls[0][1] ?? [];
|
||||
expect(args.slice(-3)).toEqual([
|
||||
expect.stringMatching(/^R:127\.0\.0\.1:8899:127\.0\.0\.1:\d+$/),
|
||||
'R:127.0.0.1:3000:127.0.0.1:3000',
|
||||
'R:127.0.0.1:8082:127.0.0.1:8082',
|
||||
]);
|
||||
} finally {
|
||||
controller.abort();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it('uses distinct ephemeral local ports and stops both proxies and children on abort', async () => {
|
||||
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
|
||||
const children: ChildProcess[] = [];
|
||||
jest.mocked(spawnAsync).mockImplementation(() => {
|
||||
const child = new ChildProcess();
|
||||
children.push(child);
|
||||
const promise = new Promise<never>((_resolve, reject) => {
|
||||
child.kill = jest.fn(signal => {
|
||||
Object.defineProperty(child, 'signalCode', { value: signal, configurable: true });
|
||||
reject(new Error('terminated'));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
return Object.assign(promise, { child });
|
||||
});
|
||||
const controllers = [new AbortController(), new AbortController()];
|
||||
const running = controllers.map(controller =>
|
||||
runLocalEgressAsync({
|
||||
url: 'https://example.test',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp',
|
||||
port: 8899,
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
try {
|
||||
await waitForAsync(() => children.length === 2);
|
||||
const localPorts = jest.mocked(spawnAsync).mock.calls.map(([, args, options]) => {
|
||||
expect(options).toMatchObject({ ignoreStdio: true, env: { AUTH: 'eas:pw' } });
|
||||
const remote = args?.[args.length - 1] ?? '';
|
||||
expect(remote).toMatch(/^R:127\.0\.0\.1:8899:127\.0\.0\.1:\d+$/);
|
||||
return Number(remote.split(':').at(-1));
|
||||
});
|
||||
expect(new Set(localPorts).size).toBe(2);
|
||||
expect(localPorts.every(port => port > 0)).toBe(true);
|
||||
controllers.forEach(controller => {
|
||||
controller.abort();
|
||||
});
|
||||
await Promise.all(running);
|
||||
for (const child of children) {
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
}
|
||||
for (const port of localPorts) {
|
||||
const probe = net.connect(port, '127.0.0.1');
|
||||
const [error] = await once(probe, 'error');
|
||||
expect(error.code).toBe('ECONNREFUSED');
|
||||
probe.destroy();
|
||||
}
|
||||
} finally {
|
||||
controllers.forEach(controller => {
|
||||
controller.abort();
|
||||
});
|
||||
await Promise.all(running);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { parse as parseDotenv } from 'dotenv';
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
import { readLocalEgressConfigFromEnv } from '../egress';
|
||||
import { writeSimulatorEnvAsync } from '../env';
|
||||
import {
|
||||
DeviceRunSessionRemoteConfig,
|
||||
formatRemoteSessionInstructions,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
} from '../utils';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
const configs: DeviceRunSessionRemoteConfig[] = [
|
||||
{
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig',
|
||||
agentDeviceRemoteSessionUrl: 'https://agent.example.test',
|
||||
agentDeviceRemoteSessionToken: 'agent-token',
|
||||
},
|
||||
{ __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: 'https://argent.example.test' },
|
||||
{
|
||||
__typename: 'AppiumRunSessionRemoteConfig',
|
||||
appiumUrl: 'https://appium.example.test',
|
||||
capabilities: { platformName: 'iOS' },
|
||||
},
|
||||
{
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://preview.example.test',
|
||||
},
|
||||
{ __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: 'https://preview.example.test' },
|
||||
];
|
||||
|
||||
const egressFields = {
|
||||
egressUrl: 'https://egress.example.test',
|
||||
egressToken: 'test-egress-token',
|
||||
egressFingerprint: 'test-fingerprint',
|
||||
egressPort: 8899,
|
||||
};
|
||||
|
||||
describe('egress exceptions across simulator controllers', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
it.each(configs)('persists only the requested exceptions for $__typename', async config => {
|
||||
const remoteConfig = { ...config, ...egressFields };
|
||||
const egressAllow = ['localhost:3000', '[::1]:4000'];
|
||||
const environment = getRemoteSessionEnvironmentVariables(remoteConfig, { egressAllow });
|
||||
await writeSimulatorEnvAsync('/test/project', environment);
|
||||
const content = String(jest.mocked(fs.writeFile).mock.calls.at(-1)?.[1]);
|
||||
expect(readLocalEgressConfigFromEnv(parseDotenv(content))).toEqual({
|
||||
url: egressFields.egressUrl,
|
||||
token: egressFields.egressToken,
|
||||
fingerprint: egressFields.egressFingerprint,
|
||||
port: 8899,
|
||||
allow: egressAllow,
|
||||
});
|
||||
const instructions = formatRemoteSessionInstructions(remoteConfig, 'dotenv', { egressAllow });
|
||||
expect(instructions).toContain('eas simulator:egress');
|
||||
expect(instructions).toContain('localhost:3000, [::1]:4000');
|
||||
});
|
||||
|
||||
it.each(configs)(
|
||||
'explicitly clears exceptions for $__typename when none are requested',
|
||||
config => {
|
||||
const environment = getRemoteSessionEnvironmentVariables({ ...config, ...egressFields });
|
||||
expect(environment.EAS_SIMULATOR_EGRESS_ALLOW).toBe('');
|
||||
expect(
|
||||
readLocalEgressConfigFromEnv({
|
||||
EAS_SIMULATOR_EGRESS_ALLOW: 'localhost:9999',
|
||||
...environment,
|
||||
}).allow
|
||||
).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(configs)('does not grant exceptions without an egress tunnel for $__typename', config => {
|
||||
expect(
|
||||
getRemoteSessionEnvironmentVariables(config, { egressAllow: ['localhost:3000'] })
|
||||
).not.toHaveProperty('EAS_SIMULATOR_EGRESS_ALLOW');
|
||||
expect(
|
||||
formatRemoteSessionInstructions(config, 'dotenv', { egressAllow: ['localhost:3000'] })
|
||||
).not.toContain('localhost:3000');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
|
||||
import { startLocalEgressProxyServerAsync } from '../egress';
|
||||
|
||||
jest.mock('../../log');
|
||||
|
||||
async function connectAsync(port: number): Promise<net.Socket> {
|
||||
const socket = net.connect(port, '127.0.0.1');
|
||||
socket.on('error', () => {});
|
||||
await once(socket, 'connect');
|
||||
return socket;
|
||||
}
|
||||
|
||||
async function waitForClosedAsync(socket: net.Socket): Promise<void> {
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (!socket.destroyed) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error('The proxy did not reject the connection above its socket limit.');
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
describe('local egress accepted connection limit', () => {
|
||||
it.each(['CONNECT', 'upgrade'])(
|
||||
'counts idle and %s connections, excludes upstream sockets, and releases capacity',
|
||||
async method => {
|
||||
const upstreamSockets = new Set<net.Socket>();
|
||||
const target = http.createServer((_req, res) => res.end('ok'));
|
||||
target.on('connection', socket => {
|
||||
upstreamSockets.add(socket);
|
||||
socket.once('close', () => upstreamSockets.delete(socket));
|
||||
});
|
||||
target.on('upgrade', (_req, socket) => {
|
||||
socket.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n'
|
||||
);
|
||||
socket.pipe(socket);
|
||||
});
|
||||
await new Promise<void>(resolve => target.listen(0, '127.0.0.1', resolve));
|
||||
const targetPort = (target.address() as net.AddressInfo).port;
|
||||
const proxy = await startLocalEgressProxyServerAsync({
|
||||
port: 0,
|
||||
resolveTargetAsync: async () => ['127.0.0.1'],
|
||||
});
|
||||
const clients: net.Socket[] = [];
|
||||
try {
|
||||
// Fill the production limit with 511 unparsed sockets and one active tunnel.
|
||||
clients.push(
|
||||
...(await Promise.all(Array.from({ length: 511 }, () => connectAsync(proxy.port))))
|
||||
);
|
||||
const tunnel = await connectAsync(proxy.port);
|
||||
clients.push(tunnel);
|
||||
const reply = once(tunnel, 'data');
|
||||
const destination = `target.test:${targetPort}`;
|
||||
tunnel.write(
|
||||
method === 'CONNECT'
|
||||
? `CONNECT ${destination} HTTP/1.1\r\nHost: ${destination}\r\n\r\n`
|
||||
: `GET http://${destination}/ HTTP/1.1\r\nHost: ${destination}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`
|
||||
);
|
||||
expect((await reply)[0].toString()).toContain(
|
||||
method === 'CONNECT' ? '200 Connection Established' : '101 Switching Protocols'
|
||||
);
|
||||
expect(proxy.getStats().active).toBe(1);
|
||||
|
||||
const excess = await connectAsync(proxy.port);
|
||||
clients.push(excess);
|
||||
await waitForClosedAsync(excess);
|
||||
expect(proxy.getStats().refused).toBe(1);
|
||||
|
||||
// A peer-acknowledged close frees a downstream slot even though the tunnel remains.
|
||||
const closed = once(clients[0], 'close');
|
||||
clients[0].end();
|
||||
await closed;
|
||||
const replacement = await connectAsync(proxy.port);
|
||||
clients.push(replacement);
|
||||
const response = once(replacement, 'data');
|
||||
replacement.write(
|
||||
`GET http://${destination}/ HTTP/1.1\r\nHost: ${destination}\r\nConnection: close\r\n\r\n`
|
||||
);
|
||||
expect((await response)[0].toString()).toContain('200 OK');
|
||||
} finally {
|
||||
for (const client of clients) {
|
||||
client.destroy();
|
||||
}
|
||||
await proxy.closeAsync();
|
||||
for (const socket of upstreamSockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise<void>(resolve =>
|
||||
target.close(() => {
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,13 +1,23 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { parse as parseDotenv } from 'dotenv';
|
||||
import * as realFs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
EAS_SIMULATOR_EGRESS_ALLOW,
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT,
|
||||
EAS_SIMULATOR_EGRESS_PORT,
|
||||
EAS_SIMULATOR_EGRESS_TOKEN,
|
||||
EAS_SIMULATOR_EGRESS_URL,
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_HEADER,
|
||||
getSimulatorEnvFilePath,
|
||||
loadSimulatorEnvAsync,
|
||||
resetSimulatorEnvAsync,
|
||||
writeSimulatorEnvAsync,
|
||||
} from '../env';
|
||||
import { readLocalEgressConfigFromEnv } from '../egress';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
@@ -63,6 +73,124 @@ describe(resetSimulatorEnvAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(loadSimulatorEnvAsync, () => {
|
||||
let projectDir: string;
|
||||
let previousEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(async () => {
|
||||
previousEnv = { ...process.env };
|
||||
projectDir = await realFs.mkdtemp(path.join(os.tmpdir(), 'eas-simulator-env-'));
|
||||
jest.mocked(fs.readFile).mockImplementation(jest.requireActual('fs-extra').readFile);
|
||||
process.env[EAS_SIMULATOR_EGRESS_ALLOW] = 'localhost:3000';
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-a';
|
||||
process.env[EAS_SIMULATOR_EGRESS_URL] = 'https://session-a.example.test';
|
||||
process.env[EAS_SIMULATOR_EGRESS_TOKEN] = 'session-a-token';
|
||||
process.env[EAS_SIMULATOR_EGRESS_FINGERPRINT] = 'session-a-fingerprint';
|
||||
process.env[EAS_SIMULATOR_EGRESS_PORT] = '8899';
|
||||
process.env.AGENT_DEVICE_DAEMON_BASE_URL = 'https://agent-a.example.test';
|
||||
process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = 'agent-a-token';
|
||||
process.env.EAS_SIMULATOR_ENV_TEST_UNRELATED = 'keep-this';
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env = previousEnv;
|
||||
await realFs.rm(projectDir, { recursive: true, force: true });
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it.each(['', `${EAS_SIMULATOR_EGRESS_ALLOW}=''\n`])(
|
||||
"does not inherit another session's exceptions when the file has no exceptions (%j)",
|
||||
async allowLine => {
|
||||
await realFs.writeFile(
|
||||
getSimulatorEnvFilePath(projectDir),
|
||||
`${EAS_SIMULATOR_SESSION_ID}='session-b'\n` +
|
||||
`${EAS_SIMULATOR_EGRESS_URL}='https://session-b.example.test'\n` +
|
||||
allowLine
|
||||
);
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
|
||||
expect(process.env[EAS_SIMULATOR_SESSION_ID]).toBe('session-b');
|
||||
expect(process.env[EAS_SIMULATOR_EGRESS_ALLOW]).toBe('');
|
||||
}
|
||||
);
|
||||
|
||||
it('preserves shell-exported exceptions when there is no simulator file', async () => {
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
|
||||
expect(process.env[EAS_SIMULATOR_EGRESS_ALLOW]).toBe('localhost:3000');
|
||||
expect(process.env[EAS_SIMULATOR_EGRESS_URL]).toBe('https://session-a.example.test');
|
||||
expect(process.env.AGENT_DEVICE_DAEMON_BASE_URL).toBe('https://agent-a.example.test');
|
||||
expect(process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN).toBe('agent-a-token');
|
||||
expect(process.env.EAS_SIMULATOR_ENV_TEST_UNRELATED).toBe('keep-this');
|
||||
});
|
||||
|
||||
it('loads the file credentials and exceptions together over inherited values', async () => {
|
||||
await realFs.writeFile(
|
||||
getSimulatorEnvFilePath(projectDir),
|
||||
`${EAS_SIMULATOR_SESSION_ID}='session-b'\n` +
|
||||
"AGENT_DEVICE_DAEMON_BASE_URL='https://agent-b.example.test'\n" +
|
||||
"AGENT_DEVICE_DAEMON_AUTH_TOKEN='agent-b-token'\n" +
|
||||
`${EAS_SIMULATOR_EGRESS_URL}='https://session-b.example.test'\n` +
|
||||
`${EAS_SIMULATOR_EGRESS_TOKEN}='session-b-token'\n` +
|
||||
`${EAS_SIMULATOR_EGRESS_FINGERPRINT}='session-b-fingerprint'\n` +
|
||||
`${EAS_SIMULATOR_EGRESS_PORT}='8900'\n` +
|
||||
`${EAS_SIMULATOR_EGRESS_ALLOW}='localhost:4000'\n`
|
||||
);
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
|
||||
expect(readLocalEgressConfigFromEnv(process.env)).toEqual({
|
||||
url: 'https://session-b.example.test',
|
||||
token: 'session-b-token',
|
||||
fingerprint: 'session-b-fingerprint',
|
||||
port: 8900,
|
||||
allow: ['localhost:4000'],
|
||||
});
|
||||
expect(process.env[EAS_SIMULATOR_SESSION_ID]).toBe('session-b');
|
||||
expect(process.env.AGENT_DEVICE_DAEMON_BASE_URL).toBe('https://agent-b.example.test');
|
||||
expect(process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN).toBe('agent-b-token');
|
||||
expect(process.env.EAS_SIMULATOR_ENV_TEST_UNRELATED).toBe('keep-this');
|
||||
});
|
||||
|
||||
it('replaces an inherited agent session with an Appium session without mixing controls', async () => {
|
||||
const capabilities = JSON.stringify({ platformName: 'iOS' });
|
||||
await realFs.writeFile(
|
||||
getSimulatorEnvFilePath(projectDir),
|
||||
`${EAS_SIMULATOR_SESSION_ID}='session-b'\n` +
|
||||
"APPIUM_URL='https://appium-b.example.test'\n" +
|
||||
`APPIUM_CAPS='${capabilities}'\n`
|
||||
);
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
|
||||
expect(process.env[EAS_SIMULATOR_SESSION_ID]).toBe('session-b');
|
||||
expect(process.env.APPIUM_URL).toBe('https://appium-b.example.test');
|
||||
expect(process.env.APPIUM_CAPS).toBe(capabilities);
|
||||
expect(process.env.AGENT_DEVICE_DAEMON_BASE_URL).toBeUndefined();
|
||||
expect(process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN).toBeUndefined();
|
||||
expect(process.env[EAS_SIMULATOR_EGRESS_URL]).toBeUndefined();
|
||||
expect(process.env[EAS_SIMULATOR_EGRESS_ALLOW]).toBe('');
|
||||
expect(process.env.EAS_SIMULATOR_ENV_TEST_UNRELATED).toBe('keep-this');
|
||||
});
|
||||
|
||||
it.each(['', `${EAS_SIMULATOR_EGRESS_URL}='https://session-b.example.test'\n`])(
|
||||
'does not fill missing file credentials from a previous session (%j)',
|
||||
async egressLine => {
|
||||
await realFs.writeFile(
|
||||
getSimulatorEnvFilePath(projectDir),
|
||||
`${EAS_SIMULATOR_SESSION_ID}='session-b'\n${egressLine}`
|
||||
);
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
|
||||
expect(process.env[EAS_SIMULATOR_EGRESS_TOKEN]).toBeUndefined();
|
||||
expect(process.env[EAS_SIMULATOR_EGRESS_ALLOW]).toBe('');
|
||||
expect(() => readLocalEgressConfigFromEnv(process.env)).toThrow('no egress client to run');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe(writeSimulatorEnvAsync, () => {
|
||||
const projectDir = '/test/project';
|
||||
const simulatorDotenvPath = getSimulatorEnvFilePath(projectDir);
|
||||
|
||||
@@ -3,11 +3,159 @@ import {
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
DeviceRunSessionRemoteConfig,
|
||||
EAS_SIMULATOR_WAITLIST_URL,
|
||||
deviceRunSessionTypeToFlagValue,
|
||||
formatLoopbackForwardNotice,
|
||||
formatPreviewUrl,
|
||||
formatRemoteSessionInstructions,
|
||||
formatSimulatorUnavailableMessage,
|
||||
getLocalEgressConfig,
|
||||
getLoopbackForwardPlan,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
sanitizeRemoteConfigForJson,
|
||||
} from '../utils';
|
||||
|
||||
const agentDeviceConfig = {
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
|
||||
agentDeviceRemoteSessionUrl: 'https://agent-device.example.test',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: 'https://preview.example.test',
|
||||
};
|
||||
|
||||
const agentDeviceConfigWithEgress = {
|
||||
...agentDeviceConfig,
|
||||
egressUrl: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
egressToken: 'egress-secret',
|
||||
egressFingerprint: 'fp=',
|
||||
egressPort: 8899,
|
||||
};
|
||||
|
||||
describe('local egress configuration', () => {
|
||||
it('is absent for sessions without egress', () => {
|
||||
expect(getLocalEgressConfig(agentDeviceConfig)).toBeNull();
|
||||
expect(getRemoteSessionEnvironmentVariables(agentDeviceConfig)).toEqual({
|
||||
AGENT_DEVICE_DAEMON_BASE_URL: 'https://agent-device.example.test',
|
||||
AGENT_DEVICE_DAEMON_AUTH_TOKEN: 'daemon-token',
|
||||
});
|
||||
expect(formatRemoteSessionInstructions(agentDeviceConfig, 'dotenv')).not.toContain(
|
||||
'eas simulator:egress'
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the egress variables and instructions when the worker reported them', () => {
|
||||
expect(getLocalEgressConfig(agentDeviceConfigWithEgress)).toEqual({
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
token: 'egress-secret',
|
||||
fingerprint: 'fp=',
|
||||
port: 8899,
|
||||
allow: [],
|
||||
});
|
||||
expect(getRemoteSessionEnvironmentVariables(agentDeviceConfigWithEgress)).toEqual({
|
||||
AGENT_DEVICE_DAEMON_BASE_URL: 'https://agent-device.example.test',
|
||||
AGENT_DEVICE_DAEMON_AUTH_TOKEN: 'daemon-token',
|
||||
EAS_SIMULATOR_EGRESS_URL: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
EAS_SIMULATOR_EGRESS_TOKEN: 'egress-secret',
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT: 'fp=',
|
||||
EAS_SIMULATOR_EGRESS_PORT: '8899',
|
||||
EAS_SIMULATOR_EGRESS_ALLOW: '',
|
||||
});
|
||||
const instructions = formatRemoteSessionInstructions(agentDeviceConfigWithEgress, 'dotenv');
|
||||
expect(instructions).toContain(
|
||||
"🔀 Local egress: the simulator's HTTP(S) traffic exits from this machine.\n"
|
||||
);
|
||||
expect(instructions).toContain('Run the egress client to connect the tunnel');
|
||||
expect(instructions).toContain('eas simulator:egress');
|
||||
expect(instructions).toContain('Keep it running for the life of the session.');
|
||||
expect(instructions).not.toContain('It can also reach');
|
||||
expect(formatRemoteSessionInstructions(agentDeviceConfigWithEgress, 'env')).toContain(
|
||||
"export EAS_SIMULATOR_EGRESS_ALLOW=''"
|
||||
);
|
||||
});
|
||||
|
||||
it('says nothing about starting the client when it runs inline', () => {
|
||||
for (const configType of ['dotenv', 'env'] as const) {
|
||||
const instructions = formatRemoteSessionInstructions(
|
||||
agentDeviceConfigWithEgress,
|
||||
configType,
|
||||
{
|
||||
egressClientRunsInline: true,
|
||||
}
|
||||
);
|
||||
expect(instructions).toContain(
|
||||
"🔀 Local egress: the simulator's HTTP(S) traffic exits from this machine."
|
||||
);
|
||||
expect(instructions).not.toContain('eas simulator:egress');
|
||||
expect(instructions).not.toContain('Run the egress client');
|
||||
expect(instructions).not.toContain('Keep it running');
|
||||
expect(instructions).not.toContain('export EAS_SIMULATOR_EGRESS');
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the allowed local destinations into the config, env file and instructions', () => {
|
||||
const egressAllow = ['localhost:3000', '192.168.1.20:8080'];
|
||||
expect(getLocalEgressConfig(agentDeviceConfigWithEgress, egressAllow)?.allow).toEqual(
|
||||
egressAllow
|
||||
);
|
||||
expect(
|
||||
getRemoteSessionEnvironmentVariables(agentDeviceConfigWithEgress, { egressAllow })
|
||||
).toMatchObject({ EAS_SIMULATOR_EGRESS_ALLOW: 'localhost:3000,192.168.1.20:8080' });
|
||||
expect(
|
||||
getRemoteSessionEnvironmentVariables(agentDeviceConfig, { egressAllow })
|
||||
).not.toHaveProperty('EAS_SIMULATOR_EGRESS_ALLOW');
|
||||
const instructions = formatRemoteSessionInstructions(agentDeviceConfigWithEgress, 'dotenv', {
|
||||
egressAllow,
|
||||
});
|
||||
expect(instructions).toContain(
|
||||
"🔀 Local egress: the simulator's HTTP(S) traffic exits from this machine. It can also reach localhost:3000, 192.168.1.20:8080."
|
||||
);
|
||||
expect(instructions).not.toContain('reachable by name only');
|
||||
});
|
||||
|
||||
it('warns only about loopback entries the device host will not forward', () => {
|
||||
expect(
|
||||
formatRemoteSessionInstructions(agentDeviceConfigWithEgress, 'dotenv', {
|
||||
egressAllow: ['localhost:80', 'localhost:3000'],
|
||||
})
|
||||
).toContain(
|
||||
'localhost:80 is reachable by name only: privileged ports and the egress proxy port are not forwarded to 127.0.0.1 in the simulator.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getLoopbackForwardPlan, () => {
|
||||
it('forwards unprivileged localhost and 127.0.0.1 ports once each, in order', () => {
|
||||
expect(
|
||||
getLoopbackForwardPlan(
|
||||
['localhost:8082', '127.0.0.1:3000', 'localhost:3000', '192.168.1.20:8080', '[::1]:4000'],
|
||||
8899
|
||||
)
|
||||
).toEqual({ ports: [3000, 8082], skipped: [] });
|
||||
});
|
||||
|
||||
it('skips privileged ports and the proxy port, which the device host does not forward', () => {
|
||||
expect(
|
||||
getLoopbackForwardPlan(['localhost:80', '127.0.0.1:8899', 'localhost:1024'], 8899)
|
||||
).toEqual({ ports: [1024], skipped: ['localhost:80', '127.0.0.1:8899'] });
|
||||
});
|
||||
|
||||
it('returns an empty plan without allow entries', () => {
|
||||
expect(getLoopbackForwardPlan([], 8899)).toEqual({ ports: [], skipped: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatLoopbackForwardNotice, () => {
|
||||
it('describes name-only entries and stays silent about forwarded ports', () => {
|
||||
expect(formatLoopbackForwardNotice({ ports: [3000, 8082], skipped: ['localhost:80'] })).toEqual(
|
||||
[
|
||||
'localhost:80 is reachable by name only: privileged ports and the egress proxy port are not forwarded to 127.0.0.1 in the simulator.',
|
||||
]
|
||||
);
|
||||
expect(formatLoopbackForwardNotice({ ports: [3000], skipped: [] })).toEqual([]);
|
||||
expect(formatLoopbackForwardNotice({ ports: [], skipped: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const iosAppiumConfig = {
|
||||
__typename: 'AppiumRunSessionRemoteConfig' as const,
|
||||
appiumUrl: 'https://appium.example.test',
|
||||
@@ -73,3 +221,272 @@ 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'
|
||||
);
|
||||
});
|
||||
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(sanitizeRemoteConfigForJson, () => {
|
||||
const PREVIEW_URL = 'https://preview.example.test';
|
||||
|
||||
it('moves the token into the preview url and drops the standalone field', () => {
|
||||
const sanitized = sanitizeRemoteConfigForJson({
|
||||
__typename: 'ServeSimRunSessionRemoteConfig' as const,
|
||||
previewUrl: PREVIEW_URL,
|
||||
previewToken: 'tok-1',
|
||||
});
|
||||
|
||||
expect(sanitized).toEqual({
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: `${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,
|
||||
toolsUrl: 'https://argent.example.test',
|
||||
toolsAuthToken: 'argent-token',
|
||||
webPreviewUrl: PREVIEW_URL,
|
||||
webPreviewToken: 'tok-1',
|
||||
});
|
||||
|
||||
expect(sanitized).toMatchObject({ webPreviewUrl: `${PREVIEW_URL}/?token=tok-1` });
|
||||
expect(JSON.stringify(sanitized)).not.toContain('webPreviewToken');
|
||||
// The controller credential is a different secret and stays: it is what simulator:exec needs.
|
||||
expect(sanitized).toMatchObject({ toolsAuthToken: 'argent-token' });
|
||||
});
|
||||
|
||||
it('leaves an ungated session untouched', () => {
|
||||
const remoteConfig = {
|
||||
__typename: 'ServeSimRunSessionRemoteConfig' as const,
|
||||
previewUrl: PREVIEW_URL,
|
||||
previewToken: null,
|
||||
};
|
||||
|
||||
expect(sanitizeRemoteConfigForJson(remoteConfig)).toEqual({
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewUrl: PREVIEW_URL,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a controller session with no web preview alone', () => {
|
||||
const sanitized = sanitizeRemoteConfigForJson({
|
||||
__typename: 'AppiumRunSessionRemoteConfig' as const,
|
||||
appiumUrl: 'https://appium.example.test',
|
||||
capabilities: {},
|
||||
webPreviewUrl: null,
|
||||
webPreviewToken: null,
|
||||
});
|
||||
|
||||
expect(sanitized).toMatchObject({ webPreviewUrl: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gated preview links', () => {
|
||||
const PREVIEW = 'https://preview.example.test';
|
||||
const GATED = `${PREVIEW}/?token=tok-1`;
|
||||
|
||||
it('prints the tokenized preview for a serve-sim session', () => {
|
||||
const instructions = formatRemoteSessionInstructions(
|
||||
{
|
||||
__typename: 'ServeSimRunSessionRemoteConfig' as const,
|
||||
previewUrl: PREVIEW,
|
||||
previewToken: 'tok-1',
|
||||
},
|
||||
'env'
|
||||
);
|
||||
|
||||
expect(instructions).toContain(GATED);
|
||||
});
|
||||
|
||||
it('prints the tokenized preview for a web-preview-only session', () => {
|
||||
const instructions = formatRemoteSessionInstructions(
|
||||
{
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
|
||||
previewUrl: PREVIEW,
|
||||
previewToken: 'tok-1',
|
||||
},
|
||||
'env'
|
||||
);
|
||||
|
||||
expect(instructions).toContain(GATED);
|
||||
});
|
||||
|
||||
it('prints the tokenized preview for every controller session type', () => {
|
||||
const controllers = [
|
||||
{
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
|
||||
agentDeviceRemoteSessionUrl: 'https://daemon.example.test',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: PREVIEW,
|
||||
webPreviewToken: 'tok-1',
|
||||
},
|
||||
{
|
||||
__typename: 'ArgentRunSessionRemoteConfig' as const,
|
||||
toolsUrl: 'https://argent.example.test',
|
||||
toolsAuthToken: 'argent-token',
|
||||
webPreviewUrl: PREVIEW,
|
||||
webPreviewToken: 'tok-1',
|
||||
},
|
||||
{ ...iosAppiumConfig, webPreviewToken: 'tok-1' },
|
||||
];
|
||||
|
||||
for (const remoteConfig of controllers) {
|
||||
expect(formatRemoteSessionInstructions(remoteConfig, 'env')).toContain(GATED);
|
||||
}
|
||||
});
|
||||
|
||||
it('prints the plain url when the preview is ungated', () => {
|
||||
const instructions = formatRemoteSessionInstructions(
|
||||
{ __typename: 'ServeSimRunSessionRemoteConfig' as const, previewUrl: PREVIEW },
|
||||
'env'
|
||||
);
|
||||
|
||||
expect(instructions).toContain(PREVIEW);
|
||||
expect(instructions).not.toContain('token=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview-only session environment', () => {
|
||||
it.each([
|
||||
{ __typename: 'ServeSimRunSessionRemoteConfig' as const, previewUrl: 'https://p.example.test' },
|
||||
{
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
|
||||
previewUrl: 'https://p.example.test',
|
||||
},
|
||||
])('has no controller variables for $__typename', remoteConfig => {
|
||||
expect(getRemoteSessionEnvironmentVariables(remoteConfig)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Argent session without a tools token', () => {
|
||||
const untokenizedArgent = {
|
||||
__typename: 'ArgentRunSessionRemoteConfig' as const,
|
||||
toolsUrl: 'https://argent.example.test',
|
||||
webPreviewUrl: null,
|
||||
};
|
||||
|
||||
it('omits the auth variable', () => {
|
||||
expect(getRemoteSessionEnvironmentVariables(untokenizedArgent)).toEqual({
|
||||
ARGENT_TOOLS_URL: 'https://argent.example.test',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the --token flag from the link command', () => {
|
||||
const instructions = formatRemoteSessionInstructions(untokenizedArgent, 'dotenv');
|
||||
|
||||
expect(instructions).toContain("argent link 'https://argent.example.test'");
|
||||
expect(instructions).not.toContain('--token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dotenv instructions', () => {
|
||||
it('tells an agent-device session to use simulator:exec', () => {
|
||||
const instructions = formatRemoteSessionInstructions(
|
||||
{
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
|
||||
agentDeviceRemoteSessionUrl: 'https://daemon.example.test',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: null,
|
||||
},
|
||||
'dotenv'
|
||||
);
|
||||
|
||||
expect(instructions).toContain('eas simulator:exec npx agent-device <command>');
|
||||
});
|
||||
|
||||
it('tells an argent session to link its local client', () => {
|
||||
const instructions = formatRemoteSessionInstructions(
|
||||
{
|
||||
__typename: 'ArgentRunSessionRemoteConfig' as const,
|
||||
toolsUrl: 'https://argent.example.test',
|
||||
toolsAuthToken: 'argent-token',
|
||||
webPreviewUrl: null,
|
||||
},
|
||||
'dotenv'
|
||||
);
|
||||
|
||||
expect(instructions).toContain('link your local Argent client');
|
||||
expect(instructions).toContain("--token 'argent-token'");
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatSimulatorUnavailableMessage, () => {
|
||||
it('names the account and points at the waitlist', () => {
|
||||
const message = formatSimulatorUnavailableMessage('acme');
|
||||
|
||||
expect(message).toContain('acme');
|
||||
expect(message).toContain(EAS_SIMULATOR_WAITLIST_URL);
|
||||
});
|
||||
});
|
||||
|
||||
const controllerConfigs: DeviceRunSessionRemoteConfig[] = [
|
||||
agentDeviceConfig,
|
||||
iosAppiumConfig,
|
||||
{ __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: 'https://argent.example.test' },
|
||||
{ __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: 'https://preview.example.test' },
|
||||
{
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: 'https://preview.example.test',
|
||||
},
|
||||
];
|
||||
|
||||
describe.each(controllerConfigs)('$__typename local egress', remoteConfig => {
|
||||
const egress = {
|
||||
egressUrl: 'https://egress.example.test',
|
||||
egressToken: 'egress-secret',
|
||||
egressFingerprint: 'fp=',
|
||||
egressPort: 8899,
|
||||
};
|
||||
|
||||
it('preserves controller variables and provides egress credentials and instructions', () => {
|
||||
const withEgress = { ...remoteConfig, ...egress };
|
||||
expect(getRemoteSessionEnvironmentVariables(withEgress)).toEqual({
|
||||
...getRemoteSessionEnvironmentVariables(remoteConfig),
|
||||
EAS_SIMULATOR_EGRESS_URL: egress.egressUrl,
|
||||
EAS_SIMULATOR_EGRESS_TOKEN: egress.egressToken,
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT: egress.egressFingerprint,
|
||||
EAS_SIMULATOR_EGRESS_PORT: '8899',
|
||||
EAS_SIMULATOR_EGRESS_ALLOW: '',
|
||||
});
|
||||
expect(getLocalEgressConfig(withEgress)).toEqual({
|
||||
url: egress.egressUrl,
|
||||
token: egress.egressToken,
|
||||
fingerprint: egress.egressFingerprint,
|
||||
port: 8899,
|
||||
allow: [],
|
||||
});
|
||||
const dotenvInstructions = formatRemoteSessionInstructions(withEgress, 'dotenv');
|
||||
expect(dotenvInstructions).toContain('eas simulator:egress');
|
||||
expect(dotenvInstructions).not.toContain(egress.egressToken);
|
||||
expect(formatRemoteSessionInstructions(withEgress, 'env')).toContain(
|
||||
'eas simulator:egress --config-type env'
|
||||
);
|
||||
expect(formatRemoteSessionInstructions(withEgress, 'env')).toContain(
|
||||
"export EAS_SIMULATOR_EGRESS_TOKEN='egress-secret'"
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start a tunnel from absent or incomplete egress credentials', () => {
|
||||
expect(getLocalEgressConfig(remoteConfig)).toBeNull();
|
||||
expect(getLocalEgressConfig({ ...remoteConfig, ...egress, egressToken: null })).toBeNull();
|
||||
expect(formatRemoteSessionInstructions(remoteConfig, 'dotenv')).not.toContain(
|
||||
'eas simulator:egress'
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,13 @@ import Log from '../log';
|
||||
|
||||
export const SIMULATOR_DOTENV_FILE_NAME = '.env.eas-simulator';
|
||||
export const EAS_SIMULATOR_SESSION_ID = 'EAS_SIMULATOR_SESSION_ID';
|
||||
// Written for sessions started with `--egress local`; read by `eas simulator:egress`.
|
||||
export const EAS_SIMULATOR_EGRESS_URL = 'EAS_SIMULATOR_EGRESS_URL';
|
||||
export const EAS_SIMULATOR_EGRESS_TOKEN = 'EAS_SIMULATOR_EGRESS_TOKEN';
|
||||
export const EAS_SIMULATOR_EGRESS_FINGERPRINT = 'EAS_SIMULATOR_EGRESS_FINGERPRINT';
|
||||
export const EAS_SIMULATOR_EGRESS_PORT = 'EAS_SIMULATOR_EGRESS_PORT';
|
||||
// Comma-separated host:port destinations from `--egress-allow`.
|
||||
export const EAS_SIMULATOR_EGRESS_ALLOW = 'EAS_SIMULATOR_EGRESS_ALLOW';
|
||||
export const SIMULATOR_DOTENV_FILE_HEADER =
|
||||
'# Do not commit this file.\n# Do not modify these values manually. They are managed by eas-cli.\n# It holds configuration only for the current simulator session.\n\n';
|
||||
|
||||
@@ -18,6 +25,38 @@ export async function loadSimulatorEnvAsync(projectDir: string): Promise<void> {
|
||||
const simulatorDotenvFilePath = getSimulatorEnvFilePath(projectDir);
|
||||
|
||||
loadProjectEnv(projectDir, { silent: true });
|
||||
try {
|
||||
const simulatorEnv = parseDotenv(await fs.readFile(simulatorDotenvFilePath, 'utf8'));
|
||||
if (simulatorEnv[EAS_SIMULATOR_SESSION_ID] || simulatorEnv[EAS_SIMULATOR_EGRESS_URL]) {
|
||||
// loadEnvFiles never replaces existing variables, even with force: true.
|
||||
// Keep this session's credentials and destination policy together rather
|
||||
// than combining them with values exported for an older session.
|
||||
for (const key of [
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
'AGENT_DEVICE_DAEMON_BASE_URL',
|
||||
'AGENT_DEVICE_DAEMON_AUTH_TOKEN',
|
||||
'ARGENT_TOOLS_URL',
|
||||
'ARGENT_AUTH_TOKEN',
|
||||
'APPIUM_URL',
|
||||
'APPIUM_CAPS',
|
||||
EAS_SIMULATOR_EGRESS_URL,
|
||||
EAS_SIMULATOR_EGRESS_TOKEN,
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT,
|
||||
EAS_SIMULATOR_EGRESS_PORT,
|
||||
]) {
|
||||
if (simulatorEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = simulatorEnv[key];
|
||||
}
|
||||
}
|
||||
process.env[EAS_SIMULATOR_EGRESS_ALLOW] = simulatorEnv[EAS_SIMULATOR_EGRESS_ALLOW] ?? '';
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT')) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
loadEnvFiles([simulatorDotenvFilePath], { force: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ import {
|
||||
DeviceRunSessionType,
|
||||
} from '../graphql/generated';
|
||||
import { link } from '../log';
|
||||
import {
|
||||
EAS_SIMULATOR_EGRESS_ALLOW,
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT,
|
||||
EAS_SIMULATOR_EGRESS_PORT,
|
||||
EAS_SIMULATOR_EGRESS_TOKEN,
|
||||
EAS_SIMULATOR_EGRESS_URL,
|
||||
} from './env';
|
||||
|
||||
type DeviceRunSessionByIdResult = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
export type DeviceRunSessionRemoteConfig = NonNullable<DeviceRunSessionByIdResult['remoteConfig']>;
|
||||
@@ -59,7 +66,129 @@ export const DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE = Object.fromEntrie
|
||||
).map(([resourceClass, value]) => [value, resourceClass])
|
||||
) as Record<string, DeviceRunSessionResourceClass>;
|
||||
|
||||
export type LocalEgressConfig = {
|
||||
url: string;
|
||||
/** Secret for the tunnel server; the client pairs it with the fixed egress username. */
|
||||
token: string;
|
||||
fingerprint: string;
|
||||
port: number;
|
||||
/**
|
||||
* Normalized `host:port` destinations on the developer's machine or network
|
||||
* that the simulator may reach through the proxy (`--egress-allow`).
|
||||
*/
|
||||
allow: string[];
|
||||
};
|
||||
|
||||
export type LocalEgressOptions = {
|
||||
egressAllow?: readonly string[];
|
||||
/**
|
||||
* Whether the calling command runs the egress client itself in the current
|
||||
* terminal, as interactive `simulator:start` does. When false the reader must
|
||||
* start `eas simulator:egress` in another process.
|
||||
*/
|
||||
egressClientRunsInline?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Connection details for the local egress client, present only for sessions
|
||||
* started with `--egress local`. `allow` comes from the developer's flags, not
|
||||
* from the worker.
|
||||
*/
|
||||
export function getLocalEgressConfig(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig,
|
||||
allow: readonly string[] = []
|
||||
): LocalEgressConfig | null {
|
||||
const { egressUrl, egressToken, egressFingerprint, egressPort } = remoteConfig;
|
||||
if (!egressUrl || !egressToken || !egressFingerprint || egressPort == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
url: egressUrl,
|
||||
token: egressToken,
|
||||
fingerprint: egressFingerprint,
|
||||
port: egressPort,
|
||||
allow: [...allow],
|
||||
};
|
||||
}
|
||||
|
||||
export type LoopbackForwardPlan = {
|
||||
/** Ports the tunnel client forwards on the device host's loopback interface. */
|
||||
ports: number[];
|
||||
/** Loopback allow entries that stay reachable by name through the proxy only. */
|
||||
skipped: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Which `--egress-allow` entries also become loopback port forwards. iOS never
|
||||
* sends loopback-literal requests to the system proxy, and Expo CLI rewrites
|
||||
* `localhost` to `127.0.0.1` in manifests, so the proxy alone cannot serve a dev
|
||||
* server allowed as `localhost:<port>`. For each entry naming `localhost` or
|
||||
* `127.0.0.1`, the tunnel client opens a reverse remote that makes
|
||||
* `127.0.0.1:<port>` on the device host reach the same port here, like
|
||||
* `adb reverse`. The device host only permits unprivileged ports, and the proxy
|
||||
* port is already taken by the tunnel server, so those entries are skipped.
|
||||
*/
|
||||
export function getLoopbackForwardPlan(
|
||||
allow: readonly string[],
|
||||
proxyPort: number
|
||||
): LoopbackForwardPlan {
|
||||
const ports = new Set<number>();
|
||||
const skipped: string[] = [];
|
||||
for (const destination of allow) {
|
||||
const match = /^(?:localhost|127\.0\.0\.1):(\d+)$/.exec(destination);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const port = Number(match[1]);
|
||||
if (port < 1024 || port === proxyPort) {
|
||||
skipped.push(destination);
|
||||
continue;
|
||||
}
|
||||
ports.add(port);
|
||||
}
|
||||
return { ports: [...ports].sort((a, b) => a - b), skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the exception is worth a line: forwarded ports just work, but an entry
|
||||
* the device host will not forward changes what the reader would expect.
|
||||
*/
|
||||
export function formatLoopbackForwardNotice({ skipped }: LoopbackForwardPlan): string[] {
|
||||
if (skipped.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
`${skipped.join(', ')} ${skipped.length === 1 ? 'is' : 'are'} reachable by name only: ` +
|
||||
'privileged ports and the egress proxy port are not forwarded to 127.0.0.1 in the simulator.',
|
||||
];
|
||||
}
|
||||
|
||||
export function getLocalEgressEnvironmentVariables(
|
||||
egress: LocalEgressConfig | null
|
||||
): Record<string, string> {
|
||||
if (!egress) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
[EAS_SIMULATOR_EGRESS_URL]: egress.url,
|
||||
[EAS_SIMULATOR_EGRESS_TOKEN]: egress.token,
|
||||
[EAS_SIMULATOR_EGRESS_FINGERPRINT]: egress.fingerprint,
|
||||
[EAS_SIMULATOR_EGRESS_PORT]: String(egress.port),
|
||||
[EAS_SIMULATOR_EGRESS_ALLOW]: egress.allow.join(','),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRemoteSessionEnvironmentVariables(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig,
|
||||
{ egressAllow }: LocalEgressOptions = {}
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...getControllerEnvironmentVariables(remoteConfig),
|
||||
...getLocalEgressEnvironmentVariables(getLocalEgressConfig(remoteConfig, egressAllow)),
|
||||
};
|
||||
}
|
||||
|
||||
function getControllerEnvironmentVariables(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig
|
||||
): Record<string, string> {
|
||||
switch (remoteConfig.__typename) {
|
||||
@@ -86,13 +215,93 @@ export function getRemoteSessionEnvironmentVariables(
|
||||
|
||||
type RemoteSessionInstructionsConfigType = 'env' | 'dotenv';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function formatPreviewUrl(url: string, token: string | null | undefined): string {
|
||||
if (!token) {
|
||||
return url;
|
||||
}
|
||||
const withToken = new URL(url);
|
||||
withToken.searchParams.set('token', token);
|
||||
return withToken.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function sanitizeRemoteConfigForJson(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig
|
||||
): DeviceRunSessionRemoteConfig {
|
||||
switch (remoteConfig.__typename) {
|
||||
case 'ServeSimRunSessionRemoteConfig':
|
||||
case 'WebPreviewOnlyRunSessionRemoteConfig': {
|
||||
const { previewToken, ...rest } = remoteConfig;
|
||||
return { ...rest, previewUrl: formatPreviewUrl(remoteConfig.previewUrl, previewToken) };
|
||||
}
|
||||
case 'AgentDeviceRunSessionRemoteConfig':
|
||||
case 'ArgentRunSessionRemoteConfig':
|
||||
case 'AppiumRunSessionRemoteConfig': {
|
||||
const { webPreviewToken, ...rest } = remoteConfig;
|
||||
return {
|
||||
...rest,
|
||||
webPreviewUrl: remoteConfig.webPreviewUrl
|
||||
? formatPreviewUrl(remoteConfig.webPreviewUrl, webPreviewToken)
|
||||
: remoteConfig.webPreviewUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatRemoteSessionInstructions(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig,
|
||||
configType: RemoteSessionInstructionsConfigType,
|
||||
{ egressAllow, egressClientRunsInline = false }: LocalEgressOptions = {}
|
||||
): string {
|
||||
const instructions = formatControllerInstructions(remoteConfig, configType);
|
||||
const egress = getLocalEgressConfig(remoteConfig, egressAllow);
|
||||
if (!egress) {
|
||||
return instructions;
|
||||
}
|
||||
const egressCommand =
|
||||
configType === 'env' ? 'eas simulator:egress --config-type env' : 'eas simulator:egress';
|
||||
const summary =
|
||||
"🔀 Local egress: the simulator's HTTP(S) traffic exits from this machine." +
|
||||
(egress.allow.length > 0 ? ` It can also reach ${egress.allow.join(', ')}.` : '');
|
||||
return [
|
||||
instructions,
|
||||
'',
|
||||
summary,
|
||||
...formatLoopbackForwardNotice(getLoopbackForwardPlan(egress.allow, egress.port)),
|
||||
// In interactive mode the client starts in this terminal once the session is
|
||||
// ready and stops with it, so there is nothing for the reader to run.
|
||||
...(egressClientRunsInline
|
||||
? []
|
||||
: [
|
||||
...(configType === 'env'
|
||||
? Object.entries(getLocalEgressEnvironmentVariables(egress)).map(
|
||||
([key, value]) => `export ${key}='${value}'`
|
||||
)
|
||||
: []),
|
||||
'Run the egress client to connect the tunnel:',
|
||||
'',
|
||||
egressCommand,
|
||||
'',
|
||||
'Keep it running for the life of the session.',
|
||||
]),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function formatControllerInstructions(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig,
|
||||
configType: RemoteSessionInstructionsConfigType
|
||||
): string {
|
||||
switch (remoteConfig.__typename) {
|
||||
case 'AgentDeviceRunSessionRemoteConfig': {
|
||||
const environmentVariables = getRemoteSessionEnvironmentVariables(remoteConfig);
|
||||
const environmentVariables = getControllerEnvironmentVariables(remoteConfig);
|
||||
const lines =
|
||||
configType === 'dotenv'
|
||||
? [
|
||||
@@ -112,13 +321,13 @@ export function formatRemoteSessionInstructions(
|
||||
'',
|
||||
'🌐 Open the following URL in your browser to preview the simulator:',
|
||||
'',
|
||||
remoteConfig.webPreviewUrl
|
||||
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
case 'ArgentRunSessionRemoteConfig': {
|
||||
const environmentVariables = getRemoteSessionEnvironmentVariables(remoteConfig);
|
||||
const environmentVariables = getControllerEnvironmentVariables(remoteConfig);
|
||||
const lines =
|
||||
configType === 'dotenv'
|
||||
? [
|
||||
@@ -150,13 +359,13 @@ export function formatRemoteSessionInstructions(
|
||||
'',
|
||||
'🌐 Open the following URL in your browser to preview the simulator:',
|
||||
'',
|
||||
remoteConfig.webPreviewUrl
|
||||
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
case 'AppiumRunSessionRemoteConfig': {
|
||||
const environmentVariables = getRemoteSessionEnvironmentVariables(remoteConfig);
|
||||
const environmentVariables = getControllerEnvironmentVariables(remoteConfig);
|
||||
const lines =
|
||||
configType === 'dotenv'
|
||||
? [
|
||||
@@ -174,7 +383,12 @@ export function formatRemoteSessionInstructions(
|
||||
'<appium-client> [args...]',
|
||||
];
|
||||
if (remoteConfig.webPreviewUrl) {
|
||||
lines.push('', 'Open the simulator preview:', '', remoteConfig.webPreviewUrl);
|
||||
lines.push(
|
||||
'',
|
||||
'Open the simulator preview:',
|
||||
'',
|
||||
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -182,13 +396,13 @@ export function formatRemoteSessionInstructions(
|
||||
return [
|
||||
'🌐 Open the following URL in your browser to access the simulator:',
|
||||
'',
|
||||
remoteConfig.previewUrl,
|
||||
formatPreviewUrl(remoteConfig.previewUrl, remoteConfig.previewToken),
|
||||
].join('\n');
|
||||
case 'WebPreviewOnlyRunSessionRemoteConfig':
|
||||
return [
|
||||
'🌐 Open the following URL in your browser to access the simulator:',
|
||||
'',
|
||||
remoteConfig.previewUrl,
|
||||
formatPreviewUrl(remoteConfig.previewUrl, remoteConfig.previewToken),
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "23.1.0",
|
||||
"version": "24.0.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": "23.1.0",
|
||||
"@expo/eas-build-job": "24.0.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "23.2.0",
|
||||
"version": "24.1.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,12 +26,12 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "23.2.0",
|
||||
"@expo/build-tools": "24.1.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/eas-build-job": "24.0.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "23.1.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "23.1.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "23.1.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/eas-build-job": "24.0.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "23.0.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "23.1.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "23.2.0",
|
||||
"version": "24.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "23.2.0",
|
||||
"@expo/build-tools": "24.1.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/eas-build-job": "24.0.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": "23.1.0",
|
||||
"@expo/template-file": "23.0.0",
|
||||
"@expo/turtle-spawn": "23.1.0",
|
||||
"@expo/steps": "24.0.0",
|
||||
"@expo/template-file": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
|
||||
@@ -21,4 +21,18 @@ describe('config', () => {
|
||||
expect(config.loggers.http.baseUrl).toBe(expectedBaseUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Environment.DEVELOPMENT, 'ws://localhost:8787'],
|
||||
[Environment.STAGING, 'wss://staging-mcp.expo.dev'],
|
||||
[Environment.PRODUCTION, 'wss://mcp.expo.dev'],
|
||||
[Environment.TEST, 'ws://localhost:8787'],
|
||||
])('uses the expected MCP server URL in %s', (environment, expectedMcpServerUrl) => {
|
||||
process.env.ENVIRONMENT = environment;
|
||||
|
||||
jest.isolateModules(() => {
|
||||
const config = require('../config').default;
|
||||
expect(config.mcpServerUrl).toBe(expectedMcpServerUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,19 @@ describe(createBuildContext.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the MCP server URL to the build context', async () => {
|
||||
const ctx = await createBuildContext({
|
||||
...baseOptions,
|
||||
job: {
|
||||
platform: Platform.ANDROID,
|
||||
type: Workflow.GENERIC,
|
||||
secrets: { environmentSecrets: [] },
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(ctx.mcpServerUrl).toBe(config.mcpServerUrl);
|
||||
});
|
||||
|
||||
it('routes managed source maps to the source-map uploader', async () => {
|
||||
const ctx = await createBuildContext({
|
||||
...baseOptions,
|
||||
|
||||
@@ -86,4 +86,10 @@ export default {
|
||||
: process.env.ENVIRONMENT === 'staging'
|
||||
? 'https://staging-api.expo.dev/v2/'
|
||||
: 'https://api.expo.dev/v2/',
|
||||
mcpServerUrl:
|
||||
process.env.ENVIRONMENT === 'development' || process.env.ENVIRONMENT === 'test'
|
||||
? 'ws://localhost:8787'
|
||||
: process.env.ENVIRONMENT === 'staging'
|
||||
? 'wss://staging-mcp.expo.dev'
|
||||
: 'wss://mcp.expo.dev',
|
||||
};
|
||||
@@ -103,6 +103,7 @@ export async function createBuildContext<TJob extends Job>({
|
||||
cacheManager: new GCSCacheManager(),
|
||||
metadata,
|
||||
expoApiV2BaseUrl: config.wwwApiV2BaseUrl,
|
||||
mcpServerUrl: config.mcpServerUrl,
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
@@ -2282,33 +2282,33 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/apple-utils@npm:2.1.22":
|
||||
version: 2.1.22
|
||||
resolution: "@expo/apple-utils@npm:2.1.22"
|
||||
"@expo/apple-utils@npm:2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "@expo/apple-utils@npm:2.2.0"
|
||||
bin:
|
||||
apple-utils: bin.js
|
||||
checksum: 10c0/19a5d21f80cde11e7502d7380b17bb4634568dcd243eb72ce741277dd71aab2b20168e44060143b10bfa01f0b03a6b8a2628b3cf3e5d697b12cf7f78a16d469a
|
||||
checksum: 10c0/527bbe07536f5ff7aeb3774feaa4d9ed89e1ffec54f414125e9a85ba5048eb6471fa5ae6b4ce25f7387601383b4e34e2490c62d3778e12f79bff13167447187e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:23.2.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:24.1.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:23.0.0"
|
||||
"@expo/eas-build-job": "npm:23.1.0"
|
||||
"@expo/downloader": "npm:24.0.0"
|
||||
"@expo/eas-build-job": "npm:24.0.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:23.1.0"
|
||||
"@expo/logger": "npm:24.0.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/plist": "npm:^0.3.5"
|
||||
"@expo/repack-app": "npm:~0.9.0"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:23.1.0"
|
||||
"@expo/template-file": "npm:23.0.0"
|
||||
"@expo/turtle-spawn": "npm:23.1.0"
|
||||
"@expo/steps": "npm:24.0.0"
|
||||
"@expo/template-file": "npm:24.0.0"
|
||||
"@expo/turtle-spawn": "npm:24.0.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
"@ngrok/ngrok": "npm:1.7.0"
|
||||
"@sentry/node": "npm:7.77.0"
|
||||
@@ -2541,7 +2541,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/downloader@npm:23.0.0, @expo/downloader@workspace:packages/downloader":
|
||||
"@expo/downloader@npm:24.0.0, @expo/downloader@workspace:packages/downloader":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/downloader@workspace:packages/downloader"
|
||||
dependencies:
|
||||
@@ -2557,13 +2557,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:23.1.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:24.0.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:
|
||||
"@expo/logger": "npm:23.1.0"
|
||||
"@expo/logger": "npm:24.0.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/turtle-spawn": "npm:23.1.0"
|
||||
"@expo/turtle-spawn": "npm:24.0.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
jest: "npm:^29.7.0"
|
||||
@@ -2576,12 +2576,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:23.1.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:24.0.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:23.1.0"
|
||||
"@expo/eas-build-job": "npm:24.0.0"
|
||||
"@tsconfig/node20": "npm:20.1.8"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2695,7 +2695,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/logger@npm:23.1.0, @expo/logger@workspace:packages/logger":
|
||||
"@expo/logger@npm:24.0.0, @expo/logger@workspace:packages/logger":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/logger@workspace:packages/logger"
|
||||
dependencies:
|
||||
@@ -2895,12 +2895,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:23.1.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:24.0.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:23.1.0"
|
||||
"@expo/logger": "npm:23.1.0"
|
||||
"@expo/eas-build-job": "npm:24.0.0"
|
||||
"@expo/logger": "npm:24.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
@@ -2926,7 +2926,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/template-file@npm:23.0.0, @expo/template-file@workspace:packages/template-file":
|
||||
"@expo/template-file@npm:24.0.0, @expo/template-file@workspace:packages/template-file":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/template-file@workspace:packages/template-file"
|
||||
dependencies:
|
||||
@@ -2949,11 +2949,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/turtle-spawn@npm:23.1.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
"@expo/turtle-spawn@npm:24.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:23.1.0"
|
||||
"@expo/logger": "npm:24.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -2969,17 +2969,17 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:23.2.0"
|
||||
"@expo/build-tools": "npm:24.1.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/eas-build-job": "npm:23.1.0"
|
||||
"@expo/logger": "npm:23.1.0"
|
||||
"@expo/eas-build-job": "npm:24.0.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:23.1.0"
|
||||
"@expo/template-file": "npm:23.0.0"
|
||||
"@expo/turtle-spawn": "npm:23.1.0"
|
||||
"@expo/steps": "npm:24.0.0"
|
||||
"@expo/template-file": "npm:24.0.0"
|
||||
"@expo/turtle-spawn": "npm:24.0.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
"@types/babel__core": "npm:7.20.5"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -9828,7 +9828,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:23.1.0"
|
||||
"@expo/steps": "npm:24.0.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -10412,12 +10412,12 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:23.2.0"
|
||||
"@expo/build-tools": "npm:24.1.0"
|
||||
"@expo/bunyan": "npm:^4.0.1"
|
||||
"@expo/eas-build-job": "npm:23.1.0"
|
||||
"@expo/logger": "npm:23.1.0"
|
||||
"@expo/eas-build-job": "npm:24.0.0"
|
||||
"@expo/logger": "npm:24.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:23.1.0"
|
||||
"@expo/turtle-spawn": "npm:24.0.0"
|
||||
"@types/fs-extra": "npm:^11.0.4"
|
||||
"@types/hapi__joi": "npm:^17.1.14"
|
||||
"@types/lodash": "npm:^4.17.4"
|
||||
@@ -10465,15 +10465,15 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli@workspace:packages/eas-cli"
|
||||
dependencies:
|
||||
"@expo/apple-utils": "npm:2.1.22"
|
||||
"@expo/apple-utils": "npm:2.2.0"
|
||||
"@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:23.1.0"
|
||||
"@expo/eas-json": "npm:23.1.0"
|
||||
"@expo/eas-build-job": "npm:24.0.0"
|
||||
"@expo/eas-json": "npm:24.0.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:23.1.0"
|
||||
"@expo/logger": "npm:24.0.0"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
"@expo/osascript": "npm:2.1.4"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -10485,7 +10485,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:23.1.0"
|
||||
"@expo/steps": "npm:24.0.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
|
||||
Reference in new issue
Block a user