mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 13:08:11 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74292d2ace | ||
|
|
b0b7cad316 | ||
|
|
926887d14e | ||
|
|
2e6bb2d276 | ||
|
|
30656509ac |
No files matched your search
@@ -8,13 +8,18 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--tag` to `eas simulator` and `eas simulator:list` to group and filter simulator sessions by app variant. ([#4318](https://github.com/expo/eas-cli/pull/4318) by [@tchayen](https://github.com/tchayen))
|
||||
- [eas-cli] Update workflow run logs in real time, instead of every 10 seconds. ([#4228](https://github.com/expo/eas-cli/pull/4228) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Add `expo-device-hub` web previews to Android web-preview-only, Agent Device, Argent, and Appium remote sessions. ([#4304](https://github.com/expo/eas-cli/pull/4304) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [eas-cli] Add `eas workflow:ssh <workflow-job-id> [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `--ssh` (and `--ssh-idle-timeout`) to `eas workflow:run` to enable ssh on the run's VM jobs. ([#4033](https://github.com/expo/eas-cli/pull/4033) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Warn when starting Android EAS Simulator sessions that support is still in development and iOS parity is coming soon. ([#4320](https://github.com/expo/eas-cli/pull/4320) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [23.1.0](https://github.com/expo/eas-cli/releases/tag/v23.1.0) - 2026-08-31
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "23.1.0",
|
||||
"version": "23.2.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "23.1.0",
|
||||
"version": "23.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
+170
-147
File diff suppressed because it is too large.
Load diff
Generated
+195
@@ -6530,6 +6530,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewToken",
|
||||
"description": "Session token gating the web preview. Null when the preview runs ungated.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewUrl",
|
||||
"description": "URL of the web preview surface for the session. Null when web previews are\nnot available for the platform (e.g. Android).",
|
||||
@@ -10352,6 +10364,30 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "deviceRunSessionTags",
|
||||
"description": "Every tag used by this app's device run sessions, sorted alphabetically.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "deviceRunSessionsPaginated",
|
||||
"description": null,
|
||||
@@ -26681,6 +26717,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewToken",
|
||||
"description": "Session token gating the web preview. Null when the preview runs ungated.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewUrl",
|
||||
"description": "URL of the web preview surface for the session. Null when web previews are\nnot available for the platform (e.g. Android).",
|
||||
@@ -29510,6 +29558,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewToken",
|
||||
"description": "Session token gating the web preview. Null when the preview runs ungated.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewUrl",
|
||||
"description": "URL of the web preview surface for the session. Null when web previews are\nnot available for the platform (e.g. Android).",
|
||||
@@ -38490,6 +38550,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "tags",
|
||||
"description": "Free-form labels for grouping sessions, for example one per app variant.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"description": null,
|
||||
@@ -44166,6 +44246,30 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "tags",
|
||||
"description": "Free-form labels for grouping sessions, for example one per app variant.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "turtleJobRun",
|
||||
"description": null,
|
||||
@@ -44563,6 +44667,26 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "tags",
|
||||
"description": "Matches sessions carrying every listed tag. Case-insensitive.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "types",
|
||||
"description": null,
|
||||
@@ -72172,6 +72296,18 @@
|
||||
"name": "ServeSimRunSessionRemoteConfig",
|
||||
"description": "Legacy remote config for SERVE_SIM sessions. Use WEB_PREVIEW_ONLY sessions and\nWebPreviewOnlyRunSessionRemoteConfig instead.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "previewToken",
|
||||
"description": "Session token gating the preview. Null when the preview runs ungated.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previewUrl",
|
||||
"description": null,
|
||||
@@ -96614,6 +96750,18 @@
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "ssh",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowRunSshInput",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"interfaces": null,
|
||||
@@ -96816,6 +96964,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "ssh",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowRunSshInput",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "workflowRevisionId",
|
||||
"description": null,
|
||||
@@ -96861,6 +97021,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "ssh",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowRunSshInput",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "workflowRunId",
|
||||
"description": null,
|
||||
@@ -96940,6 +97112,29 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowRunSshInput",
|
||||
"description": "Enables ssh on the run's VM jobs. Presence turns ssh on; idleTimeoutSeconds is optional,\ndefaults server-side, and is validated against a supported range.",
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "idleTimeoutSeconds",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"interfaces": null,
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowRunSshSettings",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "23.1.0",
|
||||
"version": "23.2.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -48,6 +48,7 @@ function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): De
|
||||
return {
|
||||
id: 'session-123',
|
||||
name: null,
|
||||
tags: [],
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
platform: AppPlatform.Ios,
|
||||
@@ -143,6 +144,7 @@ describe(SimulatorGet, () => {
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
name: undefined,
|
||||
tags: [],
|
||||
type: 'web-preview-only',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
platform: AppPlatform.Ios,
|
||||
|
||||
@@ -110,6 +110,7 @@ function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): De
|
||||
return {
|
||||
id: 'session-123',
|
||||
name: null,
|
||||
tags: [],
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
platform: AppPlatform.Ios,
|
||||
@@ -613,6 +614,19 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('warns that Android emulator support is still in development before creating a session', async () => {
|
||||
const { command } = createCommand(['--platform', 'android', '--non-interactive']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
|
||||
);
|
||||
expect(jest.mocked(Log.warn).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockCreateDeviceRunSessionAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ios', AppPlatform.Ios],
|
||||
['android', AppPlatform.Android],
|
||||
@@ -696,6 +710,36 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards repeated tags', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--tag',
|
||||
'variant:pro',
|
||||
'--tag',
|
||||
' nightly ',
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ tags: ['variant:pro', 'nightly'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('omits tags when every tag is blank', async () => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--tag', ' ']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.not.objectContaining({ tags: expect.anything() })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--launch-arg', '--uitesting'],
|
||||
['--open-url', 'exp://example.test'],
|
||||
|
||||
@@ -42,6 +42,7 @@ function makeSession(overrides: Partial<DeviceRunSessionNode> = {}): DeviceRunSe
|
||||
return {
|
||||
id: 'session-123',
|
||||
name: null,
|
||||
tags: [],
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
platform: AppPlatform.Ios,
|
||||
@@ -132,6 +133,7 @@ describe(SimulatorList, () => {
|
||||
{
|
||||
id: 'session-123',
|
||||
name: undefined,
|
||||
tags: [],
|
||||
type: 'web-preview-only',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
platform: AppPlatform.Ios,
|
||||
@@ -168,6 +170,10 @@ describe(SimulatorList, () => {
|
||||
'ios',
|
||||
'--name',
|
||||
'checkout',
|
||||
'--tag',
|
||||
'variant:pro',
|
||||
'--tag',
|
||||
'nightly',
|
||||
'--limit',
|
||||
'25',
|
||||
'--after',
|
||||
@@ -184,10 +190,30 @@ describe(SimulatorList, () => {
|
||||
types: [DeviceRunSessionType.Appium, DeviceRunSessionType.WebPreviewOnly],
|
||||
platforms: [AppPlatform.Ios],
|
||||
name: 'checkout',
|
||||
tags: ['variant:pro', 'nightly'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prints a tags row for every session, tagged or not', async () => {
|
||||
mockListByAppIdAsync.mockResolvedValue(
|
||||
makeConnection([
|
||||
makeSession({ id: 'session-1', tags: ['variant:pro', 'nightly'] }),
|
||||
makeSession({ id: 'session-2' }),
|
||||
])
|
||||
);
|
||||
|
||||
const { command } = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const printed = jest
|
||||
.mocked(Log.log)
|
||||
.mock.calls.map(([entry]) => String(entry))
|
||||
.join('\n');
|
||||
expect(printed).toContain('Tags: variant:pro, nightly');
|
||||
expect(printed).toContain('Tags: none');
|
||||
});
|
||||
|
||||
it('prints a name row for every session, named or not', async () => {
|
||||
mockListByAppIdAsync.mockResolvedValue(
|
||||
makeConnection([
|
||||
|
||||
@@ -87,6 +87,7 @@ export default class SimulatorGet extends EasCommand {
|
||||
printJsonOnlyOutput({
|
||||
id: session.id,
|
||||
name: session.name ?? undefined,
|
||||
tags: session.tags,
|
||||
type: deviceRunSessionTypeToFlagValue(session.type),
|
||||
status: session.status,
|
||||
platform: session.platform,
|
||||
@@ -123,6 +124,7 @@ function formatSessionFields(session: DeviceRunSessionById, deviceRunSessionUrl:
|
||||
return formatFields([
|
||||
{ label: 'ID', value: session.id },
|
||||
{ label: 'Name', value: formatNullable(session.name) },
|
||||
{ label: 'Tags', value: session.tags.length > 0 ? session.tags.join(', ') : 'none' },
|
||||
{ label: 'Type', value: session.type },
|
||||
{ label: 'Status', value: session.status },
|
||||
{ label: 'Platform', value: session.platform },
|
||||
|
||||
@@ -71,6 +71,11 @@ export default class Simulator extends EasCommand {
|
||||
description:
|
||||
'Human-readable name for the simulator session, shown in eas simulator:list and on expo.dev. Defaults to unnamed.',
|
||||
}),
|
||||
tag: Flags.string({
|
||||
description:
|
||||
'Label used to group simulator sessions, for example one per app variant. Repeat for multiple tags. Stored lowercased.',
|
||||
multiple: true,
|
||||
}),
|
||||
device: Flags.string({
|
||||
description:
|
||||
'Virtual device to start for the session. On iOS, a Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android, an AVD hardware profile id (e.g. "pixel_7"). Defaults to a device chosen by the runner.',
|
||||
@@ -179,6 +184,7 @@ export default class Simulator extends EasCommand {
|
||||
// The server rejects blank names, so trim here and treat a whitespace-only
|
||||
// --name as if it had been omitted rather than surfacing a validation error.
|
||||
const name = flags.name?.trim() || undefined;
|
||||
const tags = flags.tag?.map(tag => tag.trim()).filter(tag => tag.length > 0);
|
||||
const deviceIdentifier = flags.device?.trim() || undefined;
|
||||
const buildId = flags['build-id']?.trim() || undefined;
|
||||
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
|
||||
@@ -212,6 +218,12 @@ export default class Simulator extends EasCommand {
|
||||
}
|
||||
|
||||
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
|
||||
if (platform === AppPlatform.Android) {
|
||||
Log.warn(
|
||||
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
|
||||
);
|
||||
Log.newLine();
|
||||
}
|
||||
const expoGoSdkVersion = flags['expo-go']
|
||||
? await resolveExpoGoSdkVersionAsync({ projectDir, sdkVersion: sdkVersionFromFlag })
|
||||
: undefined;
|
||||
@@ -233,6 +245,7 @@ export default class Simulator extends EasCommand {
|
||||
const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
name,
|
||||
...(tags?.length ? { tags } : {}),
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
|
||||
@@ -74,6 +74,10 @@ export default class SimulatorList extends EasCommand {
|
||||
name: Flags.string({
|
||||
description: 'Filter by session name (case-insensitive prefix match)',
|
||||
}),
|
||||
tag: Flags.string({
|
||||
description: 'Filter by tag (repeatable). A session must carry every tag listed.',
|
||||
multiple: true,
|
||||
}),
|
||||
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
|
||||
after: Flags.string({
|
||||
description:
|
||||
@@ -115,6 +119,9 @@ export default class SimulatorList extends EasCommand {
|
||||
if (flags.name) {
|
||||
filter.name = flags.name;
|
||||
}
|
||||
if (flags.tag && flags.tag.length > 0) {
|
||||
filter.tags = flags.tag;
|
||||
}
|
||||
|
||||
const limit = flags.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
@@ -140,6 +147,7 @@ export default class SimulatorList extends EasCommand {
|
||||
sessions: sessions.map(session => ({
|
||||
id: session.id,
|
||||
name: session.name ?? undefined,
|
||||
tags: session.tags,
|
||||
type: deviceRunSessionTypeToFlagValue(session.type),
|
||||
status: session.status,
|
||||
platform: session.platform,
|
||||
@@ -173,6 +181,7 @@ export default class SimulatorList extends EasCommand {
|
||||
const lines = [
|
||||
`ID: ${session.id}`,
|
||||
`Name: ${session.name ?? 'null'}`,
|
||||
`Tags: ${session.tags.length > 0 ? session.tags.join(', ') : 'none'}`,
|
||||
`Type: ${session.type}`,
|
||||
`Status: ${session.status}`,
|
||||
`Platform: ${session.platform}`,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { fileExistsAsync, maybeReadStdinAsync } from '../../../commandUtils/workflow/utils';
|
||||
import { WorkflowRevisionMutation } from '../../../graphql/mutations/WorkflowRevisionMutation';
|
||||
import { WorkflowRunMutation } from '../../../graphql/mutations/WorkflowRunMutation';
|
||||
import Log from '../../../log';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
|
||||
import { uploadAccountScopedFileAsync } from '../../../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../../../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { WorkflowFile } from '../../../utils/workflowFile';
|
||||
import WorkflowRun from '../run';
|
||||
|
||||
jest.mock('@expo/spawn-async', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../graphql/mutations/WorkflowRevisionMutation');
|
||||
jest.mock('../../../graphql/mutations/WorkflowRunMutation');
|
||||
jest.mock('../../../project/projectUtils');
|
||||
jest.mock('../../../project/uploadAccountScopedFileAsync');
|
||||
jest.mock('../../../project/uploadAccountScopedProjectSourceAsync');
|
||||
jest.mock('../../../utils/workflowFile');
|
||||
jest.mock('../../../commandUtils/workflow/utils', () => ({
|
||||
...jest.requireActual('../../../commandUtils/workflow/utils'),
|
||||
fileExistsAsync: jest.fn(),
|
||||
maybeReadStdinAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../log', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
newLine: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
link: jest.fn((url: string) => url),
|
||||
}));
|
||||
|
||||
const mockSpawn = jest.mocked(spawnAsync);
|
||||
const mockValidate = jest.mocked(WorkflowRevisionMutation.validateWorkflowYamlConfigAsync);
|
||||
const mockGetOrCreateRevision = jest.mocked(
|
||||
WorkflowRevisionMutation.getOrCreateWorkflowRevisionFromGitRefAsync
|
||||
);
|
||||
const mockCreateFromGitRef = jest.mocked(WorkflowRunMutation.createWorkflowRunFromGitRefAsync);
|
||||
const mockCreateRun = jest.mocked(WorkflowRunMutation.createWorkflowRunAsync);
|
||||
const mockGetOwner = jest.mocked(getOwnerAccountForProjectIdAsync);
|
||||
const mockUploadProject = jest.mocked(uploadAccountScopedProjectSourceAsync);
|
||||
const mockUploadFile = jest.mocked(uploadAccountScopedFileAsync);
|
||||
const mockReadWorkflow = jest.mocked(WorkflowFile.readWorkflowFileContentsAsync);
|
||||
const mockFileExists = jest.mocked(fileExistsAsync);
|
||||
const mockReadStdin = jest.mocked(maybeReadStdinAsync);
|
||||
|
||||
describe('WorkflowRun --ssh', () => {
|
||||
let mockConfig: Config;
|
||||
let exitSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
mockConfig = new Config({ root: __dirname });
|
||||
mockConfig.runHook = async () => ({ failures: [], successes: [] });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as never);
|
||||
mockReadStdin.mockResolvedValue(null);
|
||||
mockGetOwner.mockResolvedValue({ id: 'acct-1', name: 'testuser' } as never);
|
||||
mockValidate.mockResolvedValue(undefined as never);
|
||||
mockReadWorkflow.mockResolvedValue({
|
||||
filePath: '.eas/workflows/test.yml',
|
||||
yamlConfig: 'jobs:\n a:\n steps: []\n',
|
||||
} as never);
|
||||
mockFileExists.mockResolvedValue(false);
|
||||
mockUploadProject.mockResolvedValue({ projectArchiveBucketKey: 'archive-key' } as never);
|
||||
mockUploadFile.mockResolvedValue({ fileBucketKey: 'file-key' } as never);
|
||||
mockCreateRun.mockResolvedValue({ id: 'run-1' } as never);
|
||||
mockCreateFromGitRef.mockResolvedValue({ id: 'run-git-1' } as never);
|
||||
mockSpawn.mockResolvedValue({ output: ['abc123\n'] } as never);
|
||||
mockGetOrCreateRevision.mockResolvedValue({
|
||||
id: 'rev-1',
|
||||
yamlConfig: 'jobs:\n a:\n steps: []\n',
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createCommand(flags: {
|
||||
ssh?: boolean;
|
||||
'ssh-idle-timeout'?: number;
|
||||
ref?: string;
|
||||
}): WorkflowRun {
|
||||
const command = new WorkflowRun(['.eas/workflows/test.yml'], mockConfig);
|
||||
// @ts-expect-error getContextAsync/parse are protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
getDynamicPrivateProjectConfigAsync: async () => ({
|
||||
projectId: 'app-1',
|
||||
exp: { slug: 'testapp' },
|
||||
}),
|
||||
loggedIn: { graphqlClient: {} },
|
||||
vcsClient: { getRootPathAsync: async () => '/test/project' },
|
||||
projectDir: '/test/project',
|
||||
});
|
||||
// @ts-expect-error parse is protected on the oclif base
|
||||
jest.spyOn(command, 'parse').mockResolvedValue({
|
||||
flags: {
|
||||
json: false,
|
||||
wait: false,
|
||||
'non-interactive': true,
|
||||
input: undefined,
|
||||
ref: flags.ref,
|
||||
ssh: flags.ssh ?? false,
|
||||
'ssh-idle-timeout': flags['ssh-idle-timeout'],
|
||||
},
|
||||
args: { file: '.eas/workflows/test.yml' },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('passes ssh input into createWorkflowRunAsync and logs that SSH is enabled', async () => {
|
||||
await expect(createCommand({ ssh: true, 'ssh-idle-timeout': 120 }).runAsync()).rejects.toThrow(
|
||||
'process.exit:0'
|
||||
);
|
||||
|
||||
expect(mockCreateRun).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
workflowRunInput: expect.objectContaining({
|
||||
ssh: { idleTimeoutSeconds: 120 },
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'SSH enabled. Each VM job logs the `eas workflow:ssh` command to connect.'
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('does not log the SSH banner when --ssh is unset', async () => {
|
||||
await expect(createCommand({ ssh: false }).runAsync()).rejects.toThrow('process.exit:0');
|
||||
|
||||
expect(mockCreateRun).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
workflowRunInput: expect.objectContaining({
|
||||
ssh: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(Log.log).not.toHaveBeenCalledWith(
|
||||
'SSH enabled. Each VM job logs the `eas workflow:ssh` command to connect.'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes ssh input into createWorkflowRunFromGitRefAsync when --ref is set', async () => {
|
||||
await expect(createCommand({ ssh: true, ref: 'main' }).runAsync()).rejects.toThrow(
|
||||
'process.exit:0'
|
||||
);
|
||||
|
||||
expect(mockCreateFromGitRef).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
ssh: { idleTimeoutSeconds: undefined },
|
||||
})
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'SSH enabled. Each VM job logs the `eas workflow:ssh` command to connect.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,31 @@ import {
|
||||
parseJsonInputs,
|
||||
parseWorkflowInputsFromYaml,
|
||||
} from '../../../commandUtils/workflow/inputs';
|
||||
import { resolveWorkflowRunSshInput } from '../run';
|
||||
|
||||
describe(resolveWorkflowRunSshInput, () => {
|
||||
it('is null when --ssh is not set', () => {
|
||||
expect(resolveWorkflowRunSshInput({ ssh: false, idleTimeoutSeconds: undefined })).toBeNull();
|
||||
});
|
||||
|
||||
it('forwards ssh with an undefined timeout when --ssh is set alone', () => {
|
||||
expect(resolveWorkflowRunSshInput({ ssh: true, idleTimeoutSeconds: undefined })).toEqual({
|
||||
idleTimeoutSeconds: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the requested idle timeout when both flags are set', () => {
|
||||
expect(resolveWorkflowRunSshInput({ ssh: true, idleTimeoutSeconds: 60 })).toEqual({
|
||||
idleTimeoutSeconds: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects --ssh-idle-timeout without --ssh', () => {
|
||||
expect(() => resolveWorkflowRunSshInput({ ssh: false, idleTimeoutSeconds: 60 })).toThrow(
|
||||
'--ssh-idle-timeout requires --ssh.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseInputs', () => {
|
||||
it('should parse single key=value pair', () => {
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { Config } from '@oclif/core';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { WorkflowJobSshQuery } from '../../../graphql/queries/WorkflowJobSshQuery';
|
||||
import Log from '../../../log';
|
||||
import { sleepAsync } from '../../../utils/promise';
|
||||
import WorkflowSsh, {
|
||||
parseSshArgv,
|
||||
resolveSshConnectStatus,
|
||||
splitConnectionHost,
|
||||
sshHostAliasForResource,
|
||||
terminalSshStatusMessage,
|
||||
} from '../ssh';
|
||||
|
||||
jest.mock('@expo/spawn-async', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../graphql/queries/WorkflowJobSshQuery');
|
||||
jest.mock('../../../log', () => ({
|
||||
__esModule: true,
|
||||
default: { log: jest.fn(), error: jest.fn(), newLine: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
const spinner = { start: jest.fn(), fail: jest.fn(), succeed: jest.fn(), stop: jest.fn() };
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
return spinner;
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../utils/promise', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('node:fs', () => {
|
||||
const actual = jest.requireActual('node:fs');
|
||||
return {
|
||||
...actual,
|
||||
promises: { ...actual.promises, mkdtemp: jest.fn(), writeFile: jest.fn(), rm: jest.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
describe(parseSshArgv, () => {
|
||||
it('takes the first token as the resource id and the rest as the passthrough command', () => {
|
||||
expect(parseSshArgv(['job-id', 'ls', '-la'])).toEqual({
|
||||
showConnect: false,
|
||||
resourceId: 'job-id',
|
||||
command: ['ls', '-la'],
|
||||
});
|
||||
});
|
||||
|
||||
it('detects --show-connect before the id and strips it', () => {
|
||||
expect(parseSshArgv(['--show-connect', 'job-id'])).toEqual({
|
||||
showConnect: true,
|
||||
resourceId: 'job-id',
|
||||
command: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('detects --show-connect=true before the id', () => {
|
||||
expect(parseSshArgv(['--show-connect=true', 'job-id'])).toEqual({
|
||||
showConnect: true,
|
||||
resourceId: 'job-id',
|
||||
command: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves --show-connect after the id in the passthrough command', () => {
|
||||
expect(parseSshArgv(['job-id', '--show-connect'])).toEqual({
|
||||
showConnect: false,
|
||||
resourceId: 'job-id',
|
||||
command: ['--show-connect'],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on an unknown flag before the id', () => {
|
||||
expect(() => parseSshArgv(['--show-conect', 'job-id'])).toThrow('Unknown flag');
|
||||
});
|
||||
|
||||
it('reports an undefined resource id when none is given', () => {
|
||||
expect(parseSshArgv([])).toEqual({ showConnect: false, resourceId: undefined, command: [] });
|
||||
expect(parseSshArgv(['--show-connect'])).toEqual({
|
||||
showConnect: true,
|
||||
resourceId: undefined,
|
||||
command: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveSshConnectStatus, () => {
|
||||
it('is unknown when the resource did not resolve', () => {
|
||||
expect(resolveSshConnectStatus(null)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('is not-enabled when the job did not request ssh', () => {
|
||||
expect(
|
||||
resolveSshConnectStatus({ sshRequested: false, jobCompleted: false, session: null })
|
||||
).toBe('not-enabled');
|
||||
});
|
||||
|
||||
it('is ended when ssh was requested, the job finished, and no session remains', () => {
|
||||
expect(resolveSshConnectStatus({ sshRequested: true, jobCompleted: true, session: null })).toBe(
|
||||
'ended'
|
||||
);
|
||||
});
|
||||
|
||||
it('is pending when ssh was requested and the job is still running without a session', () => {
|
||||
expect(
|
||||
resolveSshConnectStatus({ sshRequested: true, jobCompleted: false, session: null })
|
||||
).toBe('pending');
|
||||
});
|
||||
|
||||
it('is pending while the worker is reconnecting', () => {
|
||||
expect(
|
||||
resolveSshConnectStatus({
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session: {
|
||||
connectionConfig: { host: 'uptermd.upterm.dev', secret: 'tok', reconnecting: true },
|
||||
},
|
||||
})
|
||||
).toBe('pending');
|
||||
});
|
||||
|
||||
it('is ready when the session has a connection config', () => {
|
||||
expect(
|
||||
resolveSshConnectStatus({
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session: {
|
||||
connectionConfig: { host: 'uptermd.upterm.dev', secret: 'tok', reconnecting: false },
|
||||
},
|
||||
})
|
||||
).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe(splitConnectionHost, () => {
|
||||
it('returns the host with no port for a plain hostname', () => {
|
||||
expect(splitConnectionHost('relay.expo.dev')).toEqual({ hostname: 'relay.expo.dev' });
|
||||
});
|
||||
|
||||
it('splits a host:port into host and numeric port', () => {
|
||||
expect(splitConnectionHost('relay.expo.dev:8022')).toEqual({
|
||||
hostname: 'relay.expo.dev',
|
||||
port: 8022,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(terminalSshStatusMessage, () => {
|
||||
it('has no message while the session can still open', () => {
|
||||
expect(terminalSshStatusMessage('pending', 'job-1')).toBeNull();
|
||||
expect(terminalSshStatusMessage('ready', 'job-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('distinguishes not-enabled from ended', () => {
|
||||
expect(terminalSshStatusMessage('not-enabled', 'job-1')).toMatch(/SSH was not enabled/);
|
||||
expect(terminalSshStatusMessage('ended', 'job-1')).toMatch(/has ended/);
|
||||
expect(terminalSshStatusMessage('unknown', 'job-1')).toMatch(/No workflow job found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(sshHostAliasForResource, () => {
|
||||
it('suffixes the alias with the start of the resource id', () => {
|
||||
expect(sshHostAliasForResource('job-1')).toBe('eas-workflow-ssh-job1');
|
||||
});
|
||||
|
||||
it('drops characters that are not safe in an ssh config host alias', () => {
|
||||
expect(sshHostAliasForResource('a b\nHost evil')).toBe('eas-workflow-ssh-abHostev');
|
||||
});
|
||||
|
||||
it('falls back to the bare alias when nothing usable remains', () => {
|
||||
expect(sshHostAliasForResource('---')).toBe('eas-workflow-ssh');
|
||||
});
|
||||
});
|
||||
|
||||
describe(WorkflowSsh, () => {
|
||||
const graphqlClient = {} as never;
|
||||
const mockConnectInfo = jest.mocked(WorkflowJobSshQuery.connectInfoForResourceIdAsync);
|
||||
const mockSpawn = jest.mocked(spawnAsync);
|
||||
const mockSleep = jest.mocked(sleepAsync);
|
||||
const mockMkdtemp = jest.mocked(fs.promises.mkdtemp);
|
||||
const mockWriteFile = jest.mocked(fs.promises.writeFile);
|
||||
const mockRm = jest.mocked(fs.promises.rm);
|
||||
|
||||
let mockConfig: Config;
|
||||
let previousExitCode: typeof process.exitCode;
|
||||
|
||||
const readyInfo = {
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session: {
|
||||
id: 'ts-1',
|
||||
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
|
||||
},
|
||||
};
|
||||
const pendingInfo = { sshRequested: true, jobCompleted: false, session: null };
|
||||
const endedInfo = { sshRequested: true, jobCompleted: true, session: null };
|
||||
const notEnabledInfo = { sshRequested: false, jobCompleted: false, session: null };
|
||||
|
||||
beforeAll(async () => {
|
||||
mockConfig = new Config({ root: __dirname });
|
||||
mockConfig.runHook = async () => ({ failures: [], successes: [] });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
previousExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
mockMkdtemp.mockResolvedValue('/tmp/eas-ssh-1' as never);
|
||||
mockWriteFile.mockResolvedValue(undefined as never);
|
||||
mockRm.mockResolvedValue(undefined as never);
|
||||
mockSpawn.mockResolvedValue({ stdout: '', stderr: '' } as never);
|
||||
mockSleep.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = previousExitCode;
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): WorkflowSsh {
|
||||
const command = new WorkflowSsh(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync/parse are protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({ loggedIn: { graphqlClient } });
|
||||
// @ts-expect-error parse is protected on the oclif base
|
||||
jest.spyOn(command, 'parse').mockResolvedValue({});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('throws when no workflow job id is given', async () => {
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow('Provide a workflow job id');
|
||||
});
|
||||
|
||||
it('reports unknown and exits 1 when the id does not resolve', async () => {
|
||||
mockConnectInfo.mockResolvedValue(null);
|
||||
await createCommand(['job-1']).runAsync();
|
||||
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('No workflow job found'));
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('reports not-enabled and exits 1 when the job did not request ssh', async () => {
|
||||
mockConnectInfo.mockResolvedValue(notEnabledInfo as never);
|
||||
await createCommand(['job-1']).runAsync();
|
||||
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('was not enabled'));
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('reports ended and exits 1 when the job finished without a live session', async () => {
|
||||
mockConnectInfo.mockResolvedValue(endedInfo as never);
|
||||
await createCommand(['job-1']).runAsync();
|
||||
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('has ended'));
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('prints the connect command for --show-connect without spawning ssh', async () => {
|
||||
mockConnectInfo.mockResolvedValue(readyInfo as never);
|
||||
await createCommand(['--show-connect', 'job-1']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null [email protected]'
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints --show-connect with -p and a hostname-only wss proxy for non-default SSH ports', async () => {
|
||||
mockConnectInfo.mockResolvedValue({
|
||||
...readyInfo,
|
||||
session: {
|
||||
id: 'ts-1',
|
||||
connectionConfig: {
|
||||
host: 'relay.expo.dev:8022',
|
||||
secret: 'TOKENx',
|
||||
reconnecting: false,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
await createCommand(['--show-connect', 'job-1']).runAsync();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 8022 [email protected]'
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
' ssh -o ProxyCommand="upterm proxy wss://[email protected]" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 8022 [email protected]'
|
||||
);
|
||||
});
|
||||
|
||||
it('writes a 0600 config and opens ssh, then removes the config dir', async () => {
|
||||
mockConnectInfo.mockResolvedValue(readyInfo as never);
|
||||
await createCommand(['job-1', 'ls', '-la']).runAsync();
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
'/tmp/eas-ssh-1/config',
|
||||
expect.stringContaining('HostName relay.expo.dev'),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
'/tmp/eas-ssh-1/config',
|
||||
expect.stringContaining('Host eas-workflow-ssh-job1'),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'ssh',
|
||||
['-F', '/tmp/eas-ssh-1/config', 'eas-workflow-ssh-job1', 'ls', '-la'],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
expect(mockRm).toHaveBeenCalledWith('/tmp/eas-ssh-1', { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('waits for a pending session to open, then connects', async () => {
|
||||
mockConnectInfo
|
||||
.mockResolvedValueOnce(pendingInfo as never) // initial status check
|
||||
.mockResolvedValueOnce(pendingInfo as never) // first wait poll
|
||||
.mockResolvedValue(readyInfo as never);
|
||||
await createCommand(['job-1']).runAsync();
|
||||
expect(mockSleep).toHaveBeenCalled();
|
||||
expect(mockSpawn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits 1 when a pending session ends before it opens', async () => {
|
||||
mockConnectInfo
|
||||
.mockResolvedValueOnce(pendingInfo as never)
|
||||
.mockResolvedValue(endedInfo as never);
|
||||
await createCommand(['job-1']).runAsync();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('exits 1 when waiting for the session to open times out', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
try {
|
||||
mockConnectInfo.mockResolvedValue(pendingInfo as never);
|
||||
mockSleep.mockImplementation(async () => {
|
||||
jest.setSystemTime(Date.now() + 6 * 60 * 1000);
|
||||
});
|
||||
await createCommand(['job-1']).runAsync();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws on an unexpected connection host', async () => {
|
||||
mockConnectInfo.mockResolvedValue({
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session: {
|
||||
connectionConfig: { host: 'bad host', secret: 'TOKENx', reconnecting: false },
|
||||
},
|
||||
} as never);
|
||||
await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('connection host');
|
||||
});
|
||||
|
||||
it('propagates the ssh exit code from the catch handler', async () => {
|
||||
const command = createCommand(['job-1']);
|
||||
// @ts-expect-error isRunningSubprocess is private
|
||||
command.isRunningSubprocess = true;
|
||||
const err = Object.assign(new Error('ssh exited'), { status: 42 });
|
||||
// @ts-expect-error catch is protected
|
||||
await command.catch(err);
|
||||
expect(process.exitCode).toBe(42);
|
||||
});
|
||||
|
||||
it('reports a missing ssh client (ENOENT) from the catch handler', async () => {
|
||||
const command = createCommand(['job-1']);
|
||||
// @ts-expect-error isRunningSubprocess is private
|
||||
command.isRunningSubprocess = true;
|
||||
const err = Object.assign(new Error('spawn ssh ENOENT'), { code: 'ENOENT' });
|
||||
// @ts-expect-error catch is protected
|
||||
await command.catch(err);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(Log.error).toHaveBeenCalledWith(expect.stringContaining('Install an OpenSSH client'));
|
||||
});
|
||||
|
||||
it('delegates non-subprocess errors to the base handler', async () => {
|
||||
const command = createCommand(['job-1']);
|
||||
let threw = false;
|
||||
try {
|
||||
// @ts-expect-error catch is protected
|
||||
await command.catch(new Error('boom'));
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
it('stops the spinner and rethrows when polling fails while waiting', async () => {
|
||||
mockConnectInfo
|
||||
.mockResolvedValueOnce(pendingInfo as never)
|
||||
.mockRejectedValue(new Error('network blip'));
|
||||
await expect(createCommand(['job-1']).runAsync()).rejects.toThrow('network blip');
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,19 @@ import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccou
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { WorkflowFile } from '../../utils/workflowFile';
|
||||
|
||||
export function resolveWorkflowRunSshInput({
|
||||
ssh,
|
||||
idleTimeoutSeconds,
|
||||
}: {
|
||||
ssh: boolean;
|
||||
idleTimeoutSeconds: number | undefined;
|
||||
}): { idleTimeoutSeconds: number | undefined } | null {
|
||||
if (idleTimeoutSeconds !== undefined && !ssh) {
|
||||
throw new Error('--ssh-idle-timeout requires --ssh.');
|
||||
}
|
||||
return ssh ? { idleTimeoutSeconds } : null;
|
||||
}
|
||||
|
||||
export default class WorkflowRun extends EasCommand {
|
||||
static override description =
|
||||
'run an EAS workflow. The entire local project directory will be packaged and uploaded to EAS servers for the workflow run, unless the --ref flag is used.';
|
||||
@@ -110,6 +123,18 @@ export default class WorkflowRun extends EasCommand {
|
||||
"The git reference must exist in the project's git repository, and the workflow file must exist at that reference. When this flag is used, the local project is not uploaded; instead, the workflow is run from the exact state of the project at the chosen reference.",
|
||||
summary: 'Git reference to run the workflow on',
|
||||
}),
|
||||
ssh: Flags.boolean({
|
||||
default: false,
|
||||
description: 'Open an SSH session on each VM job for live debugging with `eas workflow:ssh`.',
|
||||
summary: '[EXPERIMENTAL] Enable SSH on the run',
|
||||
}),
|
||||
'ssh-idle-timeout': Flags.integer({
|
||||
description:
|
||||
'Seconds to keep an SSH session open after its job finishes, while no client is connected. The session is always available for the whole job. Requires --ssh. Must be between 0 and 3600; defaults to 0 (close as soon as the job finishes and nobody is connected).',
|
||||
summary: '[EXPERIMENTAL] SSH idle timeout in seconds',
|
||||
min: 0,
|
||||
max: 3600,
|
||||
}),
|
||||
...EasJsonOnlyFlag,
|
||||
};
|
||||
|
||||
@@ -127,6 +152,11 @@ export default class WorkflowRun extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const sshInput = resolveWorkflowRunSshInput({
|
||||
ssh: flags.ssh,
|
||||
idleTimeoutSeconds: flags['ssh-idle-timeout'],
|
||||
});
|
||||
|
||||
const {
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
@@ -272,6 +302,7 @@ export default class WorkflowRun extends EasCommand {
|
||||
workflowRevisionId: workflowRevisionId ?? '',
|
||||
gitRef,
|
||||
inputs,
|
||||
ssh: sshInput,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to create workflow run: ${err}`);
|
||||
@@ -341,6 +372,7 @@ export default class WorkflowRun extends EasCommand {
|
||||
packageJsonBucketKey,
|
||||
projectRootDirectory,
|
||||
},
|
||||
ssh: sshInput,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
@@ -353,6 +385,10 @@ export default class WorkflowRun extends EasCommand {
|
||||
Log.newLine();
|
||||
Log.log(`See logs: ${link(getWorkflowRunUrl(account.name, projectName, workflowRunId))}`);
|
||||
|
||||
if (sshInput) {
|
||||
Log.log('SSH enabled. Each VM job logs the `eas workflow:ssh` command to connect.');
|
||||
}
|
||||
|
||||
if (!flags.wait) {
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput({
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { Flags } from '@oclif/core';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
WorkflowJobSshConnectInfo,
|
||||
WorkflowJobSshQuery,
|
||||
WorkflowJobSshSession,
|
||||
} from '../../graphql/queries/WorkflowJobSshQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
|
||||
export function splitConnectionHost(connectionHost: string): { hostname: string; port?: number } {
|
||||
try {
|
||||
const { hostname, port: stringPort } = new URL(`ssh://${connectionHost}`);
|
||||
const port = stringPort === '' ? undefined : Number(stringPort);
|
||||
return { hostname, port };
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
'Unexpected connection host reported for this ssh session. Update eas-cli and try again, or contact support if it persists.',
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SSH_INSECURE_OPTS = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
|
||||
|
||||
const SESSION_OPEN_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const SESSION_OPEN_POLL_INTERVAL_MS = 3000;
|
||||
|
||||
export type SshConnectStatus = 'unknown' | 'not-enabled' | 'ended' | 'pending' | 'ready';
|
||||
|
||||
export function resolveSshConnectStatus(
|
||||
connectInfo:
|
||||
| (Pick<WorkflowJobSshConnectInfo, 'sshRequested' | 'jobCompleted'> & {
|
||||
session?: Pick<WorkflowJobSshSession, 'connectionConfig'> | null;
|
||||
})
|
||||
| null
|
||||
): SshConnectStatus {
|
||||
if (!connectInfo) {
|
||||
return 'unknown';
|
||||
}
|
||||
const { sshRequested, jobCompleted, session } = connectInfo;
|
||||
if (session?.connectionConfig && !session.connectionConfig.reconnecting) {
|
||||
return 'ready';
|
||||
}
|
||||
if (session?.connectionConfig?.reconnecting) {
|
||||
return 'pending';
|
||||
}
|
||||
if (!sshRequested) {
|
||||
return 'not-enabled';
|
||||
}
|
||||
return jobCompleted ? 'ended' : 'pending';
|
||||
}
|
||||
|
||||
export function terminalSshStatusMessage(
|
||||
status: SshConnectStatus,
|
||||
resourceId: string
|
||||
): string | null {
|
||||
switch (status) {
|
||||
case 'unknown':
|
||||
return `No workflow job found for "${resourceId}". Pass a workflow job id from a run started with \`eas workflow:run --ssh\`.`;
|
||||
case 'not-enabled':
|
||||
return `SSH was not enabled for "${resourceId}". Start the run with \`eas workflow:run --ssh\` to enable it.`;
|
||||
case 'ended':
|
||||
return 'This ssh session has ended.';
|
||||
case 'pending':
|
||||
case 'ready':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function sshHostAliasForResource(resourceId: string): string {
|
||||
const suffix = resourceId.replace(/[^A-Za-z0-9]/g, '').slice(0, 8);
|
||||
return suffix ? `eas-workflow-ssh-${suffix}` : 'eas-workflow-ssh';
|
||||
}
|
||||
|
||||
export function parseSshArgv(rawArgv: readonly string[]): {
|
||||
showConnect: boolean;
|
||||
resourceId: string | undefined;
|
||||
command: string[];
|
||||
} {
|
||||
let showConnect = false;
|
||||
let index = 0;
|
||||
while (index < rawArgv.length && rawArgv[index].startsWith('-')) {
|
||||
if (rawArgv[index] === '--show-connect' || rawArgv[index] === '--show-connect=true') {
|
||||
showConnect = true;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unknown flag "${rawArgv[index]}" before the id. The only supported flag is --show-connect; everything after the id is passed to the remote shell.`
|
||||
);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
const [resourceId, ...command] = rawArgv.slice(index);
|
||||
return { showConnect, resourceId, command };
|
||||
}
|
||||
|
||||
export default class WorkflowSsh extends EasCommand {
|
||||
static override hidden = true;
|
||||
|
||||
static override description =
|
||||
'[EXPERIMENTAL] open an ssh session on the worker running a workflow job';
|
||||
|
||||
static override strict = false;
|
||||
|
||||
static override flags = {
|
||||
'show-connect': Flags.boolean({
|
||||
description: 'Print the ssh connection command (host and token) instead of opening a session',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private isRunningSubprocess = false;
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const rawArgv = [...this.argv];
|
||||
await this.parse(WorkflowSsh, []);
|
||||
const { showConnect, resourceId, command } = parseSshArgv(rawArgv);
|
||||
|
||||
if (typeof resourceId !== 'string' || resourceId.length === 0) {
|
||||
throw new Error('Provide a workflow job id: eas workflow:ssh <workflow-job-id> [command...]');
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(WorkflowSsh, { nonInteractive: true });
|
||||
|
||||
const connectInfo = await WorkflowJobSshQuery.connectInfoForResourceIdAsync(
|
||||
graphqlClient,
|
||||
resourceId
|
||||
);
|
||||
const status = resolveSshConnectStatus(connectInfo);
|
||||
const statusMessage = terminalSshStatusMessage(status, resourceId);
|
||||
if (statusMessage) {
|
||||
Log.error(statusMessage);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionConfig =
|
||||
(status === 'ready' ? connectInfo?.session?.connectionConfig : null) ??
|
||||
(await waitForSessionToOpenAsync(graphqlClient, resourceId));
|
||||
if (!connectionConfig) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const { host: connectionHost, secret } = connectionConfig;
|
||||
const { hostname, port } = splitConnectionHost(connectionHost);
|
||||
const portOption = port !== undefined ? ` -p ${port}` : '';
|
||||
|
||||
if (showConnect) {
|
||||
Log.log(`ssh ${SSH_INSECURE_OPTS}${portOption} ${secret}@${hostname}`);
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
'If your network blocks the direct SSH connection, reach the session through the WebSocket relay with upterm (https://upterm.dev):'
|
||||
);
|
||||
// WSS terminates on the relay hostname (default 443). Do not paste an SSH
|
||||
// :port into the wss:// URL — that port is only for the ssh destination.
|
||||
Log.log(
|
||||
` ssh -o ProxyCommand="upterm proxy wss://${secret}@${hostname}" ${SSH_INSECURE_OPTS}${portOption} ${secret}@${hostname}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const configDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-workflow-ssh-'));
|
||||
try {
|
||||
const configPath = path.join(configDir, 'config');
|
||||
const hostAlias = sshHostAliasForResource(resourceId);
|
||||
await fs.promises.writeFile(
|
||||
configPath,
|
||||
[
|
||||
`Host ${hostAlias}`,
|
||||
` HostName ${hostname}`,
|
||||
...(port !== undefined ? [` Port ${port}`] : []),
|
||||
` User ${secret}`,
|
||||
' StrictHostKeyChecking no',
|
||||
' UserKnownHostsFile /dev/null',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
this.isRunningSubprocess = true;
|
||||
await spawnAsync('ssh', ['-F', configPath, hostAlias, ...command], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} finally {
|
||||
await fs.promises.rm(configDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
protected override catch(err: Error): Promise<void> {
|
||||
if (this.isRunningSubprocess) {
|
||||
if ((err as Error & { code?: string }).code === 'ENOENT') {
|
||||
Log.error(
|
||||
'Could not run `ssh`. Install an OpenSSH client and make sure `ssh` is on your PATH, then try again.'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return Promise.resolve();
|
||||
}
|
||||
const status = (err as Error & { status?: number | null }).status;
|
||||
process.exitCode = process.exitCode ?? status ?? 1;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return super.catch(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSessionToOpenAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
workflowJobId: string
|
||||
): Promise<NonNullable<WorkflowJobSshSession['connectionConfig']> | null> {
|
||||
const spinner = ora('Waiting for the worker to open the ssh session').start();
|
||||
const deadline = Date.now() + SESSION_OPEN_TIMEOUT_MS;
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const connectInfo = await WorkflowJobSshQuery.connectInfoForResourceIdAsync(
|
||||
graphqlClient,
|
||||
workflowJobId
|
||||
);
|
||||
const status = resolveSshConnectStatus(connectInfo);
|
||||
const statusMessage = terminalSshStatusMessage(status, workflowJobId);
|
||||
if (statusMessage) {
|
||||
spinner.fail(statusMessage);
|
||||
return null;
|
||||
}
|
||||
if (status === 'ready') {
|
||||
spinner.succeed('The ssh session is ready.');
|
||||
return connectInfo?.session?.connectionConfig ?? null;
|
||||
}
|
||||
await sleepAsync(SESSION_OPEN_POLL_INTERVAL_MS);
|
||||
}
|
||||
spinner.fail(
|
||||
'Timed out waiting for the ssh session to open. The worker may still be starting up; try again in a moment.'
|
||||
);
|
||||
return null;
|
||||
} catch (err) {
|
||||
spinner.stop();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
+44
-2
@@ -1073,6 +1073,8 @@ export type AgentDeviceRunSessionRemoteConfig = {
|
||||
__typename?: 'AgentDeviceRunSessionRemoteConfig';
|
||||
agentDeviceRemoteSessionToken: Scalars['String']['output'];
|
||||
agentDeviceRemoteSessionUrl: Scalars['String']['output'];
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* URL of the web preview surface for the session. Null when web previews are
|
||||
* not available for the platform (e.g. Android).
|
||||
@@ -1520,6 +1522,8 @@ export type App = Project & {
|
||||
/** @deprecated Classic updates have been deprecated. */
|
||||
description: Scalars['String']['output'];
|
||||
devDomainName?: Maybe<AppDevDomainName>;
|
||||
/** Every tag used by this app's device run sessions, sorted alphabetically. */
|
||||
deviceRunSessionTags: Array<Scalars['String']['output']>;
|
||||
deviceRunSessionsPaginated: AppDeviceRunSessionsConnection;
|
||||
embeddedUpdatesPaginated: AppEmbeddedUpdatesConnection;
|
||||
/** Environment secrets for an app */
|
||||
@@ -3808,6 +3812,8 @@ export type AppiumRunSessionRemoteConfig = {
|
||||
appiumUrl: Scalars['String']['output'];
|
||||
/** W3C capabilities for the device that backs this session. */
|
||||
capabilities: Scalars['JSONObject']['output'];
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* URL of the web preview surface for the session. Null when web previews are
|
||||
* not available for the platform (e.g. Android).
|
||||
@@ -4216,6 +4222,8 @@ export type ArgentRunSessionRemoteConfig = {
|
||||
__typename?: 'ArgentRunSessionRemoteConfig';
|
||||
toolsAuthToken?: Maybe<Scalars['String']['output']>;
|
||||
toolsUrl: Scalars['String']['output'];
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* URL of the web preview surface for the session. Null when web previews are
|
||||
* not available for the platform (e.g. Android).
|
||||
@@ -5460,6 +5468,8 @@ export type CreateDeviceRunSessionInput = {
|
||||
* true. If omitted, the current Expo Go archive for the platform is used.
|
||||
*/
|
||||
sdkVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Free-form labels for grouping sessions, for example one per app variant. */
|
||||
tags?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
type: DeviceRunSessionType;
|
||||
};
|
||||
|
||||
@@ -6239,6 +6249,8 @@ export type DeviceRunSession = {
|
||||
remoteConfig?: Maybe<DeviceRunSessionRemoteConfig>;
|
||||
startedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
status: DeviceRunSessionStatus;
|
||||
/** Free-form labels for grouping sessions, for example one per app variant. */
|
||||
tags: Array<Scalars['String']['output']>;
|
||||
turtleJobRun?: Maybe<JobRun>;
|
||||
type: DeviceRunSessionType;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
@@ -6285,6 +6297,8 @@ export type DeviceRunSessionFilterInput = {
|
||||
name?: InputMaybe<Scalars['String']['input']>;
|
||||
platforms?: InputMaybe<Array<AppPlatform>>;
|
||||
statuses?: InputMaybe<Array<DeviceRunSessionStatus>>;
|
||||
/** Matches sessions carrying every listed tag. Case-insensitive. */
|
||||
tags?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
types?: InputMaybe<Array<DeviceRunSessionType>>;
|
||||
};
|
||||
|
||||
@@ -10227,6 +10241,8 @@ export type SentryProjectMutationDeleteSentryProjectArgs = {
|
||||
*/
|
||||
export type ServeSimRunSessionRemoteConfig = {
|
||||
__typename?: 'ServeSimRunSessionRemoteConfig';
|
||||
/** Session token gating the preview. Null when the preview runs ungated. */
|
||||
previewToken?: Maybe<Scalars['String']['output']>;
|
||||
previewUrl: Scalars['String']['output'];
|
||||
/** @deprecated Use previewUrl instead. */
|
||||
streamUrl?: Maybe<Scalars['String']['output']>;
|
||||
@@ -13552,6 +13568,7 @@ export type WorkflowRunGitBranchFilterInput = {
|
||||
export type WorkflowRunInput = {
|
||||
inputs?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
projectSource: WorkflowProjectSourceInput;
|
||||
ssh?: InputMaybe<WorkflowRunSshInput>;
|
||||
};
|
||||
|
||||
export type WorkflowRunMutation = {
|
||||
@@ -13586,12 +13603,14 @@ export type WorkflowRunMutationCreateWorkflowRunArgs = {
|
||||
export type WorkflowRunMutationCreateWorkflowRunFromGitRefArgs = {
|
||||
gitRef: Scalars['String']['input'];
|
||||
inputs?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
ssh?: InputMaybe<WorkflowRunSshInput>;
|
||||
workflowRevisionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type WorkflowRunMutationRetryWorkflowRunArgs = {
|
||||
fromFailedJobs?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
ssh?: InputMaybe<WorkflowRunSshInput>;
|
||||
workflowRunId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
@@ -13605,6 +13624,14 @@ export type WorkflowRunQueryByIdArgs = {
|
||||
workflowRunId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Enables ssh on the run's VM jobs. Presence turns ssh on; idleTimeoutSeconds is optional,
|
||||
* defaults server-side, and is validated against a supported range.
|
||||
*/
|
||||
export type WorkflowRunSshInput = {
|
||||
idleTimeoutSeconds?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type WorkflowRunSshSettings = {
|
||||
__typename?: 'WorkflowRunSshSettings';
|
||||
idleTimeoutSeconds: Scalars['Int']['output'];
|
||||
@@ -14893,6 +14920,7 @@ export type CreateWorkflowRunFromGitRefMutationVariables = Exact<{
|
||||
workflowRevisionId: Scalars['ID']['input'];
|
||||
gitRef: Scalars['String']['input'];
|
||||
inputs?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
ssh?: InputMaybe<WorkflowRunSshInput>;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -15269,7 +15297,7 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, name?: string | null, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, updatedAt: any, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, name: string, filename: string, downloadUrl: string, fileSizeBytes?: number | null, metadata?: any | null, createdAt: any, updatedAt: any }>, remoteConfig?:
|
||||
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, name?: string | null, tags: Array<string>, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, updatedAt: any, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, name: string, filename: string, downloadUrl: string, fileSizeBytes?: number | null, metadata?: any | null, createdAt: any, updatedAt: any }>, remoteConfig?:
|
||||
| { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null }
|
||||
| { __typename: 'AppiumRunSessionRemoteConfig', appiumUrl: string, capabilities: any, webPreviewUrl?: string | null }
|
||||
| { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null }
|
||||
@@ -15285,7 +15313,7 @@ export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type DeviceRunSessionsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, deviceRunSessionsPaginated: { __typename?: 'AppDeviceRunSessionsConnection', edges: Array<{ __typename?: 'AppDeviceRunSessionEdge', cursor: string, node: { __typename?: 'DeviceRunSession', id: string, name?: string | null, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
|
||||
export type DeviceRunSessionsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, deviceRunSessionsPaginated: { __typename?: 'AppDeviceRunSessionsConnection', edges: Array<{ __typename?: 'AppDeviceRunSessionEdge', cursor: string, node: { __typename?: 'DeviceRunSession', id: string, name?: string | null, tags: Array<string>, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
|
||||
|
||||
export type ViewEmbeddedUpdateByIdQueryVariables = Exact<{
|
||||
embeddedUpdateId: Scalars['ID']['input'];
|
||||
@@ -15671,6 +15699,20 @@ export type WorkflowJobByIdQuery = { __typename?: 'RootQuery', workflowJobs: { _
|
||||
| { __typename: 'User', id: string, displayName: string }
|
||||
| null, app: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, updateChannel?: { __typename?: 'UpdateChannel', id: string, name: string } | null, runtime?: { __typename?: 'Runtime', id: string, version: string } | null, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, errors: Array<{ __typename?: 'WorkflowJobError', title: string, message: string }> } } };
|
||||
|
||||
export type WorkflowJobSshPollQueryVariables = Exact<{
|
||||
workflowJobId: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type WorkflowJobSshPollQuery = { __typename?: 'RootQuery', workflowJobs: { __typename?: 'WorkflowJobQuery', byId: { __typename?: 'WorkflowJob', id: string, status: WorkflowJobStatus, workflowRun: { __typename?: 'WorkflowRun', id: string, sshSettings?: { __typename?: 'WorkflowRunSshSettings', idleTimeoutSeconds: number } | null }, turtleJobRun?: { __typename?: 'JobRun', id: string, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } | null, turtleBuild?: { __typename?: 'Build', id: string, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } | null } } };
|
||||
|
||||
export type JobRunSshPollQueryVariables = Exact<{
|
||||
jobRunId: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type JobRunSshPollQuery = { __typename?: 'RootQuery', jobRun: { __typename?: 'JobRunQuery', byId: { __typename?: 'JobRun', id: string, status: JobRunStatus, sshSession?: { __typename?: 'TurtleSshSession', id: string, connectionConfig: { __typename?: 'TurtleSshConnectionConfig', host: string, secret: string, reconnecting: boolean } } | null } } };
|
||||
|
||||
export type ExpoGoSupportedSdkVersionsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
WorkflowProjectSourceInput,
|
||||
WorkflowRevisionInput,
|
||||
WorkflowRunInput,
|
||||
WorkflowRunSshInput,
|
||||
} from '../generated';
|
||||
|
||||
export namespace WorkflowRunMutation {
|
||||
@@ -64,10 +65,12 @@ export namespace WorkflowRunMutation {
|
||||
workflowRevisionId,
|
||||
gitRef,
|
||||
inputs,
|
||||
ssh,
|
||||
}: {
|
||||
workflowRevisionId: string;
|
||||
gitRef: string;
|
||||
inputs?: Record<string, any>;
|
||||
ssh?: WorkflowRunSshInput | null;
|
||||
}
|
||||
): Promise<{ id: string }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
@@ -81,12 +84,14 @@ export namespace WorkflowRunMutation {
|
||||
$workflowRevisionId: ID!
|
||||
$gitRef: String!
|
||||
$inputs: JSONObject
|
||||
$ssh: WorkflowRunSshInput
|
||||
) {
|
||||
workflowRun {
|
||||
createWorkflowRunFromGitRef(
|
||||
workflowRevisionId: $workflowRevisionId
|
||||
gitRef: $gitRef
|
||||
inputs: $inputs
|
||||
ssh: $ssh
|
||||
) {
|
||||
id
|
||||
}
|
||||
@@ -97,6 +102,7 @@ export namespace WorkflowRunMutation {
|
||||
workflowRevisionId,
|
||||
gitRef,
|
||||
inputs,
|
||||
ssh,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
|
||||
@@ -55,6 +55,7 @@ export const DeviceRunSessionQuery = {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
name
|
||||
tags
|
||||
status
|
||||
type
|
||||
platform
|
||||
@@ -152,6 +153,7 @@ export const DeviceRunSessionQuery = {
|
||||
node {
|
||||
id
|
||||
name
|
||||
tags
|
||||
status
|
||||
type
|
||||
platform
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { GraphqlError, withErrorHandlingAsync } from '../client';
|
||||
import { JobRunStatus, WorkflowJobStatus } from '../generated';
|
||||
|
||||
const FINAL_WORKFLOW_JOB_STATUSES = new Set<WorkflowJobStatus>([
|
||||
WorkflowJobStatus.Success,
|
||||
WorkflowJobStatus.Failure,
|
||||
WorkflowJobStatus.Canceled,
|
||||
WorkflowJobStatus.Skipped,
|
||||
]);
|
||||
|
||||
const FINAL_JOB_RUN_STATUSES = new Set<JobRunStatus>([
|
||||
JobRunStatus.Errored,
|
||||
JobRunStatus.Finished,
|
||||
JobRunStatus.Canceled,
|
||||
]);
|
||||
|
||||
export type WorkflowJobSshConnectionConfig = {
|
||||
host: string;
|
||||
secret: string;
|
||||
reconnecting: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowJobSshSession = {
|
||||
id: string;
|
||||
connectionConfig: WorkflowJobSshConnectionConfig;
|
||||
};
|
||||
|
||||
export type WorkflowJobSshConnectInfo = {
|
||||
sshRequested: boolean;
|
||||
jobCompleted: boolean;
|
||||
session: WorkflowJobSshSession | null;
|
||||
};
|
||||
|
||||
type WorkflowJobSshPollQuery = {
|
||||
workflowJobs: {
|
||||
byId: {
|
||||
id: string;
|
||||
status: WorkflowJobStatus;
|
||||
workflowRun: {
|
||||
id: string;
|
||||
sshSettings: { idleTimeoutSeconds: number } | null;
|
||||
};
|
||||
turtleJobRun: { id: string; sshSession: WorkflowJobSshSession | null } | null;
|
||||
turtleBuild: { id: string; sshSession: WorkflowJobSshSession | null } | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type WorkflowJobSshPollQueryVariables = {
|
||||
workflowJobId: string;
|
||||
};
|
||||
|
||||
type JobRunSshPollQuery = {
|
||||
jobRun: {
|
||||
byId: {
|
||||
id: string;
|
||||
status: JobRunStatus;
|
||||
sshSession: WorkflowJobSshSession | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type JobRunSshPollQueryVariables = {
|
||||
jobRunId: string;
|
||||
};
|
||||
|
||||
function toConnectInfo(
|
||||
job: WorkflowJobSshPollQuery['workflowJobs']['byId']
|
||||
): WorkflowJobSshConnectInfo {
|
||||
const hasTurtleTarget = job.turtleJobRun != null || job.turtleBuild != null;
|
||||
return {
|
||||
sshRequested: hasTurtleTarget && job.workflowRun.sshSettings != null,
|
||||
jobCompleted: FINAL_WORKFLOW_JOB_STATUSES.has(job.status),
|
||||
session: job.turtleJobRun?.sshSession ?? job.turtleBuild?.sshSession ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
if (!(error instanceof GraphqlError)) {
|
||||
return false;
|
||||
}
|
||||
return error.graphQLErrors.some(e => {
|
||||
const code = e?.extensions?.errorCode ?? e?.extensions?.code;
|
||||
return (
|
||||
code === 'ENTITY_NOT_FOUND' ||
|
||||
/Entity not found/i.test(e?.message ?? '') ||
|
||||
/not found/i.test(e?.message ?? '')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const WorkflowJobSshQuery = {
|
||||
async connectInfoForWorkflowJobAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
workflowJobId: string
|
||||
): Promise<WorkflowJobSshConnectInfo | null> {
|
||||
let data: WorkflowJobSshPollQuery;
|
||||
try {
|
||||
data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<WorkflowJobSshPollQuery, WorkflowJobSshPollQueryVariables>(
|
||||
gql`
|
||||
query WorkflowJobSshPoll($workflowJobId: ID!) {
|
||||
workflowJobs {
|
||||
byId(workflowJobId: $workflowJobId) {
|
||||
id
|
||||
status
|
||||
workflowRun {
|
||||
id
|
||||
sshSettings {
|
||||
idleTimeoutSeconds
|
||||
}
|
||||
}
|
||||
turtleJobRun {
|
||||
id
|
||||
sshSession {
|
||||
id
|
||||
connectionConfig {
|
||||
host
|
||||
secret
|
||||
reconnecting
|
||||
}
|
||||
}
|
||||
}
|
||||
turtleBuild {
|
||||
id
|
||||
sshSession {
|
||||
id
|
||||
connectionConfig {
|
||||
host
|
||||
secret
|
||||
reconnecting
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ workflowJobId },
|
||||
{ requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return toConnectInfo(data.workflowJobs.byId);
|
||||
},
|
||||
|
||||
async connectInfoForJobRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
jobRunId: string
|
||||
): Promise<WorkflowJobSshConnectInfo | null> {
|
||||
let data: JobRunSshPollQuery;
|
||||
try {
|
||||
data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<JobRunSshPollQuery, JobRunSshPollQueryVariables>(
|
||||
gql`
|
||||
query JobRunSshPoll($jobRunId: ID!) {
|
||||
jobRun {
|
||||
byId(jobRunId: $jobRunId) {
|
||||
id
|
||||
status
|
||||
sshSession {
|
||||
id
|
||||
connectionConfig {
|
||||
host
|
||||
secret
|
||||
reconnecting
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ jobRunId },
|
||||
{ requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const jobRun = data.jobRun.byId;
|
||||
const jobCompleted = FINAL_JOB_RUN_STATUSES.has(jobRun.status);
|
||||
return {
|
||||
sshRequested: jobRun.sshSession != null || jobCompleted,
|
||||
jobCompleted,
|
||||
session: jobRun.sshSession,
|
||||
};
|
||||
},
|
||||
|
||||
async connectInfoForResourceIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
resourceId: string
|
||||
): Promise<WorkflowJobSshConnectInfo | null> {
|
||||
return (
|
||||
(await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, resourceId)) ??
|
||||
(await WorkflowJobSshQuery.connectInfoForJobRunAsync(graphqlClient, resourceId))
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { GraphqlError } from '../../client';
|
||||
import { WorkflowJobStatus } from '../../generated';
|
||||
import { WorkflowJobSshQuery } from '../WorkflowJobSshQuery';
|
||||
|
||||
describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => {
|
||||
function makeClient(byId: unknown): {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
query: jest.Mock;
|
||||
} {
|
||||
const query = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({ data: { workflowJobs: { byId } } }),
|
||||
});
|
||||
return { graphqlClient: { query } as unknown as ExpoGraphqlClient, query };
|
||||
}
|
||||
|
||||
it('maps workflowJobs.byId into connect info', async () => {
|
||||
const { graphqlClient, query } = makeClient({
|
||||
id: 'job-1',
|
||||
status: WorkflowJobStatus.InProgress,
|
||||
workflowRun: { sshSettings: { idleTimeoutSeconds: 0 } },
|
||||
turtleJobRun: {
|
||||
sshSession: {
|
||||
id: 'ts-1',
|
||||
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
|
||||
},
|
||||
},
|
||||
turtleBuild: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1')
|
||||
).toEqual({
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session: {
|
||||
id: 'ts-1',
|
||||
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
|
||||
},
|
||||
});
|
||||
expect(query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ workflowJobId: 'job-1' },
|
||||
{ requestPolicy: 'network-only' }
|
||||
);
|
||||
});
|
||||
|
||||
it('is not ssh-requested when the run has no sshSettings', async () => {
|
||||
const { graphqlClient } = makeClient({
|
||||
id: 'job-1',
|
||||
status: WorkflowJobStatus.InProgress,
|
||||
workflowRun: { sshSettings: null },
|
||||
turtleJobRun: null,
|
||||
turtleBuild: { sshSession: null },
|
||||
});
|
||||
|
||||
expect(
|
||||
await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1')
|
||||
).toMatchObject({ sshRequested: false });
|
||||
});
|
||||
|
||||
it('returns null when the job is not found', async () => {
|
||||
const query = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: undefined,
|
||||
error: new GraphqlError({
|
||||
graphQLErrors: [
|
||||
{ message: 'Entity not found', extensions: { errorCode: 'ENTITY_NOT_FOUND' } },
|
||||
],
|
||||
networkError: undefined,
|
||||
response: undefined,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const graphqlClient = { query } as unknown as ExpoGraphqlClient;
|
||||
expect(
|
||||
await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'missing')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for not-found errors matched by message or code', async () => {
|
||||
for (const graphQLErrors of [
|
||||
[{ message: 'Workflow job not found', extensions: {} }],
|
||||
[{ message: 'missing', extensions: { code: 'ENTITY_NOT_FOUND' } }],
|
||||
]) {
|
||||
const query = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: undefined,
|
||||
error: new GraphqlError({
|
||||
graphQLErrors,
|
||||
networkError: undefined,
|
||||
response: undefined,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const graphqlClient = { query } as unknown as ExpoGraphqlClient;
|
||||
expect(
|
||||
await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'missing')
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the turtleBuild session when there is no turtleJobRun', async () => {
|
||||
const { graphqlClient } = makeClient({
|
||||
id: 'job-1',
|
||||
status: WorkflowJobStatus.Success,
|
||||
workflowRun: { sshSettings: { idleTimeoutSeconds: 60 } },
|
||||
turtleJobRun: null,
|
||||
turtleBuild: {
|
||||
sshSession: {
|
||||
id: 'ts-build',
|
||||
connectionConfig: { host: 'relay.expo.dev', secret: 'TOK', reconnecting: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1')
|
||||
).toEqual({
|
||||
sshRequested: true,
|
||||
jobCompleted: true,
|
||||
session: {
|
||||
id: 'ts-build',
|
||||
connectionConfig: { host: 'relay.expo.dev', secret: 'TOK', reconnecting: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows GraphQL errors that are not not-found', async () => {
|
||||
const error = new GraphqlError({
|
||||
graphQLErrors: [{ message: 'boom', extensions: { errorCode: 'INTERNAL' } }],
|
||||
networkError: undefined,
|
||||
response: undefined,
|
||||
});
|
||||
const query = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({ data: undefined, error }),
|
||||
});
|
||||
const graphqlClient = { query } as unknown as ExpoGraphqlClient;
|
||||
await expect(
|
||||
WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1')
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('rethrows unexpected non-GraphQL errors', async () => {
|
||||
const query = jest.fn().mockReturnValue({
|
||||
toPromise: async () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
});
|
||||
const graphqlClient = { query } as unknown as ExpoGraphqlClient;
|
||||
await expect(
|
||||
WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, 'job-1')
|
||||
).rejects.toThrow('network down');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "23.1.0",
|
||||
"version": "23.2.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,7 +26,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "23.1.0",
|
||||
"@expo/build-tools": "23.2.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/logger": "23.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "23.1.0",
|
||||
"version": "23.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "23.1.0",
|
||||
"@expo/build-tools": "23.2.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
|
||||
@@ -2291,7 +2291,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:23.1.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:23.2.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
@@ -2969,7 +2969,7 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:23.1.0"
|
||||
"@expo/build-tools": "npm:23.2.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/eas-build-job": "npm:23.1.0"
|
||||
@@ -10412,7 +10412,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:23.1.0"
|
||||
"@expo/build-tools": "npm:23.2.0"
|
||||
"@expo/bunyan": "npm:^4.0.1"
|
||||
"@expo/eas-build-job": "npm:23.1.0"
|
||||
"@expo/logger": "npm:23.1.0"
|
||||
|
||||
Reference in new issue
Block a user