Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages.

- [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))

### 🐛 Bug fixes

Expand Down
387 changes: 387 additions & 0 deletions packages/eas-cli/src/commands/workflow/__tests__/ssh.test.ts
Original file line number Diff line number Diff line change
@@ -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 TOKENx@relay.expo.dev'
);
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 TOKENx@relay.expo.dev'
);
expect(Log.log).toHaveBeenCalledWith(
' ssh -o ProxyCommand="upterm proxy wss://TOKENx@relay.expo.dev" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 8022 TOKENx@relay.expo.dev'
);
});

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');
});
});
Loading
Loading