Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features

- Add `--runtime-version` and `--platform` filters to `eas update:list`. ([#3261](https://github.com/expo/eas-cli/pull/3261) by [@HarelSultan](https://github.com/HarelSultan))
- Add `--local` and `--fastlane-args` to `eas submit`. ([#3279](https://github.com/expo/eas-cli/pull/3279) by [@linrongda](https://github.com/linrongda))

### 🐛 Bug fixes

Expand Down
57 changes: 51 additions & 6 deletions packages/eas-cli/src/commands/submit.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { EasJsonAccessor } from '@expo/eas-json';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
Expand All @@ -13,12 +14,17 @@ import {
selectRequestedPlatformAsync,
toPlatforms,
} from '../platform';
import { SubmitArchiveFlags, createSubmissionContextAsync } from '../submit/context';
import {
SubmissionContext,
SubmitArchiveFlags,
createSubmissionContextAsync,
} from '../submit/context';
import {
exitWithNonZeroCodeIfSomeSubmissionsDidntFinish,
submitAsync,
waitToCompleteAsync,
} from '../submit/submit';
import { submitLocalIosAsync } from '../submit/utils/local';
import { printSubmissionDetailsUrls } from '../submit/utils/urls';
import { getProfilesAsync } from '../utils/profiles';
import { maybeWarnAboutEasOutagesAsync } from '../utils/statuspageService';
Expand All @@ -36,6 +42,8 @@ interface RawCommandFlags {
'non-interactive': boolean;
'verbose-fastlane': boolean;
groups?: string[];
local?: boolean;
'fastlane-args'?: string;
}

interface CommandFlags {
Expand All @@ -48,6 +56,8 @@ interface CommandFlags {
nonInteractive: boolean;
isVerboseFastlaneEnabled: boolean;
groups?: string[];
local?: boolean;
fastlaneArgs?: string;
}

export default class Submit extends EasCommand {
Expand Down Expand Up @@ -102,6 +112,13 @@ export default class Submit extends EasCommand {
multiple: true,
char: 'g',
}),
local: Flags.boolean({
description: 'Perform submission locally (upload from this machine)',
default: false,
}),
'fastlane-args': Flags.string({
description: 'Pass additional arguments to fastlane as a single string',
}),
'non-interactive': Flags.boolean({
default: false,
description: 'Run command in non-interactive mode',
Expand All @@ -128,7 +145,7 @@ export default class Submit extends EasCommand {
withServerSideEnvironment: null,
});

const flags = this.sanitizeFlags(rawFlags);
const flags = this.sanitizeFlags(rawFlags as RawCommandFlags);

await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasSubmit]);

Expand All @@ -144,6 +161,7 @@ export default class Submit extends EasCommand {
});

const submissions: SubmissionFragment[] = [];
let localPerformed = false;
for (const submissionProfile of submissionProfiles) {
// this command doesn't make use of env when getting the project config
const ctx = await createSubmissionContextAsync({
Expand Down Expand Up @@ -174,14 +192,33 @@ export default class Submit extends EasCommand {
);
}

const submission = await submitAsync(ctx);
submissions.push(submission);
if (flagsWithPlatform.local) {
if (ctx.platform === Platform.IOS) {
await submitLocalIosAsync(
ctx as SubmissionContext<Platform.IOS>,
flagsWithPlatform.fastlaneArgs
);
localPerformed = true;
} else {
Errors.error('--local is only supported for iOS submissions', { exit: 1 });
}
} else {
const submission = await submitAsync(ctx);
submissions.push(submission);
}
}

Log.newLine();
printSubmissionDetailsUrls(submissions);
if (submissions.length > 0) {
printSubmissionDetailsUrls(submissions);
}
if (localPerformed) {
Log.log(
'Local submission(s) completed on this machine — no server submission records were created.'
);
}

if (flagsWithPlatform.wait) {
if (flagsWithPlatform.wait && submissions.length > 0) {
const completedSubmissions = await waitToCompleteAsync(graphqlClient, submissions, {
verbose: flagsWithPlatform.verbose,
});
Expand All @@ -200,6 +237,8 @@ export default class Submit extends EasCommand {
'non-interactive': nonInteractive,
'verbose-fastlane': isVerboseFastlaneEnabled,
groups,
local,
'fastlane-args': fastlaneArgs,
'what-to-test': whatToTest,
...archiveFlags
} = flags;
Expand All @@ -214,6 +253,10 @@ export default class Submit extends EasCommand {
? (flags.platform.toLowerCase() as RequestedPlatform)
: undefined;

if (fastlaneArgs && !local) {
Errors.error('--fastlane-args is only supported with --local', { exit: 1 });
}

return {
archiveFlags,
requestedPlatform,
Expand All @@ -224,6 +267,8 @@ export default class Submit extends EasCommand {
whatToTest,
isVerboseFastlaneEnabled,
groups,
local,
fastlaneArgs,
};
}

Expand Down
168 changes: 168 additions & 0 deletions packages/eas-cli/src/submit/utils/__tests__/local-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import childProcess from 'child_process';
import fs from 'fs-extra';

import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
import { getAscApiKeyResultAsync } from '../../ios/AscApiKeySource';
import submitLocalIosAsync from '../local';

jest.mock('child_process', () => ({ spawn: jest.fn(), spawnSync: jest.fn() }));
jest.mock('../../ios/AscApiKeySource', () => ({
getAscApiKeyResultAsync: jest.fn(),
AscApiKeySourceType: { path: 'path', prompt: 'prompt', credentialsService: 'credentialsService' },
}));
jest.mock('../../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
AppStoreConnectApiKeyQuery: { getByIdAsync: jest.fn() },
}));

describe('submitLocalIosAsync', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('throws when archive path is missing', async () => {
const ctx: any = {
archiveFlags: {},
profile: {},
nonInteractive: false,
graphqlClient: {},
isVerboseFastlaneEnabled: false,
};

await expect(submitLocalIosAsync(ctx)).rejects.toThrow('--local currently requires --path');
});

test('throws when ipa file does not exist', async () => {
const ctx: any = {
archiveFlags: { path: '/nonexistent.ipa' },
profile: {},
nonInteractive: false,
graphqlClient: {},
isVerboseFastlaneEnabled: false,
};
jest.spyOn(fs as any, 'pathExists').mockResolvedValue(false);

await expect(submitLocalIosAsync(ctx)).rejects.toThrow('does not exist');
});

test('throws when fastlane is missing', async () => {
const ctx: any = {
archiveFlags: { path: '/existing.ipa' },
profile: {},
nonInteractive: false,
graphqlClient: {},
isVerboseFastlaneEnabled: false,
};
jest.spyOn(fs as any, 'pathExists').mockResolvedValue(true);
jest.spyOn(fs as any, 'mkdtemp').mockResolvedValue('/tmp/eas-asc-123' as any);
jest.spyOn(fs as any, 'writeFile').mockResolvedValue(undefined);
jest.spyOn(fs as any, 'chmod').mockResolvedValue(undefined);
jest.spyOn(fs as any, 'remove').mockResolvedValue(undefined);

jest.mocked(childProcess.spawnSync as any).mockReturnValue({ status: 1 });

jest.mocked(getAscApiKeyResultAsync as jest.Mock).mockResolvedValue({
result: { keyP8: 'p8', keyId: 'kid', issuerId: 'iss' },
summary: { source: 'local', keyId: 'kid' },
});

await expect(submitLocalIosAsync(ctx)).rejects.toThrow('fastlane is not installed');
});

test('throws when ASC key lookup via App Store Connect fails', async () => {
const ctx: any = {
archiveFlags: { path: '/existing.ipa' },
profile: {},
nonInteractive: false,
graphqlClient: {},
isVerboseFastlaneEnabled: false,
};
jest.spyOn(fs as any, 'pathExists').mockResolvedValue(true);
jest.spyOn(fs as any, 'mkdtemp').mockResolvedValue('/tmp/eas-asc-123' as any);
jest.spyOn(fs as any, 'writeFile').mockResolvedValue(undefined);
jest.spyOn(fs as any, 'chmod').mockResolvedValue(undefined);
jest.spyOn(fs as any, 'remove').mockResolvedValue(undefined);

jest.mocked(childProcess.spawnSync as any).mockReturnValue({ status: 0 });

// Simulate that AscApiKeySource returned an ascApiKeyId which triggers AppStoreConnect lookup
jest.mocked(getAscApiKeyResultAsync as jest.Mock).mockResolvedValue({
result: { ascApiKeyId: 'nonexistent' },
summary: { source: 'EAS servers', keyId: 'unknown' },
});

jest
.mocked(AppStoreConnectApiKeyQuery.getByIdAsync as jest.Mock)
.mockRejectedValue(new Error('not found'));

await expect(submitLocalIosAsync(ctx)).rejects.toThrow('not found');
});

test('rejects when fastlane upload fails (non-zero exit)', async () => {
const ctx: any = {
archiveFlags: { path: '/existing.ipa' },
profile: {},
nonInteractive: false,
graphqlClient: {},
isVerboseFastlaneEnabled: false,
};
jest.spyOn(fs as any, 'pathExists').mockResolvedValue(true);
jest.spyOn(fs as any, 'mkdtemp').mockResolvedValue('/tmp/eas-asc-123' as any);
jest.spyOn(fs as any, 'writeFile').mockResolvedValue(undefined);
jest.spyOn(fs as any, 'chmod').mockResolvedValue(undefined);
jest.spyOn(fs as any, 'remove').mockResolvedValue(undefined);

jest.mocked(childProcess.spawnSync as any).mockReturnValue({ status: 0 });

jest.mocked(getAscApiKeyResultAsync as jest.Mock).mockResolvedValue({
result: { keyP8: 'p8', keyId: 'kid', issuerId: 'iss' },
summary: { source: 'local', keyId: 'kid' },
});

// Mock spawn to call the 'close' callback with non-zero code
const mockChild = {
on: jest.fn((event: string, cb: (...args: any[]) => void) => {
if (event === 'close') {
cb(1);
}
return mockChild;
}),
} as any;
jest.mocked(childProcess.spawn as any).mockReturnValue(mockChild);

await expect(submitLocalIosAsync(ctx)).rejects.toThrow('fastlane exited with code 1');
});

test('resolves when fastlane upload succeeds', async () => {
const ctx: any = {
archiveFlags: { path: '/existing.ipa' },
profile: {},
nonInteractive: false,
graphqlClient: {},
isVerboseFastlaneEnabled: false,
};
jest.spyOn(fs as any, 'pathExists').mockResolvedValue(true);
jest.spyOn(fs as any, 'mkdtemp').mockResolvedValue('/tmp/eas-asc-123' as any);
jest.spyOn(fs as any, 'writeFile').mockResolvedValue(undefined);
jest.spyOn(fs as any, 'chmod').mockResolvedValue(undefined);
jest.spyOn(fs as any, 'remove').mockResolvedValue(undefined);

jest.mocked(childProcess.spawnSync as any).mockReturnValue({ status: 0 });

jest.mocked(getAscApiKeyResultAsync as jest.Mock).mockResolvedValue({
result: { keyP8: 'p8', keyId: 'kid', issuerId: 'iss' },
summary: { source: 'local', keyId: 'kid' },
});

const mockChild = {
on: jest.fn((event: string, cb: (...args: any[]) => void) => {
if (event === 'close') {
cb(0);
}
return mockChild;
}),
} as any;
jest.mocked(childProcess.spawn as any).mockReturnValue(mockChild);

await expect(submitLocalIosAsync(ctx)).resolves.toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { splitArgsString } from '../local';

describe('splitArgsString', () => {
test('splits on spaces', () => {
expect(splitArgsString('a b c')).toEqual(['a', 'b', 'c']);
});

test('respects double quotes', () => {
expect(splitArgsString('a "b c" d')).toEqual(['a', 'b c', 'd']);
});

test('respects single quotes', () => {
expect(splitArgsString("a 'b c' d")).toEqual(['a', 'b c', 'd']);
});

test('handles mixed quotes and unquoted', () => {
expect(splitArgsString('--option=1 "value with spaces" \'other val\' plain')).toEqual([
'--option=1',
'value with spaces',
'other val',
'plain',
]);
});

test('returns empty array for empty string', () => {
expect(splitArgsString('')).toEqual([]);
});
});
Loading