Compare commits

..
Author SHA1 Message Date
evanbacon 329ddccd61 Merge branch 'main' into @evanbacon/cli/auto-bundle-id 2026-03-30 11:11:21 -07:00
evanbacon 1db7281e8b Merge branch 'main' into @evanbacon/cli/auto-bundle-id 2026-02-14 14:12:30 -08:00
evanbacon 1e8a0acabe Update CHANGELOG.md 2026-02-14 14:12:18 -08:00
evanbaconandClaude Opus 4.5 6bfb8af57f Fix tests and add changelog entry
- Wrap suggestion lookup in try-catch for non-interactive mode
- Add changelog entry for the new feature

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-12 15:16:00 -08:00
evanbacon 231f29ecf1 Non-interactive auto-generate bundle & package
Allow iOS bundle identifier and Android application id to be auto-generated in non-interactive mode instead of immediately throwing. If a suggested identifier is available (from AppQuery or the counterpart platform field) it will be used and logged; if not, an error is still thrown. Tests updated to cover auto-generation, cross-platform fallbacks, and the error case when no valid suggestion can be produced.
2026-02-12 15:15:23 -08:00
73 changed files with 2723 additions and 1984 deletions

No files matched your search

-33
View File
@@ -4,36 +4,3 @@ When working with this repository follow instructions from CLAUDE.md.
- [./CLAUDE.md](./CLAUDE.md)
- Before committing, run the formatter on modified files.
## Cursor Cloud specific instructions
### Environment
- **Node.js**: v22+ (provided by nvm in the VM)
- **Yarn**: 4.12.0 (activated via `corepack enable && corepack install`)
- The update script runs `corepack enable`, `corepack install`, `yarn install`, and `yarn build` automatically on VM startup.
### Key development commands
All standard commands are documented in [CLAUDE.md](./CLAUDE.md). Quick reference:
| Task | Command |
| ------------ | -------------------------------- |
| Install deps | `yarn install` |
| Build all | `yarn build` |
| Watch mode | `yarn start` |
| Lint | `yarn lint` |
| Format check | `yarn fmt:check` |
| Format fix | `yarn fmt` |
| Typecheck | `yarn typecheck` |
| Test all | `yarn test` |
| Test one pkg | `cd packages/<pkg> && yarn test` |
| Run CLI | `yarn eas <command>` |
### Non-obvious caveats
- After `yarn install`, you **must** run `yarn build` before tests or the CLI will work. The build step compiles TypeScript for all packages and cross-package imports rely on the compiled output.
- The `yarn test` worker process warning about force exit is a known, benign issue — all tests pass.
- The CLI runs without authentication for most diagnostic/help commands. Commands that interact with the Expo API require `yarn eas login` or `EXPO_TOKEN` env var.
- No Docker, databases, or external services are needed — all tests are fully mocked.
- Oxlint reports 7 pre-existing warnings (missing return types in test files and a sort-imports issue). These are not errors.
+2 -3
View File
@@ -8,19 +8,18 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
- Auto-generate `ios.bundleIdentifier` and `android.package` in non-interactive mode when not set in app config. ([#3399](https://github.com/expo/eas-cli/pull/3399) by [@evanbacon](https://github.com/evanbacon))
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
+2 -2
View File
@@ -35,8 +35,8 @@
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.4.0",
"@expo/env": "^0.4.0",
+7
View File
@@ -19,5 +19,12 @@ export { PackageManager } from './utils/packageManager';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export {
maybeStartPreparingPrecompiledModules,
PRECOMPILED_MODULES_PATH,
shouldUsePrecompiledDependencies,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} from './utils/precompiledModules';
export * from './generic';
@@ -0,0 +1,127 @@
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
import StreamZip from 'node-stream-zip';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import {
startPreparingPrecompiledDependencies,
PRECOMPILED_MODULES_PATH,
} from '../../utils/precompiledModules';
import { installPods } from '../pod';
jest.mock('@expo/downloader');
jest.mock('fs-extra');
jest.mock('@expo/turtle-spawn');
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(),
},
}));
describe(installPods.name, () => {
const extract = jest.fn();
const close = jest.fn();
beforeEach(() => {
jest.mocked(remove).mockImplementation(async () => undefined as any);
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
jest.mocked(move).mockImplementation(async () => undefined as any);
let mkdtempCallCount = 0;
jest.mocked(mkdtemp).mockImplementation(async () => {
mkdtempCallCount += 1;
return mkdtempCallCount === 1
? ('/tmp/precompiled-modules-archive' as any)
: ('/tmp/precompiled-modules-staging' as any);
});
extract.mockReset().mockResolvedValue(undefined);
close.mockReset().mockResolvedValue(undefined);
jest.mocked(StreamZip.async).mockImplementation(
() =>
({
extract,
close,
}) as any
);
});
it('waits for precompiled dependencies before running pod install', async () => {
let resolvePreparation: (() => void) | undefined;
const preparationPromise = new Promise<void>(resolve => {
resolvePreparation = resolve;
});
jest.mocked(downloadFile).mockReturnValue(preparationPromise);
const ctx = new BuildContext(
createTestIosJob({
buildCredentials: undefined,
}),
{
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
}
);
startPreparingPrecompiledDependencies(ctx, [
'https://example.com/precompiled-modules-0.zip',
'https://example.com/precompiled-modules-1.zip',
]);
jest.mocked(spawn).mockResolvedValue({} as any);
const installPodsPromise = installPods(ctx, {});
await Promise.resolve();
expect(spawn).not.toHaveBeenCalled();
resolvePreparation?.();
await installPodsPromise;
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: '/workingdir/build/ios',
})
);
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
});
it('continues with pod install when precompiled dependencies preparation fails', async () => {
jest.mocked(downloadFile).mockRejectedValue(new Error('download failed'));
const ctx = new BuildContext(
createTestIosJob({
buildCredentials: undefined,
}),
{
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
}
);
startPreparingPrecompiledDependencies(ctx, ['https://example.com/precompiled-modules-0.zip']);
jest.mocked(spawn).mockResolvedValue({} as any);
await expect(installPods(ctx, {})).resolves.toEqual({
spawnPromise: expect.any(Promise),
});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: '/workingdir/build/ios',
})
);
});
});
+10
View File
@@ -3,11 +3,21 @@ import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spa
import path from 'path';
import { BuildContext } from '../context';
import { waitForPrecompiledModulesPreparationAsync } from '../utils/precompiledModules';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
{ infoCallbackFn }: SpawnOptions
): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
try {
await waitForPrecompiledModulesPreparationAsync();
} catch (err) {
ctx.logger.warn(
{ err },
'Precompiled dependencies were not prepared successfully, continuing with pod install'
);
}
const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
@@ -17,6 +17,7 @@ import { injectAndroidCredentialsFunction } from './functions/injectAndroidCrede
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createRepackBuildFunction } from './functions/repack';
@@ -80,6 +81,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createUploadToAscBuildFunction(),
createInternalEasMaestroTestFunction(ctx),
createReportMaestroTestResultsFunction(ctx),
];
@@ -0,0 +1,59 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { waitForPrecompiledModulesPreparationAsync } from '../../../utils/precompiledModules';
import { createInstallPodsBuildFunction } from '../installPods';
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/precompiledModules', () => ({
...jest.requireActual('../../../utils/precompiledModules'),
waitForPrecompiledModulesPreparationAsync: jest.fn(),
}));
describe(createInstallPodsBuildFunction, () => {
it('waits for precompiled dependencies before running pod install', async () => {
jest.mocked(spawn).mockResolvedValue({} as any);
jest.mocked(waitForPrecompiledModulesPreparationAsync).mockResolvedValue(undefined);
const installPods = createInstallPodsBuildFunction();
const globalContext = createGlobalContextMock({});
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
callInputs: {},
});
await buildStep.executeAsync();
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: globalContext.defaultWorkingDirectory,
})
);
});
it('continues with pod install when precompiled dependencies preparation fails', async () => {
jest.mocked(spawn).mockResolvedValue({} as any);
jest
.mocked(waitForPrecompiledModulesPreparationAsync)
.mockRejectedValue(new Error('precompiled dependencies failed'));
const installPods = createInstallPodsBuildFunction();
const globalContext = createGlobalContextMock({});
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
callInputs: {},
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: globalContext.defaultWorkingDirectory,
})
);
});
});
@@ -0,0 +1,238 @@
import { spawnAsync } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import os from 'os';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../../utils/retry';
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
describe(createInternalEasMaestroTestFunction, () => {
const mockUploadArtifact = jest.fn();
beforeEach(() => {
vol.mkdirSync(os.tmpdir(), { recursive: true });
mockedSpawnAsync.mockResolvedValue(undefined as any);
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
mockUploadArtifact.mockResolvedValue({ artifactId: null });
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
]);
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
{ serialId: 'emulator-5554', state: 'device' } as any,
]);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.startAsync.mockResolvedValue({
serialId: 'emulator-5556',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
});
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
};
const fn = createInternalEasMaestroTestFunction(ctx as any);
return fn.createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
}),
{
callInputs: {
platform: 'ios',
flow_paths: JSON.stringify(['.maestro']),
...overrides?.callInputs,
},
}
);
}
it('sets junit_report_directory output when output_format is junit', async () => {
const step = createStep({
callInputs: { output_format: 'junit' },
});
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toMatch(/maestro-reports-/);
});
it('does not set junit_report_directory output when output_format is not junit', async () => {
const step = createStep();
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('does not set junit_report_directory when function throws before reaching output code', async () => {
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
const step = createStep({
callInputs: { output_format: 'junit' },
});
try {
await step.executeAsync();
} catch {
// Expected - no booted device found
}
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('retries Android clone startup with increasing timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
const step = createStep({
callInputs: { platform: 'android' },
});
await step.executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-attempt-1',
deviceName: 'eas-simulator-0-0',
})
);
});
it('generates per-attempt report filenames when retrying', async () => {
// Mock sequence:
// 1. xcrun simctl shutdown → succeeds (beforeEach default is fine)
// 2. maestro test attempt 0 → fails (triggers retry)
// 3. maestro test attempt 1 → succeeds (beforeEach default)
mockedSpawnAsync
.mockResolvedValueOnce(undefined as any) // xcrun simctl shutdown
.mockRejectedValueOnce(new Error('Maestro test failed')); // maestro attempt 0
// Attempt 1 falls through to beforeEach's mockResolvedValue default
const step = createStep({
callInputs: { output_format: 'junit', retries: 2 },
});
await step.executeAsync();
// Filter to just the maestro test command calls (skip xcrun simctl shutdown)
const maestroCalls = mockedSpawnAsync.mock.calls.filter(([cmd]) => cmd === 'maestro');
expect(maestroCalls).toHaveLength(2);
// Extract --output paths from maestro calls
const outputArgs = maestroCalls.map(([, args]) => args[args.indexOf('--output') + 1]);
expect(outputArgs[0]).toContain('attempt-0');
expect(outputArgs[1]).toContain('attempt-1');
// Filenames must be unique (not overwriting)
expect(outputArgs[0]).not.toBe(outputArgs[1]);
});
it('fails Android flow when clone startup exhausts all attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-3',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
const step = createStep({
callInputs: { platform: 'android' },
});
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
});
});
@@ -1,6 +1,8 @@
import { BuildFunction } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { waitForPrecompiledModulesPreparationAsync } from '../../utils/precompiledModules';
export function createInstallPodsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -8,6 +10,14 @@ export function createInstallPodsBuildFunction(): BuildFunction {
name: 'Install Pods',
__metricsId: 'eas/install_pods',
fn: async (stepsCtx, { env }) => {
try {
await waitForPrecompiledModulesPreparationAsync();
} catch (err) {
stepsCtx.logger.warn(
{ err },
'Precompiled dependencies were not prepared successfully, continuing with pod install'
);
}
stepsCtx.logger.info('Installing pods');
const verboseFlag = stepsCtx.global.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
const cocoapodsDeploymentFlag =
@@ -0,0 +1,684 @@
import { GenericArtifactType } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { Result, asyncResult, result } from '@expo/results';
import {
BuildFunction,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
spawnAsync,
} from '@expo/steps';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import {
AndroidDeviceSerialId,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../utils/retry';
import { PlatformToProperNounMap } from '../../utils/strings';
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: '__maestro_test',
__metricsId: 'eas/__maestro_test',
inputProviders: [
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'platform',
required: true,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
id: 'flow_paths',
required: true,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
id: 'retries',
defaultValue: 1,
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'include_tags',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'exclude_tags',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
id: 'shards',
defaultValue: 1,
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'output_format',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
id: 'record_screen',
defaultValue: false,
required: false,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'test_reports_artifact_id',
required: false,
}),
BuildStepOutput.createProvider({
id: 'junit_report_directory',
required: false,
}),
],
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
const {
platform,
flow_paths,
retries,
include_tags,
exclude_tags,
shards,
output_format,
record_screen,
} = z
.object({
platform: z.enum(['ios', 'android']),
flow_paths: z.array(z.string()),
retries: z.number().default(1),
include_tags: z.string().optional(),
exclude_tags: z.string().optional(),
shards: z.number().default(1),
output_format: z.string().optional(),
record_screen: z.boolean().default(false),
})
.parse(
Object.fromEntries(Object.entries(_inputs).map(([key, value]) => [key, value.value]))
);
const flowPathsToExecute: string[] = [];
for (const flowPath of flow_paths) {
const flowPaths = await findMaestroPathsFlowsToExecuteAsync({
workingDirectory: stepCtx.workingDirectory,
flowPath,
logger: stepCtx.logger,
includeTags: include_tags ? include_tags.split(',') : undefined,
excludeTags: exclude_tags ? exclude_tags.split(',') : undefined,
});
if (flowPaths.length === 0) {
stepCtx.logger.warn(`No flows to execute found in "${flowPath}".`);
continue;
}
stepCtx.logger.info(
`Marking for execution:\n- ${flowPaths
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
.join('\n- ')}`
);
stepCtx.logger.info('');
flowPathsToExecute.push(...flowPaths);
}
// TODO: Add support for shards. (Shouldn't be too difficult.)
if (shards > 1) {
stepCtx.logger.warn(
'Sharding support has been temporarily disabled. Running tests on a single shard.'
);
}
// eas/__maestro_test does not start devices, it expects a single device to be already running
// and configured with the app. Here we find the booted device and stop it.
let sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
switch (platform) {
case 'ios': {
const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'booted',
});
if (bootedDevices.length === 0) {
throw new Error('No booted iOS Simulator found.');
} else if (bootedDevices.length > 1) {
throw new Error('Multiple booted iOS Simulators found.');
}
const device = bootedDevices[0];
stepCtx.logger.info(`Running tests on iOS Simulator: ${device.name}.`);
stepCtx.logger.info(`Preparing Simulator for tests...`);
await spawnAsync('xcrun', ['simctl', 'shutdown', device.udid], {
logger: stepCtx.logger,
stdio: 'pipe',
});
sourceDeviceIdentifier = device.name;
break;
}
case 'android': {
const connectedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
if (connectedDevices.length === 0) {
throw new Error('No booted Android Emulator found.');
} else if (connectedDevices.length > 1) {
throw new Error('Multiple booted Android Emulators found.');
}
const { serialId } = connectedDevices[0];
const adbEmuAvdNameResult = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
mode: PipeMode.COMBINED,
env,
});
const avdName = adbEmuAvdNameResult.stdout
.replace(/\r\n/g, '\n')
.split('\n')[0] as AndroidVirtualDeviceName;
stepCtx.logger.info(`Running tests on Android Emulator: ${avdName}.`);
stepCtx.logger.info(`Preparing Emulator for tests...`);
await spawnAsync('adb', ['-s', serialId, 'emu', 'kill'], {
stdio: 'pipe',
});
// Waiting for emulator to get killed, see ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL.
await setTimeout(1000);
sourceDeviceIdentifier = avdName;
break;
}
}
// During tests we generate reports and device logs. We store them in temporary directories
// and upload them once all tests are done. When a test is retried, new reports overwrite
// the old ones. The files are named "flow-${index}" for easier identification.
const maestroReportsDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'maestro-reports-')
);
const deviceLogsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'device-logs-'));
const failedFlows: string[] = [];
for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
stepCtx.logger.info('');
for (let attemptCount = 0; attemptCount < retries; attemptCount++) {
// Generate unique report path per attempt (not overwritten on retry)
const outputPath = path.join(
maestroReportsDir,
[
`${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}-attempt-${attemptCount}`,
MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
]
.filter(Boolean)
.join('.')
);
const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}` as
| IosSimulatorName
| AndroidVirtualDeviceName;
// If the test passes, but the recording fails, we don't want to make the test fail,
// so we return two separate results.
const {
fnResult: { fnResult, recordingResult },
logsResult,
} = await withCleanDeviceAsync({
platform,
sourceDeviceIdentifier,
localDeviceName,
env,
logger: stepCtx.logger,
fn: async ({ deviceIdentifier }) => {
return await maybeWithScreenRecordingAsync({
shouldRecord: record_screen,
platform,
deviceIdentifier,
env,
logger: stepCtx.logger,
fn: async () => {
stepCtx.logger.info('');
const [command, ...args] = getMaestroTestCommand({
flow_path: flowPath,
output_format,
output_path: outputPath,
});
try {
await spawnAsync(command, args, {
logger: stepCtx.logger,
cwd: stepCtx.workingDirectory,
env,
stdio: 'pipe',
});
} finally {
stepCtx.logger.info('');
}
},
});
},
});
// Move device logs to the device logs directory.
if (logsResult?.ok) {
try {
const extension = path.extname(logsResult.value.outputPath);
const destinationPath = path.join(
deviceLogsDir,
`flow-${flowIndex}-attempt-${attemptCount}${extension}`
);
await fs.promises.rename(logsResult.value.outputPath, destinationPath);
} catch (err) {
stepCtx.logger.warn({ err }, 'Failed to prepare device logs for upload.');
}
} else if (logsResult?.reason) {
stepCtx.logger.error({ err: logsResult.reason }, 'Failed to collect device logs.');
}
const isLastAttempt = fnResult.ok || attemptCount === retries - 1;
if (isLastAttempt && recordingResult.value) {
try {
await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
// TODO(sjchmiela): Add metadata to artifacts so we don't need to encode flow path and attempt in the name.
name: `Screen Recording (${flowIndex}-${path.basename(
flowPath,
path.extname(flowPath)
)})`,
paths: [recordingResult.value],
type: GenericArtifactType.OTHER,
},
});
} catch (err) {
stepCtx.logger.warn({ err }, 'Failed to upload screen recording.');
}
}
if (fnResult.ok) {
stepCtx.logger.info(`Test passed.`);
// Break out of the retry loop.
break;
}
if (attemptCount < retries - 1) {
stepCtx.logger.info(`Retrying test...`);
stepCtx.logger.info('');
continue;
}
// fnResult.reason is not super interesting, but it does print out the full command so we can keep it for debugging purposes.
stepCtx.logger.error({ err: fnResult.reason }, 'Test errored.');
failedFlows.push(flowPath);
}
}
stepCtx.logger.info('');
// When all tests are done, we upload the reports and device logs.
const generatedMaestroReports = await fs.promises.readdir(maestroReportsDir);
if (generatedMaestroReports.length === 0) {
stepCtx.logger.warn('No reports were generated.');
} else {
stepCtx.logger.info(`Uploading reports...`);
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
name: `${PlatformToProperNounMap[platform]} Maestro Test Reports (${output_format})`,
paths: [maestroReportsDir],
type: GenericArtifactType.OTHER,
},
});
if (artifactId) {
outputs.test_reports_artifact_id.set(artifactId);
}
} catch (err) {
stepCtx.logger.error({ err }, 'Failed to upload reports.');
}
}
if (output_format === 'junit') {
outputs.junit_report_directory.set(maestroReportsDir);
}
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
if (generatedDeviceLogs.length === 0) {
stepCtx.logger.warn('No device logs were successfully collected.');
} else {
stepCtx.logger.info(`Uploading device logs...`);
try {
await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
name: `Maestro Test Device Logs`,
paths: [deviceLogsDir],
type: GenericArtifactType.OTHER,
},
});
} catch (err) {
stepCtx.logger.error({ err }, 'Failed to upload device logs.');
}
}
stepCtx.logger.info('');
// If any tests failed, we throw an error to mark the step as failed.
if (failedFlows.length > 0) {
throw new Error(
`Some Maestro tests failed:\n- ${failedFlows
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
.join('\n- ')}`
);
} else {
stepCtx.logger.info('All Maestro tests passed.');
}
},
});
}
export function getMaestroTestCommand(params: {
flow_path: string;
output_format: string | undefined;
/** Unused if `output_format` is undefined */
output_path: string;
}): [command: string, ...args: string[]] {
let outputFormatFlags: string[] = [];
if (params.output_format) {
outputFormatFlags = [`--format`, params.output_format, `--output`, params.output_path];
}
return ['maestro', 'test', ...outputFormatFlags, params.flow_path] as [
command: string,
...args: string[],
];
}
const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
junit: 'xml',
html: 'html',
};
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
async function withCleanDeviceAsync<TResult>({
platform,
sourceDeviceIdentifier,
localDeviceName,
env,
logger,
fn,
}: {
env: BuildStepEnv;
logger: bunyan;
platform: 'ios' | 'android';
sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
localDeviceName: IosSimulatorName | AndroidVirtualDeviceName;
fn: ({
deviceIdentifier,
}: {
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
}) => Promise<TResult>;
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
// Clone and start the device
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
switch (platform) {
case 'ios': {
logger.info(`Cloning iOS Simulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
await IosSimulatorUtils.cloneAsync({
sourceDeviceIdentifier: sourceDeviceIdentifier as IosSimulatorName,
destinationDeviceName: localDeviceName as IosSimulatorName,
env,
});
logger.info(`Starting iOS Simulator ${localDeviceName}...`);
const { udid } = await IosSimulatorUtils.startAsync({
deviceIdentifier: localDeviceName as IosSimulatorName,
env,
});
logger.info(`Waiting for iOS Simulator ${localDeviceName} to be ready...`);
await IosSimulatorUtils.waitForReadyAsync({
udid,
env,
});
localDeviceIdentifier = localDeviceName as IosSimulatorName;
break;
}
case 'android': {
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
let serialId: AndroidDeviceSerialId | null = null;
try {
logger.info(
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
serialId = startResult.serialId;
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
timeoutMs,
logger,
});
localDeviceIdentifier = serialId;
} catch (err) {
logger.warn(
{ err },
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
);
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn(
{ err: cleanupErr },
`Failed to clean up Android Emulator ${localDeviceName}.`
);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
break;
}
}
if (!localDeviceIdentifier) {
throw new Error('Device did not return an identifier after startup.');
}
// Run the function
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
// Stop the device
let logsResult: Result<{ outputPath: string }> | null = null;
try {
switch (platform) {
case 'ios': {
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
IosSimulatorUtils.collectLogsAsync({
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await IosSimulatorUtils.deleteAsync({
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
env,
});
break;
}
case 'android': {
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
AndroidEmulatorUtils.collectLogsAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await AndroidEmulatorUtils.deleteAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
});
break;
}
}
} catch (err) {
logger.error(`Error cleaning up device: ${err}`);
}
return { fnResult: fnResult.enforceValue(), logsResult };
}
/** Runs provided `fn` function, optionally wrapping it with starting and stopping screen recording. */
async function maybeWithScreenRecordingAsync<TResult>({
shouldRecord,
platform,
deviceIdentifier,
env,
logger,
fn,
}: {
// As weird as it is, it's more convenient to have this function like `maybeWith...`
// than "withScreenRecordingAsync" and `withScreenRecordingAsync(fn)` vs `fn` in the caller.
shouldRecord: boolean;
platform: 'ios' | 'android';
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
env: BuildStepEnv;
logger: bunyan;
fn: () => Promise<TResult>;
}): Promise<{ fnResult: Result<TResult>; recordingResult: Result<string | null> }> {
if (!shouldRecord) {
return { fnResult: await asyncResult(fn()), recordingResult: result(null) };
}
let recordingResult: Result<{
recordingSpawn: SpawnPromise<SpawnResult>;
outputPath?: string;
}>;
// Start screen recording
logger.info(`Starting screen recording on ${deviceIdentifier}...`);
switch (platform) {
case 'ios': {
recordingResult = await asyncResult(
IosSimulatorUtils.startScreenRecordingAsync({
deviceIdentifier: deviceIdentifier as IosSimulatorName,
env,
})
);
break;
}
case 'android': {
recordingResult = await asyncResult(
AndroidEmulatorUtils.startScreenRecordingAsync({
serialId: deviceIdentifier as AndroidDeviceSerialId,
env,
})
);
break;
}
}
if (!recordingResult.ok) {
logger.warn('Failed to start screen recording.', recordingResult.reason);
}
// Run the function
const fnResult = await asyncResult(fn());
// If recording failed there's nothing to stop, so we return the results
if (!recordingResult.ok) {
return { fnResult, recordingResult: result(recordingResult.reason) };
}
// If recording started, finish it
try {
logger.info(`Stopping screen recording on ${deviceIdentifier}...`);
switch (platform) {
case 'ios': {
await IosSimulatorUtils.stopScreenRecordingAsync({
recordingSpawn: recordingResult.value.recordingSpawn,
});
return {
fnResult,
// We know outputPath is defined, because startIosScreenRecording() should have filled it.
recordingResult: result(recordingResult.value.outputPath!),
};
}
case 'android': {
const { outputPath } = await AndroidEmulatorUtils.stopScreenRecordingAsync({
serialId: deviceIdentifier as AndroidDeviceSerialId,
recordingSpawn: recordingResult.value.recordingSpawn,
env,
});
return { fnResult, recordingResult: result(outputPath) };
}
}
} catch (err) {
logger.warn('Failed to stop screen recording.', err);
return { fnResult, recordingResult: result(err as Error) };
}
}
@@ -0,0 +1,400 @@
import spawnAsync from '@expo/spawn-async';
import bunyan from 'bunyan';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { createMockLogger } from '../../__tests__/utils/logger';
import { findMaestroPathsFlowsToExecuteAsync } from '../findMaestroPathsFlowsToExecuteAsync';
// We use node:fs
jest.unmock('node:fs');
// and fg uses fs.
jest.unmock('fs');
jest.setTimeout(15000);
/**
*
* You need to have a Simulator running to run these tests.
*
*/
describe('findMaestroPathsFlowsToExecuteAsync', () => {
const fixturesDir = path.join(__dirname, 'fixtures', 'maestro-flows');
const singleFileDir = path.join(__dirname, 'fixtures', 'single-file');
const configFlowsDir = path.join(__dirname, 'fixtures', 'config-flows');
const noConfigFlowsDir = path.join(__dirname, 'fixtures', 'no-config-flows');
let logger: bunyan;
beforeAll(() => {
logger = createMockLogger();
});
// Helper function to run maestro test and determine which flows it executed
// by parsing the output
async function getMaestroFlowList(
flowPath: string,
includeTags: string[] = [],
excludeTags: string[] = []
): Promise<string[]> {
const args = ['test', flowPath, '--no-ansi'];
if (includeTags.length > 0) {
args.push('--include-tags', includeTags.join(','));
}
if (excludeTags.length > 0) {
args.push('--exclude-tags', excludeTags.join(','));
}
try {
const result = await spawnAsync('maestro', args, {
stdio: 'pipe',
});
return parseMaestroExecutedFlows(result.stdout + result.stderr);
} catch (error: any) {
const output = (error.stdout || '') + (error.stderr || '') + (error.message || '');
return parseMaestroExecutedFlows(output);
}
}
function parseMaestroExecutedFlows(output: string): string[] {
return output
.split('\n')
.filter(line => line.includes('[Passed]') || line.includes('> Flow'))
.map(line => {
if (line.includes('[Passed]')) {
return line.split('[Passed]')[1].trim().split(' ')[0];
}
return line.split('> Flow')[1].trim();
})
.sort();
}
// Helper function to run our implementation
async function getOurFlowList(
flowPath: string,
includeTags: string[] = [],
excludeTags: string[] = []
): Promise<string[]> {
const result = await findMaestroPathsFlowsToExecuteAsync({
workingDirectory: process.cwd(),
flowPath: path.relative(process.cwd(), flowPath),
includeTags,
excludeTags,
logger,
});
return result.map(filePath => path.basename(filePath, path.extname(filePath))).sort();
}
describe('Directory-based flow discovery', () => {
it('should discover all flows when no tags are specified', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir);
const ourFlows = await getOurFlowList(fixturesDir);
expect(ourFlows).toEqual(maestroFlows);
});
it('should filter flows by include tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth']);
const ourFlows = await getOurFlowList(fixturesDir, ['auth']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should filter flows by exclude tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow']);
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should filter flows by both include and exclude tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['smoke'], ['regression']);
const ourFlows = await getOurFlowList(fixturesDir, ['smoke'], ['regression']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should handle multiple include tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth', 'smoke']);
const ourFlows = await getOurFlowList(fixturesDir, ['auth', 'smoke']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should handle multiple exclude tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow', 'regression']);
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow', 'regression']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should not include nested flows in subdirectories', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['nested']);
const ourFlows = await getOurFlowList(fixturesDir, ['nested']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should exclude config files from flow discovery', async () => {
const ourFlows = await getOurFlowList(fixturesDir);
expect(ourFlows.every(flow => !flow.endsWith('config.yaml'))).toBe(true);
expect(ourFlows.every(flow => !flow.endsWith('config.yml'))).toBe(true);
});
it('should exclude non-YAML files from flow discovery', async () => {
const ourFlows = await getOurFlowList(fixturesDir);
expect(ourFlows).not.toContain('non-flow-file');
});
});
describe('Single file scenarios', () => {
it('should handle single file input', async () => {
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
const maestroFlows = await getMaestroFlowList(singleFile);
const ourFlows = await getOurFlowList(singleFile);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toHaveLength(1);
expect(ourFlows[0]).toBe(path.basename(singleFile, path.extname(singleFile)));
});
it('should handle single file with tag filtering (include)', async () => {
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
// This file has 'smoke' tag, so should be included
const maestroFlows = await getMaestroFlowList(singleFile, ['smoke']);
const ourFlows = await getOurFlowList(singleFile, ['smoke']);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toHaveLength(1);
});
it('should handle single file with tag filtering (exclude)', async () => {
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
// This file has 'smoke' tag, so should be excluded
const maestroFlows = await getMaestroFlowList(singleFile, [], ['smoke']);
const ourFlows = await getOurFlowList(singleFile, [], ['smoke']);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toHaveLength(1);
});
});
describe('Edge cases', () => {
it('should handle non-existent paths gracefully', async () => {
const nonExistentPath = '/path/that/does/not/exist';
// Our implementation should return empty array for non-existent paths
await expect(getOurFlowList(nonExistentPath)).rejects.toThrow('ENOENT');
});
it('should handle empty directories', async () => {
const emptyDir = path.join(__dirname, 'fixtures', 'empty-dir');
await fs.mkdir(emptyDir, { recursive: true });
try {
const ourFlows = await getOurFlowList(emptyDir);
expect(ourFlows).toEqual([]);
} finally {
await fs.rmdir(emptyDir);
}
});
it('should handle flows that cannot be parsed', async () => {
// The invalid-flow.yaml has extra fields that might cause parsing issues
// but should still be discovered if tag filtering passes
const ourFlows = await getOurFlowList(fixturesDir, ['invalid']);
// Should either include the file (if parsing is lenient) or exclude it (if parsing fails)
// The important thing is that it doesn't crash
expect(Array.isArray(ourFlows)).toBe(true);
});
});
describe('Performance comparison', () => {
it('should complete discovery in reasonable time', async () => {
const startTime = Date.now();
await getOurFlowList(fixturesDir);
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(5000); // Should complete within 5 seconds
});
});
describe('Detailed flow content verification', () => {
it('should correctly identify flows with specific tag combinations', async () => {
// Test complex tag filtering scenarios
const authFlows = await getOurFlowList(fixturesDir, ['auth']);
const smokeFlows = await getOurFlowList(fixturesDir, ['smoke']);
const criticalFlows = await getOurFlowList(fixturesDir, ['critical']);
// Verify specific files are included/excluded correctly
expect(authFlows).toContain('tagged-flow-auth');
expect(authFlows).not.toContain('nested-flow'); // Subdirectory flows excluded by default
expect(smokeFlows).toContain('tagged-flow-smoke');
expect(criticalFlows).toContain('tagged-flow-smoke');
});
it('should handle flows without tags correctly', async () => {
// When no filters are applied, flows without tags should be included
const allFlows = await getOurFlowList(fixturesDir);
expect(allFlows).toContain('no-tags-flow');
expect(allFlows).toContain('basic-flow');
// When include tags are specified, flows without tags should be excluded
const taggedFlows = await getOurFlowList(fixturesDir, ['auth']);
expect(taggedFlows).not.toContain('no-tags-flow');
expect(taggedFlows).not.toContain('basic-flow');
});
});
describe('config.yaml flow patterns', () => {
it('should discover flows using config.yaml patterns', async () => {
const maestroFlows = await getMaestroFlowList(configFlowsDir);
const ourFlows = await getOurFlowList(configFlowsDir);
expect(ourFlows).toEqual(maestroFlows);
// Verify specific files are included based on config patterns
expect(ourFlows).toContain('login'); // features/auth/login.yaml
expect(ourFlows).toContain('logout'); // features/auth/logout.yaml
expect(ourFlows).toContain('navigation'); // features/core/navigation.yaml
expect(ourFlows).toContain('api.spec'); // specs/api.spec.yaml
expect(ourFlows).toContain('ui.spec'); // specs/ui.spec.yaml
// Verify top-level file is excluded (not matching patterns)
expect(ourFlows).not.toContain('ignored-top-level');
});
it('should handle tag filtering with config.yaml patterns', async () => {
const authFlows = await getOurFlowList(configFlowsDir, ['auth']);
const maestroAuthFlows = await getMaestroFlowList(configFlowsDir, ['auth']);
expect(authFlows).toEqual(maestroAuthFlows);
expect(authFlows).toContain('login');
expect(authFlows).toContain('logout');
expect(authFlows).not.toContain('navigation');
expect(authFlows).not.toContain('api.spec');
});
it('should handle exclude tags with config.yaml patterns', async () => {
const nonSmokeFlows = await getOurFlowList(configFlowsDir, [], ['smoke']);
const maestroNonSmokeFlows = await getMaestroFlowList(configFlowsDir, [], ['smoke']);
expect(nonSmokeFlows).toEqual(maestroNonSmokeFlows);
expect(nonSmokeFlows).not.toContain('login'); // has smoke tag
expect(nonSmokeFlows).not.toContain('navigation'); // has smoke tag
expect(nonSmokeFlows).toContain('logout'); // no smoke tag
expect(nonSmokeFlows).toContain('api.spec'); // no smoke tag
});
it('should handle multiple include tags with config.yaml patterns', async () => {
const multiTagFlows = await getOurFlowList(configFlowsDir, ['auth', 'integration']);
const maestroMultiTagFlows = await getMaestroFlowList(configFlowsDir, [
'auth',
'integration',
]);
expect(multiTagFlows).toEqual(maestroMultiTagFlows);
expect(multiTagFlows).toContain('login'); // auth tag
expect(multiTagFlows).toContain('logout'); // auth tag
expect(multiTagFlows).toContain('api.spec'); // integration tag
expect(multiTagFlows).toContain('ui.spec'); // integration tag
expect(multiTagFlows).not.toContain('navigation'); // only core/smoke tags
});
});
describe('config.yaml includeTags/excludeTags merging', () => {
const configTagsDir = path.join(__dirname, 'fixtures', 'config-tags');
it('should respect includeTags/excludeTags from config.yaml when no CLI tags provided', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir);
const ourFlows = await getOurFlowList(configTagsDir);
expect(ourFlows).toEqual(maestroFlows);
// Based on fixtures, only flows matching config includeTags and not matching excludeTags should be included
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-inc-and-exc');
expect(ourFlows).not.toContain('flow-neutral');
expect(ourFlows).not.toContain('flow-cli-exclude');
expect(ourFlows).not.toContain('flow-cli-include');
});
it('should merge CLI includeTags with config includeTags', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cliInclude']);
const ourFlows = await getOurFlowList(configTagsDir, ['cliInclude']);
expect(ourFlows).toEqual(maestroFlows);
// Now flows with either cfgInclude or cliInclude should be included, minus excluded ones
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).toContain('flow-cli-include');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-inc-and-exc');
expect(ourFlows).not.toContain('flow-cli-exclude');
});
it('should merge CLI excludeTags with config excludeTags', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir, [], ['cliExclude']);
const ourFlows = await getOurFlowList(configTagsDir, [], ['cliExclude']);
expect(ourFlows).toEqual(maestroFlows);
// Exclude both cfgExclude and cliExclude
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-cli-exclude');
expect(ourFlows).not.toContain('flow-inc-and-exc');
});
it('should apply both merged include and exclude tags correctly', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
const ourFlows = await getOurFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-inc-and-exc');
expect(ourFlows).not.toContain('flow-neutral');
});
});
describe('backward compatibility (no config.yaml)', () => {
it('should use default "*" pattern when no config.yaml exists', async () => {
const maestroFlows = await getMaestroFlowList(noConfigFlowsDir);
const ourFlows = await getOurFlowList(noConfigFlowsDir);
expect(ourFlows).toEqual(maestroFlows);
// Should include top-level files
expect(ourFlows).toContain('top-level-flow');
// Should exclude subdirectory files (default "*" behavior)
expect(ourFlows).not.toContain('nested-flow');
});
it('should handle tag filtering without config.yaml', async () => {
const basicFlows = await getOurFlowList(noConfigFlowsDir, ['basic']);
const maestroBasicFlows = await getMaestroFlowList(noConfigFlowsDir, ['basic']);
expect(basicFlows).toEqual(maestroBasicFlows);
expect(basicFlows).toContain('top-level-flow');
const noIncludeTagsFlows = await getOurFlowList(noConfigFlowsDir, []);
const maestroNoIncludeTagsFlows = await getMaestroFlowList(noConfigFlowsDir, []);
expect(noIncludeTagsFlows).toEqual(maestroNoIncludeTagsFlows);
expect(noIncludeTagsFlows).toContain('top-level-flow');
expect(noIncludeTagsFlows).not.toContain('nested-flow');
});
});
});
@@ -0,0 +1,3 @@
flows:
- 'features/**/*.yaml'
- 'specs/*.spec.yaml'
@@ -0,0 +1,5 @@
appId: com.example.app
name: "login"
tags: ["auth", "smoke"]
---
- assertTrue: true
@@ -0,0 +1,5 @@
appId: com.example.app
name: "logout"
tags: ["auth"]
---
- assertTrue: true
@@ -0,0 +1,5 @@
appId: com.example.app
name: "navigation"
tags: ["core", "smoke"]
---
- assertTrue: true
@@ -0,0 +1,5 @@
appId: com.example.app
name: "ignored-top-level"
tags: ["ignored"]
---
- assertTrue: true
@@ -0,0 +1,5 @@
appId: com.example.app
name: "api.spec"
tags: ["api", "integration"]
---
- assertTrue: true
@@ -0,0 +1,5 @@
appId: com.example.app
name: "ui.spec"
tags: ["ui", "integration"]
---
- assertTrue: true
@@ -0,0 +1,4 @@
appId: com.example.app
name: "basic-flow"
---
- assertTrue: true
@@ -0,0 +1,6 @@
appId: com.example.app
name: "invalid-flow"
tags:
- invalid
---
- assertTrue: true
@@ -0,0 +1,4 @@
appId: com.example.app
name: "no-tags-flow"
---
- assertTrue: true
@@ -0,0 +1 @@
This is not a YAML file and should be ignored by flow discovery.
@@ -0,0 +1,7 @@
appId: com.example.app
name: "nested-flow"
tags:
- nested
- auth
---
- assertTrue: true
@@ -0,0 +1,7 @@
appId: com.example.app
name: "tagged-flow-auth"
tags:
- auth
- login
---
- assertTrue: true
@@ -0,0 +1,7 @@
appId: com.example.app
name: "tagged-flow-regression"
tags:
- regression
- slow
---
- assertTrue: true
@@ -0,0 +1,7 @@
appId: com.example.app
name: "tagged-flow-smoke"
tags:
- smoke
- critical
---
- assertTrue: true
@@ -0,0 +1,5 @@
appId: com.example.app
name: "nested-flow"
tags: ["nested"]
---
- assertTrue: true
@@ -0,0 +1,5 @@
appId: com.example.app
name: "top-level-flow"
tags: ["basic"]
---
- assertTrue: true
@@ -0,0 +1,7 @@
appId: com.example.app
name: "standalone-flow"
tags:
- standalone
- smoke
---
- assertTrue: true
@@ -0,0 +1,120 @@
import fs from 'fs-extra';
import nock from 'nock';
import os from 'os';
import path from 'path';
import { createMockLogger } from '../../__tests__/utils/logger';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
describe('precompiledModules integration', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.enableNetConnect();
});
afterEach(() => {
nock.cleanAll();
});
it('merges the extracted debug and release xcframework trees', async () => {
const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-home-'));
const previousHome = process.env.HOME;
process.env.HOME = homeDirectory;
try {
const fixturesDirectory = path.join(__dirname, 'fixtures', 'precompiledModules');
const debugArchive = await fs.readFile(
path.join(fixturesDirectory, 'xcframeworks-Debug.zip')
);
const releaseArchive = await fs.readFile(
path.join(fixturesDirectory, 'xcframeworks-Release.zip')
);
nock('https://fixtures.expo.test')
.get('/xcframeworks-Debug.zip')
.reply(200, debugArchive, { 'Content-Type': 'application/zip' })
.get('/xcframeworks-Release.zip')
.reply(200, releaseArchive, { 'Content-Type': 'application/zip' });
jest.resetModules();
jest.doMock('os', () => ({
...jest.requireActual('os'),
homedir: () => homeDirectory,
}));
const {
PRECOMPILED_MODULES_PATH,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} = await import('../precompiledModules');
startPreparingPrecompiledDependencies(
{
logger: createMockLogger(),
env: {},
} as any,
[
'https://fixtures.expo.test/xcframeworks-Debug.zip',
'https://fixtures.expo.test/xcframeworks-Release.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(PRECOMPILED_MODULES_PATH).toBe(
path.join(homeDirectory, '.expo', 'precompiled-modules')
);
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-modules-core/output/debug/xcframeworks/ExpoModulesCore.tar.gz'
),
'utf8'
)
).resolves.toBe('debug expo-modules-core fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-modules-core/output/release/xcframeworks/ExpoModulesCore.tar.gz'
),
'utf8'
)
).resolves.toBe('release expo-modules-core fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-camera/output/debug/xcframeworks/ExpoCamera.tar.gz'
),
'utf8'
)
).resolves.toBe('debug expo-camera fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-application/output/release/xcframeworks/EXApplication.tar.gz'
),
'utf8'
)
).resolves.toBe('release expo-application fixture\n');
} finally {
jest.dontMock('os');
jest.resetModules();
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.remove(homeDirectory);
}
});
});
@@ -0,0 +1,168 @@
import downloadFile from '@expo/downloader';
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
import StreamZip from 'node-stream-zip';
import {
PRECOMPILED_MODULES_PATH,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} from '../precompiledModules';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
jest.mock('@expo/downloader');
jest.mock('fs-extra');
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(),
},
}));
describe('precompiledModules', () => {
const extract = jest.fn();
const close = jest.fn();
const getMkdtempPath = jest.fn();
const createCtx = (env: Record<string, string | undefined> = {}): BuildContext =>
({
logger: createMockLogger(),
env,
}) as unknown as BuildContext;
beforeEach(() => {
jest.mocked(remove).mockImplementation(async () => undefined as any);
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
jest.mocked(move).mockImplementation(async () => undefined as any);
getMkdtempPath.mockReset();
getMkdtempPath
.mockReturnValueOnce('/tmp/precompiled-modules-archive')
.mockReturnValueOnce('/tmp/precompiled-modules-staging');
jest.mocked(mkdtemp).mockImplementation(async () => getMkdtempPath() as any);
jest.mocked(downloadFile).mockReset();
extract.mockReset().mockResolvedValue(undefined);
close.mockReset().mockResolvedValue(undefined);
jest.mocked(StreamZip.async).mockImplementation(
() =>
({
extract,
close,
}) as any
);
});
afterEach(() => {
jest.useRealTimers();
});
it('downloads through CocoaPods proxy when enabled', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
startPreparingPrecompiledDependencies(
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
[
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(downloadFile).toHaveBeenCalledWith(
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
});
it('falls back to the direct url when CocoaPods proxy download fails', async () => {
jest
.mocked(downloadFile)
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
startPreparingPrecompiledDependencies(
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
[
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(downloadFile).toHaveBeenNthCalledWith(
1,
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
expect(downloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
});
it('extracts all configured archives into the well-known destination path', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
startPreparingPrecompiledDependencies(createCtx(), [
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
]);
await waitForPrecompiledModulesPreparationAsync();
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
expect(downloadFile).toHaveBeenNthCalledWith(
1,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
expect(downloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-1.zip',
{ retry: 3 }
);
expect(extract).toHaveBeenNthCalledWith(1, null, '/tmp/precompiled-modules-staging');
expect(extract).toHaveBeenNthCalledWith(2, null, '/tmp/precompiled-modules-staging');
expect(move).toHaveBeenCalledWith('/tmp/precompiled-modules-staging', PRECOMPILED_MODULES_PATH);
});
it('leaves the destination empty when preparation fails', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
extract.mockRejectedValueOnce(new Error('extract failed'));
startPreparingPrecompiledDependencies(createCtx(), [
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]);
await expect(waitForPrecompiledModulesPreparationAsync()).rejects.toThrow('extract failed');
expect(move).not.toHaveBeenCalled();
expect(remove).toHaveBeenCalledWith('/tmp/precompiled-modules-staging');
expect(remove).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
});
it('throws when precompiled dependencies are still not ready after 30 seconds', async () => {
jest.useFakeTimers();
jest.mocked(downloadFile).mockImplementation(() => new Promise<void>(() => {}) as any);
startPreparingPrecompiledDependencies(createCtx(), [
'https://example.com/xcframeworks-Debug.zip',
]);
const waitPromise = waitForPrecompiledModulesPreparationAsync();
const waitExpectation = expect(waitPromise).rejects.toThrow(
'Timed out waiting for precompiled dependencies after 30 seconds'
);
await Promise.resolve();
await jest.advanceTimersByTimeAsync(30_000);
await waitExpectation;
});
});
@@ -0,0 +1,230 @@
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fg from 'fast-glob';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'yaml';
import { z } from 'zod';
const FlowConfigSchema = z.object({
name: z.string().optional(),
tags: z.array(z.string()).optional(),
});
const WorkspaceConfigSchema = z.object({
flows: z.array(z.string()).optional(),
executionOrder: z.record(z.string(), z.unknown()).optional(),
includeTags: z.array(z.string()).optional(),
excludeTags: z.array(z.string()).optional(),
});
type FlowConfig = z.infer<typeof FlowConfigSchema>;
type WorkspaceConfig = z.infer<typeof WorkspaceConfigSchema>;
export async function findMaestroPathsFlowsToExecuteAsync({
workingDirectory,
flowPath,
includeTags: _includeTags,
excludeTags: _excludeTags,
logger,
}: {
workingDirectory: string;
flowPath: string;
includeTags: string[] | undefined;
excludeTags: string[] | undefined;
logger: bunyan;
}): Promise<string[]> {
const absoluteFlowPath = path.resolve(workingDirectory, flowPath);
// If it's a file, just return it (no validation needed)
const stat = await fs.stat(absoluteFlowPath);
if (stat.isFile()) {
logger.info(`Found a file: ${path.relative(workingDirectory, absoluteFlowPath)}`);
return [absoluteFlowPath];
}
// It's a directory - discover flow files
logger.info(`Found a directory: ${path.relative(workingDirectory, absoluteFlowPath)}`);
// Check for workspace config
logger.info(`Searching for workspace config...`);
const workspaceConfig = await findAndParseWorkspaceConfigAsync({
dirPath: absoluteFlowPath,
workingDirectory,
logger,
});
logger.info(`Using workspace config: ${JSON.stringify(workspaceConfig)}`);
if (workspaceConfig?.executionOrder) {
logger.warn(`Execution order is not supported yet. Ignoring.`);
}
logger.info(`Searching for flow files...`);
const { flows } = await findAndParseFlowFilesAsync({
dirPath: absoluteFlowPath,
workingDirectory,
workspaceConfig,
logger,
});
if (flows.length === 0) {
logger.info(
`No valid flow files found in: ${path.relative(workingDirectory, absoluteFlowPath)}`
);
return [];
}
const includeTags = [...(_includeTags ?? []), ...(workspaceConfig?.includeTags ?? [])];
const excludeTags = [...(_excludeTags ?? []), ...(workspaceConfig?.excludeTags ?? [])];
if (includeTags.length === 0 && excludeTags.length === 0) {
logger.info(`No tags provided, returning all flows.`);
return flows.map(({ path }) => path);
}
logger.info(
`Filtering flows by tags. Tags to include: ${JSON.stringify(includeTags)}. Tags to exclude: ${
JSON.stringify(excludeTags) ?? 'none'
}.`
);
return flows
.filter(({ config, path: flowPath }) => {
const shouldInclude = matchesTags({
flowTags: config?.tags ?? [],
includeTags,
excludeTags,
});
logger.info(
shouldInclude
? `- ${path.relative(workingDirectory, flowPath)} matches tags, including.`
: `- ${path.relative(workingDirectory, flowPath)} does not match tags, excluding.`
);
return shouldInclude;
})
.map(({ path }) => path);
}
async function findAndParseWorkspaceConfigAsync({
dirPath,
workingDirectory,
logger,
}: {
dirPath: string;
workingDirectory: string;
logger: bunyan;
}): Promise<WorkspaceConfig | null> {
const configPaths = await fg(['config.yaml', 'config.yml'], {
cwd: dirPath,
absolute: true,
});
if (configPaths.length === 0) {
logger.info(`No workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
return null;
}
for (const configPath of configPaths) {
try {
const content = await fs.readFile(configPath, 'utf-8');
const configDoc = yaml.parse(content);
if (!configDoc) {
logger.warn(
`No content found in workspace config: ${path.relative(workingDirectory, configPath)}`
);
continue;
}
logger.info(`Using workspace config from: ${path.relative(workingDirectory, configPath)}`);
return WorkspaceConfigSchema.parse(configDoc);
} catch (err) {
logger.warn(
{ err },
`Failed to parse workspace config: ${path.relative(workingDirectory, configPath)}`
);
continue;
}
}
logger.info(`No valid workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
return null;
}
async function findAndParseFlowFilesAsync({
workingDirectory,
dirPath,
workspaceConfig,
logger,
}: {
workingDirectory: string;
dirPath: string;
workspaceConfig: WorkspaceConfig | null;
logger: bunyan;
}): Promise<{ flows: { config: FlowConfig; path: string }[] }> {
const flows: { config: FlowConfig; path: string }[] = [];
// Determine flow patterns from config or use default
const flowPatterns = workspaceConfig?.flows ?? ['*'];
logger.info(`Using flow patterns: ${JSON.stringify(flowPatterns)}`);
// Use fast-glob to find matching files
const matchedFiles = await fg(flowPatterns, {
cwd: dirPath,
absolute: true,
onlyFiles: true,
ignore: ['*/config.yaml', '*/config.yml'], // Skip workspace config files
});
logger.info(`Found ${matchedFiles.length} potential flow files`);
// Parse each matched file
for (const filePath of matchedFiles) {
// Skip non-YAML files
const ext = path.extname(filePath);
if (ext !== '.yaml' && ext !== '.yml') {
logger.info(`Skipping non-YAML file: ${path.relative(workingDirectory, filePath)}`);
continue;
}
const result = await asyncResult(parseFlowFile(filePath));
if (result.ok) {
logger.info(`Found flow file: ${path.relative(workingDirectory, filePath)}`);
flows.push({ config: result.value, path: filePath });
} else {
logger.info(
{ err: result.reason },
`Skipping flow file: ${path.relative(workingDirectory, filePath)}`
);
}
}
return { flows };
}
async function parseFlowFile(filePath: string): Promise<FlowConfig> {
const content = await fs.readFile(filePath, 'utf-8');
const documents = yaml.parseAllDocuments(content);
const configDoc = documents[0];
if (!configDoc) {
throw new Error(`No config section found in ${filePath}`);
}
return FlowConfigSchema.parse(configDoc.toJS());
}
function matchesTags({
flowTags,
includeTags,
excludeTags,
}: {
flowTags: string[];
includeTags: string[];
excludeTags: string[];
}): boolean {
// Include logic: if includeTags is empty OR flow has any of the include tags
const includeMatch = includeTags.length === 0 || includeTags.some(tag => flowTags.includes(tag));
// Exclude logic: if excludeTags is empty OR flow has none of the exclude tags
const excludeMatch = excludeTags.length === 0 || !excludeTags.some(tag => flowTags.includes(tag));
return includeMatch && excludeMatch;
}
@@ -0,0 +1,174 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import StreamZip from 'node-stream-zip';
import { BuildContext } from '../context';
let precompiledModulesPreparationPromise: Promise<void> | null = null;
const PRECOMPILED_MODULES_WAIT_TIMEOUT_MS = 30_000;
export const PRECOMPILED_MODULES_PATH = path.join(os.homedir(), '.expo', 'precompiled-modules');
export function shouldUsePrecompiledDependencies(env: Record<string, string | undefined>): boolean {
return env.EAS_USE_PRECOMPILED_MODULES === '1';
}
export function maybeStartPreparingPrecompiledModules(
ctx: BuildContext,
config: { precompiledModulesUrls: string[] }
): void {
if (!shouldUsePrecompiledDependencies(ctx.env)) {
return;
}
startPreparingPrecompiledDependencies(ctx, config.precompiledModulesUrls);
}
export function startPreparingPrecompiledDependencies(ctx: BuildContext, urls: string[]): void {
precompiledModulesPreparationPromise = preparePrecompiledDependenciesAsync({
logger: ctx.logger,
urls,
destinationDirectory: PRECOMPILED_MODULES_PATH,
cocoapodsProxyUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
}
export async function waitForPrecompiledModulesPreparationAsync(): Promise<void> {
if (!precompiledModulesPreparationPromise) {
return;
}
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
precompiledModulesPreparationPromise,
new Promise<void>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(
new Error(
`Timed out waiting for precompiled dependencies after ${PRECOMPILED_MODULES_WAIT_TIMEOUT_MS / 1000} seconds`
)
);
}, PRECOMPILED_MODULES_WAIT_TIMEOUT_MS);
timeoutHandle.unref?.();
}),
]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
async function preparePrecompiledDependenciesAsync({
logger,
urls,
destinationDirectory,
cocoapodsProxyUrl,
}: {
logger: bunyan;
urls: string[];
destinationDirectory: string;
cocoapodsProxyUrl?: string;
}): Promise<void> {
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-'));
const destinationParentDirectory = path.dirname(destinationDirectory);
await fs.mkdirp(destinationParentDirectory);
const stagingDirectory = await fs.mkdtemp(
path.join(destinationParentDirectory, `${path.basename(destinationDirectory)}-staging-`)
);
logger.info('');
logger.info(
{
destinationDirectory,
urls,
},
'Starting precompiled dependencies download'
);
await fs.remove(destinationDirectory);
await fs.mkdirp(destinationDirectory);
try {
const archives = urls.map((url, index) => ({
archiveName: path.basename(new URL(url).pathname),
url,
archivePath: path.join(archiveDirectory, `precompiled-modules-${index}.zip`),
}));
await Promise.all(
archives.map(async ({ url, archivePath }) => {
await downloadPrecompiledModulesAsync({
url,
archivePath,
cocoapodsProxyUrl,
logger,
});
})
);
for (const { archiveName, url, archivePath } of archives) {
logger.info({ archivePath, url }, `Downloaded ${archiveName}, extracting archive`);
await extractZipAsync(archivePath, stagingDirectory);
logger.info(
{ destinationDirectory: stagingDirectory },
`Extracted ${archiveName} into staging precompiled dependencies directory`
);
}
await fs.remove(destinationDirectory);
await fs.move(stagingDirectory, destinationDirectory);
logger.info({ destinationDirectory }, `Precompiled modules ready in ${destinationDirectory}`);
} catch (error) {
await fs.remove(stagingDirectory);
await fs.remove(destinationDirectory);
await fs.mkdirp(destinationDirectory);
throw error;
} finally {
await fs.remove(archiveDirectory);
}
}
async function downloadPrecompiledModulesAsync({
url,
archivePath,
cocoapodsProxyUrl,
logger,
}: {
url: string;
archivePath: string;
cocoapodsProxyUrl?: string;
logger: bunyan;
}): Promise<void> {
const proxiedUrl = cocoapodsProxyUrl
? (() => {
const parsedUrl = new URL(url);
return `${cocoapodsProxyUrl}/${parsedUrl.hostname}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
})()
: null;
if (proxiedUrl) {
try {
await downloadFile(proxiedUrl, archivePath, { retry: 3 });
return;
} catch (err) {
logger.warn(
{ err, proxiedUrl },
'Failed to download through CocoaPods proxy, retrying directly'
);
}
}
await downloadFile(url, archivePath, { retry: 3 });
}
async function extractZipAsync(archivePath: string, destinationDirectory: string): Promise<void> {
const zip = new StreamZip.async({ file: archivePath });
try {
await zip.extract(null, destinationDirectory);
} finally {
await zip.close();
}
}
@@ -24,34 +24,6 @@ describe('StaticWorkflowInterpolationContextZ', () => {
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects invalid app and account context', () => {
const context = {
after: {},
-7
View File
@@ -259,13 +259,6 @@ export const StaticWorkflowInterpolationContextZ = z.object({
id: z.string(),
name: z.string(),
}),
app_store_connect: z
.looseObject({
app: z.looseObject({
id: z.string(),
}),
})
.optional(),
});
export type StaticWorkflowInterpolationContext = z.infer<
+3 -3
View File
@@ -43,10 +43,10 @@
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.1.16",
"@expo/apple-utils": "2.1.15",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "18.4.0",
"@expo/eas-json": "18.4.0",
"@expo/env": "^1.0.0",
-36
View File
@@ -315,42 +315,6 @@
"liveEdits": true,
"optional": true
}
},
"screenshots": {
"type": "object",
"description": "Screenshots for this locale, organized by display type (e.g., APP_IPHONE_67, APP_IPAD_PRO_129)",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"previews": {
"type": "object",
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129)",
"additionalProperties": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Video file path (relative to project root)"
},
"previewFrameTimeCode": {
"type": "string",
"description": "Optional preview frame time code (e.g., '00:05:00' for 5 seconds)"
}
},
"required": ["path"],
"additionalProperties": false
}
]
}
}
}
},
+1 -1
View File
@@ -107,7 +107,7 @@ async function validateAndroidPNGsAsync(ctx: CommonContext<Platform.ANDROID>): P
},
{
configPath: 'exp.notification.icon',
pngPath: (ctx.exp as Record<string, any>).notification?.icon,
pngPath: ctx.exp.notification?.icon,
},
];
await validatePNGsAsync(pngs);
@@ -16,7 +16,7 @@ import {
import uniq from '../../../utils/expodash/uniq';
import { AttributesOf } from '../../utils/asc';
import { removeDatePrecision } from '../../utils/date';
import { AppleMetadata, ApplePreviews, AppleScreenshots } from '../types';
import { AppleMetadata } from '../types';
type PartialExcept<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>;
@@ -211,14 +211,4 @@ export class AppleConfigReader {
// TODO: add attachment
};
}
/** Get screenshots configuration for a specific locale */
public getScreenshots(locale: string): AppleScreenshots | null {
return this.schema.info?.[locale]?.screenshots ?? null;
}
/** Get video previews configuration for a specific locale */
public getPreviews(locale: string): ApplePreviews | null {
return this.schema.info?.[locale]?.previews ?? null;
}
}
@@ -13,7 +13,7 @@ import {
} from '@expo/apple-utils';
import { AttributesOf } from '../../utils/asc';
import { AppleMetadata, ApplePreviews, AppleScreenshots } from '../types';
import { AppleMetadata } from '../types';
/**
* Serializes the Apple ASC entities into the metadata configuration schema.
@@ -186,21 +186,6 @@ export class AppleConfigWriter {
// TODO: add attachment
};
}
/** Set screenshots for a specific locale */
public setScreenshots(locale: string, screenshots: AppleScreenshots): void {
this.schema.info = this.schema.info ?? {};
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
this.schema.info[locale].screenshots =
Object.keys(screenshots).length > 0 ? screenshots : undefined;
}
/** Set video previews for a specific locale */
public setPreviews(locale: string, previews: ApplePreviews): void {
this.schema.info = this.schema.info ?? {};
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
this.schema.info[locale].previews = Object.keys(previews).length > 0 ? previews : undefined;
}
}
/** Helper function to convert `T | null` to `T | undefined`, required for the entity properties */
+2 -11
View File
@@ -4,25 +4,16 @@ import type { AgeRatingData } from './tasks/age-rating';
import type { AppInfoData } from './tasks/app-info';
import type { AppReviewData } from './tasks/app-review-detail';
import type { AppVersionData } from './tasks/app-version';
import type { PreviewsData } from './tasks/previews';
import type { ScreenshotsData } from './tasks/screenshots';
/**
* The fully prepared apple data, used within the `downloadAsync` or `uploadAsync` tasks.
* It contains references to each individual models, to either upload or download App Store data.
*/
export type AppleData = { app: App; projectDir: string } & AppInfoData &
AppVersionData &
AgeRatingData &
AppReviewData &
ScreenshotsData &
PreviewsData;
export type AppleData = { app: App } & AppInfoData & AppVersionData & AgeRatingData & AppReviewData;
/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
* It contains a reference to the app, each task should populate the necessary data.
* If an entity fails to prepare the data, individual tasks should raise errors about the missing data.
*/
export type PartialAppleData = { app: App; projectDir: string } & Partial<
Omit<AppleData, 'app' | 'projectDir'>
>;
export type PartialAppleData = { app: App } & Partial<Omit<AppleData, 'app'>>;
@@ -1,14 +1,8 @@
import { truthy } from '../../../utils/expodash/filter';
import { IssueRule } from '../../config/issue';
import { AppleInfo } from '../types';
/** Only check text properties that may contain restricted words */
type AppleInfoTextProperty = 'title' | 'subtitle' | 'description' | 'keywords';
const RESTRICTED_PROPERTIES: AppleInfoTextProperty[] = [
'title',
'subtitle',
'description',
'keywords',
];
const RESTRICTED_PROPERTIES: (keyof AppleInfo)[] = ['title', 'subtitle', 'description', 'keywords'];
const RESTRICTED_WORDS = {
beta: 'Apple restricts the word "beta" and synonyms implying incomplete functionality.',
};
@@ -1,4 +1,4 @@
import { AgeRatingDeclaration, AppInfo, Rating } from '@expo/apple-utils';
import { AgeRatingDeclaration, AppStoreVersion, Rating } from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
@@ -12,13 +12,13 @@ jest.mock('../../config/writer');
describe(AgeRatingTask, () => {
describe('prepareAsync', () => {
it('loads age rating from app info', async () => {
it('loads age rating from app version', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppInfo.type}/stub-id/ageRatingDeclaration`)
.get(`/v1/${AppStoreVersion.type}/stub-id/ageRatingDeclaration`)
.reply(200, require('./fixtures/appStoreVersions/get-ageRatingDeclaration-200.json'));
const context: any = {
info: new AppInfo(requestContext, 'stub-id', {} as any),
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
};
await new AgeRatingTask().prepareAsync({ context });
@@ -26,16 +26,6 @@ describe(AgeRatingTask, () => {
expect(context.ageRating).toBeInstanceOf(AgeRatingDeclaration);
expect(scope.isDone()).toBeTruthy();
});
it('skips when info is not available', async () => {
const context: any = {
info: undefined,
};
await new AgeRatingTask().prepareAsync({ context });
expect(context.ageRating).toBeUndefined();
});
});
describe('downloadAsync', () => {
@@ -64,13 +54,13 @@ describe(AgeRatingTask, () => {
});
describe('uploadAsync', () => {
it('skips when age rating is not loaded', async () => {
await expect(
new AgeRatingTask().uploadAsync({
config: new AppleConfigReader({}),
context: { ageRating: undefined } as any,
})
).resolves.not.toThrow();
it('aborts when age rating is not loaded', async () => {
const promise = new AgeRatingTask().uploadAsync({
config: new AppleConfigReader({}),
context: { ageRating: undefined } as any,
});
await expect(promise).rejects.toThrow('rating not initialized');
});
it('skips updating age rating when not configured', async () => {
@@ -23,7 +23,6 @@ describe(AppInfoTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppInfoTask().prepareAsync({ context });
@@ -72,20 +72,20 @@ describe(AppReviewDetailTask, () => {
nock.cleanAll();
});
it('skips when version is not loaded', async () => {
await expect(
new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
},
}),
context: {} as any,
})
).resolves.not.toThrow();
it('aborts when version is not loaded', async () => {
const promise = new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: 'review@example.com',
phone: '+1 555 555 5555',
},
}),
context: {} as any,
});
await expect(promise).rejects.toThrow('version not init');
});
it('updates review details when loaded', async () => {
@@ -44,7 +44,6 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ editLive: true }).prepareAsync({ context });
@@ -84,7 +83,6 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ editLive: true }).prepareAsync({ context });
@@ -120,7 +118,6 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask().prepareAsync({ context });
@@ -156,7 +153,6 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask().prepareAsync({ context });
@@ -198,7 +194,6 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ version: '2.0' }).prepareAsync({ context });
@@ -248,7 +243,6 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ version: '3.0' }).prepareAsync({ context });
@@ -377,7 +371,6 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
};
@@ -406,7 +399,6 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
versionPhasedRelease: null, // Not enabled yet
};
@@ -425,7 +417,6 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
// Enabled, and not completed yet
versionPhasedRelease: new AppStoreVersionPhasedRelease(
@@ -449,9 +440,8 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
// Enabled, and completed
// Enabled, and not completed yet
versionPhasedRelease: new AppStoreVersionPhasedRelease(
requestContext,
'APP_STORE_VERSION_PHASED_RELEASE_1',
@@ -1,405 +0,0 @@
import {
AppPreview,
AppPreviewSet,
AppStoreVersionLocalization,
PreviewType,
} from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { PartialAppleData } from '../../data';
import { PreviewsTask } from '../previews';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
const mockFetch = jest.fn();
jest.mock('../../../../fetch', () => ({
__esModule: true,
default: (...args: any[]) => mockFetch(...args),
}));
import fs from 'fs';
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'statSync').mockReturnValue({ size: 10240 } as any);
jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
describe(PreviewsTask, () => {
beforeEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
describe('prepareAsync', () => {
it('initializes empty preview sets when no locales are available', async () => {
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: undefined,
};
await new PreviewsTask().prepareAsync({ context });
expect(context.previewSets).toBeDefined();
expect(context.previewSets!.size).toBe(0);
});
it('fetches preview sets for each locale', async () => {
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersionLocalization.type}/LOC_1/${AppPreviewSet.type}`)
.query(true)
.reply(200, {
data: [
{
id: 'PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
],
});
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: [locale],
};
await new PreviewsTask().prepareAsync({ context });
expect(context.previewSets).toBeDefined();
expect(context.previewSets!.size).toBe(1);
expect(context.previewSets!.get('en-US')).toBeDefined();
expect(context.previewSets!.get('en-US')!.has(PreviewType.IPHONE_67)).toBeTruthy();
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('skips when preview sets are not prepared', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new PreviewsTask().downloadAsync({
config: writer,
context: { previewSets: undefined, versionLocales: undefined } as any,
});
expect(writer.setPreviews).not.toBeCalled();
});
it('skips locales with no preview sets', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const previewSets = new Map();
previewSets.set('en-US', new Map());
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets,
versionLocales: [locale],
} as any,
});
expect(writer.setPreviews).not.toBeCalled();
});
it('downloads previews and sets config with simple path', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const preview = new AppPreview(requestContext, 'PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
videoUrl: 'https://example.com/demo.mp4',
assetDeliveryState: {
state: 'COMPLETE',
errors: [],
warnings: [],
},
} as any);
jest.spyOn(preview, 'getVideoUrl').mockReturnValue('https://example.com/demo.mp4');
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
const previewSet = new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [preview],
} as any);
previewTypeMap.set(PreviewType.IPHONE_67, previewSet);
const previewSets = new Map();
previewSets.set('en-US', previewTypeMap);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-video-data')),
});
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setPreviews).toBeCalledWith('en-US', {
[PreviewType.IPHONE_67]: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
});
});
it('downloads previews with previewFrameTimeCode as object config', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const preview = new AppPreview(requestContext, 'PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: '00:05:00',
videoUrl: 'https://example.com/demo.mp4',
assetDeliveryState: {
state: 'COMPLETE',
errors: [],
warnings: [],
},
} as any);
jest.spyOn(preview, 'getVideoUrl').mockReturnValue('https://example.com/demo.mp4');
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
const previewSet = new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [preview],
} as any);
previewTypeMap.set(PreviewType.IPHONE_67, previewSet);
const previewSets = new Map();
previewSets.set('en-US', previewTypeMap);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-video-data')),
});
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setPreviews).toBeCalledWith('en-US', {
[PreviewType.IPHONE_67]: {
path: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
previewFrameTimeCode: '00:05:00',
},
});
});
});
describe('uploadAsync', () => {
it('skips when preview sets are not prepared', async () => {
const config = new AppleConfigReader({});
await new PreviewsTask().uploadAsync({
config,
context: { previewSets: undefined, versionLocales: undefined } as any,
});
// Should not throw, just log and return
});
it('skips when no locales are configured', async () => {
const config = new AppleConfigReader({});
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets: new Map(),
versionLocales: [],
} as any,
});
// Should not throw, just log and return
});
it('skips locales without previews in config', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
// No previews
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets: new Map(),
versionLocales: [locale],
} as any,
});
// Should complete without uploading
});
it('uploads new preview for configured locale with string path', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
IPHONE_67: './previews/demo.mp4',
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const newPreview = new AppPreview(requestContext, 'NEW_PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
// Create preview set
.post(`/v1/${AppPreviewSet.type}`)
.reply(201, {
data: {
id: 'NEW_PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
});
// Mock AppPreview.uploadAsync to avoid real file access
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(AppPreview.uploadAsync).toHaveBeenCalledWith(
requestContext,
expect.objectContaining({
id: 'NEW_PSET_1',
filePath: '/test/project/previews/demo.mp4',
waitForProcessing: true,
})
);
});
it('uploads new preview with object config including previewFrameTimeCode', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
IPHONE_67: {
path: './previews/demo.mp4',
previewFrameTimeCode: '00:05:00',
},
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const newPreview = new AppPreview(requestContext, 'NEW_PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: '00:05:00',
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
// Create preview set
.post(`/v1/${AppPreviewSet.type}`)
.reply(201, {
data: {
id: 'NEW_PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
});
// Mock AppPreview.uploadAsync to avoid real file access
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(AppPreview.uploadAsync).toHaveBeenCalledWith(
requestContext,
expect.objectContaining({
id: 'NEW_PSET_1',
filePath: '/test/project/previews/demo.mp4',
waitForProcessing: true,
previewFrameTimeCode: '00:05:00',
})
);
});
});
});
@@ -1,303 +0,0 @@
import {
AppScreenshot,
AppScreenshotSet,
AppStoreVersionLocalization,
ScreenshotDisplayType,
} from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { PartialAppleData } from '../../data';
import { ScreenshotsTask } from '../screenshots';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
const mockFetch = jest.fn();
jest.mock('../../../../fetch', () => ({
__esModule: true,
default: (...args: any[]) => mockFetch(...args),
}));
import fs from 'fs';
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'statSync').mockReturnValue({ size: 1024 } as any);
jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
describe(ScreenshotsTask, () => {
beforeEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
describe('prepareAsync', () => {
it('initializes empty screenshot sets when no locales are available', async () => {
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: undefined,
};
await new ScreenshotsTask().prepareAsync({ context });
expect(context.screenshotSets).toBeDefined();
expect(context.screenshotSets!.size).toBe(0);
});
it('fetches screenshot sets for each locale', async () => {
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersionLocalization.type}/LOC_1/${AppScreenshotSet.type}`)
.query(true)
.reply(200, {
data: [
{
id: 'SET_1',
type: AppScreenshotSet.type,
attributes: {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [],
},
},
],
});
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: [locale],
};
await new ScreenshotsTask().prepareAsync({ context });
expect(context.screenshotSets).toBeDefined();
expect(context.screenshotSets!.size).toBe(1);
expect(context.screenshotSets!.get('en-US')).toBeDefined();
expect(
context.screenshotSets!.get('en-US')!.has(ScreenshotDisplayType.APP_IPHONE_67)
).toBeTruthy();
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('skips when screenshot sets are not prepared', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new ScreenshotsTask().downloadAsync({
config: writer,
context: { screenshotSets: undefined, versionLocales: undefined } as any,
});
expect(writer.setScreenshots).not.toBeCalled();
});
it('skips locales with no screenshot sets', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const screenshotSets = new Map();
screenshotSets.set('en-US', new Map());
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new ScreenshotsTask().downloadAsync({
config: writer,
context: {
screenshotSets,
versionLocales: [locale],
} as any,
});
expect(writer.setScreenshots).not.toBeCalled();
});
it('downloads screenshots and sets config', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const screenshot = new AppScreenshot(requestContext, 'SS_1', {
fileName: 'home.png',
fileSize: 1024,
assetDeliveryState: {
state: 'COMPLETE',
errors: [],
warnings: [],
},
} as any);
// Mock getImageAssetUrl
jest.spyOn(screenshot, 'getImageAssetUrl').mockReturnValue('https://example.com/home.png');
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
const screenshotSet = new AppScreenshotSet(requestContext, 'SET_1', {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [screenshot],
} as any);
displayTypeMap.set(ScreenshotDisplayType.APP_IPHONE_67, screenshotSet);
const screenshotSets = new Map();
screenshotSets.set('en-US', displayTypeMap);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
// Mock fetch response
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-image-data')),
});
await new ScreenshotsTask().downloadAsync({
config: writer,
context: {
screenshotSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setScreenshots).toBeCalledWith('en-US', {
[ScreenshotDisplayType.APP_IPHONE_67]: [
'store/apple/screenshot/en-US/APP_IPHONE_67/home.png',
],
});
});
});
describe('uploadAsync', () => {
it('skips when screenshot sets are not prepared', async () => {
const config = new AppleConfigReader({});
await new ScreenshotsTask().uploadAsync({
config,
context: { screenshotSets: undefined, versionLocales: undefined } as any,
});
// Should not throw, just log and return
});
it('skips when no locales are configured', async () => {
const config = new AppleConfigReader({});
await new ScreenshotsTask().uploadAsync({
config,
context: {
screenshotSets: new Map(),
versionLocales: [],
} as any,
});
// Should not throw, just log and return
});
it('skips locales without screenshots in config', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
// No screenshots
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new ScreenshotsTask().uploadAsync({
config,
context: {
screenshotSets: new Map(),
versionLocales: [locale],
} as any,
});
// Should complete without uploading
});
it('uploads new screenshots for configured locale', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
screenshots: {
APP_IPHONE_67: ['./screenshots/home.png'],
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const newScreenshot = new AppScreenshot(requestContext, 'NEW_SS_1', {
fileName: 'home.png',
fileSize: 1024,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
// Mock createAppScreenshotSetAsync on the locale
const scope = nock('https://api.appstoreconnect.apple.com')
// Create screenshot set
.post(`/v1/${AppScreenshotSet.type}`)
.reply(201, {
data: {
id: 'NEW_SET_1',
type: AppScreenshotSet.type,
attributes: {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [],
},
},
});
// Mock AppScreenshot.uploadAsync to avoid real file access
jest.spyOn(AppScreenshot, 'uploadAsync').mockResolvedValue(newScreenshot);
// Mock AppScreenshotSet.infoAsync for reorder step
jest.spyOn(AppScreenshotSet, 'infoAsync').mockResolvedValue(
new AppScreenshotSet(requestContext, 'NEW_SET_1', {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [newScreenshot],
} as any)
);
// Mock reorderScreenshotsAsync
const reorderMock = jest.fn().mockResolvedValue([]);
AppScreenshotSet.prototype.reorderScreenshotsAsync = reorderMock;
const screenshotSets = new Map();
screenshotSets.set('en-US', new Map());
await new ScreenshotsTask().uploadAsync({
config,
context: {
screenshotSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(AppScreenshot.uploadAsync).toHaveBeenCalledWith(
requestContext,
expect.objectContaining({
id: 'NEW_SET_1',
filePath: '/test/project/screenshots/home.png',
waitForProcessing: true,
})
);
// Reorder is skipped because the current order already matches
expect(reorderMock).not.toHaveBeenCalled();
});
});
});
@@ -1,4 +1,5 @@
import { AgeRatingDeclaration } from '@expo/apple-utils';
import assert from 'assert';
import chalk from 'chalk';
import Log from '../../../log';
@@ -6,7 +7,7 @@ import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
export type AgeRatingData = {
/** The app age rating declaration for the app info */
/** The app age rating declaration for the app version */
ageRating: AgeRatingDeclaration;
};
@@ -14,22 +15,7 @@ export class AgeRatingTask extends AppleTask {
public name = (): string => 'age rating declarations';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
if (!context.info) {
return;
}
// The ageRatingDeclaration relationship is on appInfos (not appStoreVersions).
try {
context.ageRating = (await context.info.getAgeRatingDeclarationAsync()) ?? undefined;
} catch (error: any) {
// Gracefully handle cases where the relationship is not available
if (error?.message?.includes('ageRatingDeclaration')) {
Log.warn(
chalk`{yellow Skipped age rating - not available for this app. This may require updating through App Store Connect directly.}`
);
return;
}
throw error;
}
context.ageRating = (await context.version!.getAgeRatingDeclarationAsync()) ?? undefined;
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
@@ -39,10 +25,7 @@ export class AgeRatingTask extends AppleTask {
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
if (!context.ageRating) {
Log.log(chalk`{dim - Skipped age rating update, not available}`);
return;
}
assert(context.ageRating, `Age rating not initialized, can't update age rating`);
const ageRating = config.getAgeRating();
if (!ageRating) {
@@ -1,4 +1,5 @@
import { AppStoreReviewDetail } from '@expo/apple-utils';
import assert from 'assert';
import chalk from 'chalk';
import Log from '../../../log';
@@ -15,10 +16,7 @@ export class AppReviewDetailTask extends AppleTask {
public name = (): string => 'app review detail';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
if (!context.version) {
return;
}
context.reviewDetail = (await context.version.getAppStoreReviewDetailAsync()) ?? undefined;
context.reviewDetail = (await context.version!.getAppStoreReviewDetailAsync()) ?? undefined;
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
@@ -34,10 +32,7 @@ export class AppReviewDetailTask extends AppleTask {
return;
}
if (!context.version) {
Log.log(chalk`{dim - Skipped store review details, no version available}`);
return;
}
assert(context.version, `App version not initialized, can't upload store review details`);
const { versionString } = context.version.attributes;
if (!context.reviewDetail) {
@@ -2,8 +2,6 @@ import { AgeRatingTask } from './age-rating';
import { AppInfoTask } from './app-info';
import { AppReviewDetailTask } from './app-review-detail';
import { AppVersionOptions, AppVersionTask } from './app-version';
import { PreviewsTask } from './previews';
import { ScreenshotsTask } from './screenshots';
import { AppleTask } from '../task';
type AppleTaskOptions = {
@@ -19,7 +17,5 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
new AppInfoTask(),
new AgeRatingTask(),
new AppReviewDetailTask(),
new ScreenshotsTask(),
new PreviewsTask(),
];
}
@@ -1,297 +0,0 @@
import {
AppPreview,
AppPreviewSet,
AppStoreVersionLocalization,
PreviewType,
} from '@expo/apple-utils';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import fetch from '../../../fetch';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
import { ApplePreviewConfig, ApplePreviews } from '../types';
/** Locale -> PreviewType -> AppPreviewSet */
export type PreviewSetsMap = Map<string, Map<PreviewType, AppPreviewSet>>;
export type PreviewsData = {
/** Map of locales to their preview sets */
previewSets: PreviewSetsMap;
};
/**
* Normalize preview config to always return an object with path and optional previewFrameTimeCode.
*/
function normalizePreviewConfig(config: ApplePreviewConfig): {
path: string;
previewFrameTimeCode?: string;
} {
if (typeof config === 'string') {
return { path: config };
}
return config;
}
/**
* Task for managing App Store video previews.
* Downloads existing previews and uploads new ones based on store configuration.
*/
export class PreviewsTask extends AppleTask {
public name = (): string => 'video previews';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
// Initialize the preview sets map
context.previewSets = new Map();
if (!context.versionLocales) {
return;
}
// Fetch preview sets for all locales in parallel
await Promise.all(
context.versionLocales.map(async locale => {
const sets = await locale.getAppPreviewSetsAsync();
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
for (const set of sets) {
previewTypeMap.set(set.attributes.previewType, set);
}
context.previewSets!.set(locale.attributes.locale, previewTypeMap);
})
);
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
if (!context.previewSets || !context.versionLocales) {
return;
}
for (const locale of context.versionLocales) {
const localeCode = locale.attributes.locale;
const previewTypeMap = context.previewSets.get(localeCode);
if (!previewTypeMap || previewTypeMap.size === 0) {
continue;
}
const previews: ApplePreviews = {};
for (const [previewType, set] of previewTypeMap) {
const previewModels = set.attributes.appPreviews;
if (!previewModels || previewModels.length === 0) {
continue;
}
// For now, we only handle the first preview per set (App Store allows up to 3)
// We can extend this later to support multiple previews
const preview = previewModels[0];
const relativePath = await downloadPreviewAsync(
context.projectDir,
localeCode,
previewType,
preview,
0
);
if (relativePath) {
// Include preview frame time code if available
if (preview.attributes.previewFrameTimeCode) {
previews[previewType] = {
path: relativePath,
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
};
} else {
previews[previewType] = relativePath;
}
}
}
if (Object.keys(previews).length > 0) {
config.setPreviews(localeCode, previews);
}
}
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
if (!context.previewSets || !context.versionLocales) {
Log.log(chalk`{dim - Skipped video previews, no version available}`);
return;
}
const locales = config.getLocales();
if (locales.length <= 0) {
Log.log(chalk`{dim - Skipped video previews, no locales configured}`);
return;
}
for (const localeCode of locales) {
const previews = config.getPreviews(localeCode);
if (!previews || Object.keys(previews).length === 0) {
continue;
}
const localization = context.versionLocales.find(l => l.attributes.locale === localeCode);
if (!localization) {
Log.warn(chalk`{yellow Skipping video previews for ${localeCode} - locale not found}`);
continue;
}
for (const [previewType, previewConfig] of Object.entries(previews)) {
if (!previewConfig) {
continue;
}
await syncPreviewSetAsync(
context.projectDir,
localization,
previewType as PreviewType,
normalizePreviewConfig(previewConfig),
context.previewSets.get(localeCode)
);
}
}
}
}
/**
* Sync a preview set - upload new preview, delete old one if changed.
*/
async function syncPreviewSetAsync(
projectDir: string,
localization: AppStoreVersionLocalization,
previewType: PreviewType,
previewConfig: { path: string; previewFrameTimeCode?: string },
existingSets: Map<PreviewType, AppPreviewSet> | undefined
): Promise<void> {
const locale = localization.attributes.locale;
const absolutePath = path.resolve(projectDir, previewConfig.path);
const fileName = path.basename(absolutePath);
if (!fs.existsSync(absolutePath)) {
Log.warn(chalk`{yellow Video preview not found: ${absolutePath}}`);
return;
}
// Get or create the preview set
let previewSet = existingSets?.get(previewType);
if (!previewSet) {
previewSet = await logAsync(
() =>
localization.createAppPreviewSetAsync({
previewType,
}),
{
pending: `Creating preview set for ${chalk.bold(previewType)} (${locale})...`,
success: `Created preview set for ${chalk.bold(previewType)} (${locale})`,
failure: `Failed creating preview set for ${chalk.bold(previewType)} (${locale})`,
}
);
}
const existingPreviews = previewSet.attributes.appPreviews || [];
// Check if we need to update (different filename, size, or no existing preview)
const existingPreview = existingPreviews.find(p => p.attributes.fileName === fileName);
const localSize = fs.statSync(absolutePath).size;
if (
existingPreview &&
existingPreview.isComplete() &&
existingPreview.attributes.fileSize === localSize
) {
// Preview with same filename exists, check if we need to update preview frame time code
if (
previewConfig.previewFrameTimeCode &&
existingPreview.attributes.previewFrameTimeCode !== previewConfig.previewFrameTimeCode
) {
await logAsync(
() =>
existingPreview.updateAsync({
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
}),
{
pending: `Updating preview frame time code for ${chalk.bold(fileName)} (${locale})...`,
success: `Updated preview frame time code for ${chalk.bold(fileName)} (${locale})`,
failure: `Failed updating preview frame time code for ${chalk.bold(fileName)} (${locale})`,
}
);
}
Log.log(chalk`{dim Preview ${fileName} already exists, skipping upload}`);
return;
}
// Delete existing previews that don't match
for (const preview of existingPreviews) {
if (preview.attributes.fileName !== fileName) {
await logAsync(() => preview.deleteAsync(), {
pending: `Deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})...`,
success: `Deleted old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
failure: `Failed deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
});
}
}
// Upload new preview
await logAsync(
() =>
AppPreview.uploadAsync(localization.context, {
id: previewSet!.id,
filePath: absolutePath,
waitForProcessing: true,
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
}),
{
pending: `Uploading video preview ${chalk.bold(fileName)} (${locale})...`,
success: `Uploaded video preview ${chalk.bold(fileName)} (${locale})`,
failure: `Failed uploading video preview ${chalk.bold(fileName)} (${locale})`,
}
);
}
/**
* Download a video preview to the local filesystem.
* Returns the relative path to the downloaded file.
*/
async function downloadPreviewAsync(
projectDir: string,
locale: string,
previewType: PreviewType,
preview: AppPreview,
index: number
): Promise<string | null> {
const videoUrl = preview.getVideoUrl();
if (!videoUrl) {
Log.warn(chalk`{yellow Could not get download URL for preview ${preview.attributes.fileName}}`);
return null;
}
// Create directory structure: store/apple/preview/{locale}/{previewType}/
const previewsDir = path.join(projectDir, 'store', 'apple', 'preview', locale, previewType);
await fs.promises.mkdir(previewsDir, { recursive: true });
// Use original filename for matching during sync
const fileName = preview.attributes.fileName || `${String(index + 1).padStart(2, '0')}.mp4`;
const outputPath = path.join(previewsDir, fileName);
const relativePath = path.relative(projectDir, outputPath);
try {
const response = await fetch(videoUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const buffer = await response.buffer();
await fs.promises.writeFile(outputPath, buffer);
Log.log(chalk`{dim Downloaded video preview: ${relativePath}}`);
return relativePath;
} catch (error: any) {
Log.warn(chalk`{yellow Failed to download video preview ${fileName}: ${error.message}}`);
return null;
}
}
@@ -1,314 +0,0 @@
import {
AppScreenshot,
AppScreenshotSet,
AppStoreVersionLocalization,
ScreenshotDisplayType,
} from '@expo/apple-utils';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import fetch from '../../../fetch';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
import { AppleScreenshots } from '../types';
/** Locale -> ScreenshotDisplayType -> AppScreenshotSet */
export type ScreenshotSetsMap = Map<string, Map<ScreenshotDisplayType, AppScreenshotSet>>;
export type ScreenshotsData = {
/** Map of locales to their screenshot sets */
screenshotSets: ScreenshotSetsMap;
};
/**
* Task for managing App Store screenshots.
* Downloads existing screenshots and uploads new ones based on store configuration.
*/
export class ScreenshotsTask extends AppleTask {
public name = (): string => 'screenshots';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
// Initialize the screenshot sets map
context.screenshotSets = new Map();
if (!context.versionLocales) {
return;
}
// Fetch screenshot sets for all locales in parallel
await Promise.all(
context.versionLocales.map(async locale => {
const sets = await locale.getAppScreenshotSetsAsync();
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
for (const set of sets) {
displayTypeMap.set(set.attributes.screenshotDisplayType, set);
}
context.screenshotSets!.set(locale.attributes.locale, displayTypeMap);
})
);
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
if (!context.screenshotSets || !context.versionLocales) {
return;
}
for (const locale of context.versionLocales) {
const localeCode = locale.attributes.locale;
const displayTypeMap = context.screenshotSets.get(localeCode);
if (!displayTypeMap || displayTypeMap.size === 0) {
continue;
}
const screenshots: AppleScreenshots = {};
for (const [displayType, set] of displayTypeMap) {
const screenshotModels = set.attributes.appScreenshots;
if (!screenshotModels || screenshotModels.length === 0) {
continue;
}
// Download screenshots and save to local filesystem
const paths: string[] = [];
for (let i = 0; i < screenshotModels.length; i++) {
const screenshot = screenshotModels[i];
const relativePath = await downloadScreenshotAsync(
context.projectDir,
localeCode,
displayType,
screenshot,
i
);
if (relativePath) {
paths.push(relativePath);
}
}
if (paths.length > 0) {
screenshots[displayType] = paths;
}
}
if (Object.keys(screenshots).length > 0) {
config.setScreenshots(localeCode, screenshots);
}
}
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
if (!context.screenshotSets || !context.versionLocales) {
Log.log(chalk`{dim - Skipped screenshots, no version available}`);
return;
}
const locales = config.getLocales();
if (locales.length <= 0) {
Log.log(chalk`{dim - Skipped screenshots, no locales configured}`);
return;
}
for (const localeCode of locales) {
const screenshots = config.getScreenshots(localeCode);
if (!screenshots || Object.keys(screenshots).length === 0) {
continue;
}
const localization = context.versionLocales.find(l => l.attributes.locale === localeCode);
if (!localization) {
Log.warn(chalk`{yellow Skipping screenshots for ${localeCode} - locale not found}`);
continue;
}
for (const [displayType, paths] of Object.entries(screenshots)) {
if (!paths || paths.length === 0) {
continue;
}
await syncScreenshotSetAsync(
context.projectDir,
localization,
displayType as ScreenshotDisplayType,
paths,
context.screenshotSets.get(localeCode)
);
}
}
}
}
/**
* Sync a screenshot set - upload new screenshots, delete removed ones, reorder if needed.
*/
async function syncScreenshotSetAsync(
projectDir: string,
localization: AppStoreVersionLocalization,
displayType: ScreenshotDisplayType,
paths: string[],
existingSets: Map<ScreenshotDisplayType, AppScreenshotSet> | undefined
): Promise<void> {
const locale = localization.attributes.locale;
// Get or create the screenshot set
let screenshotSet = existingSets?.get(displayType);
if (!screenshotSet) {
screenshotSet = await logAsync(
() =>
localization.createAppScreenshotSetAsync({
screenshotDisplayType: displayType,
}),
{
pending: `Creating screenshot set for ${chalk.bold(displayType)} (${locale})...`,
success: `Created screenshot set for ${chalk.bold(displayType)} (${locale})`,
failure: `Failed creating screenshot set for ${chalk.bold(displayType)} (${locale})`,
}
);
}
const existingScreenshots = screenshotSet.attributes.appScreenshots || [];
// Build a map of existing screenshots by filename for comparison
const existingByFilename = new Map<string, AppScreenshot>();
for (const screenshot of existingScreenshots) {
existingByFilename.set(screenshot.attributes.fileName, screenshot);
}
// Track which screenshots to keep, upload, and delete
const screenshotIdsToKeep: string[] = [];
const pathsToUpload: string[] = [];
for (const relativePath of paths) {
const absolutePath = path.resolve(projectDir, relativePath);
const fileName = path.basename(absolutePath);
// Check if screenshot already exists with same name and file size
const existing = existingByFilename.get(fileName);
const localSize = fs.existsSync(absolutePath) ? fs.statSync(absolutePath).size : null;
if (
existing &&
existing.isComplete() &&
(localSize === null || existing.attributes.fileSize === localSize)
) {
screenshotIdsToKeep.push(existing.id);
existingByFilename.delete(fileName);
} else {
pathsToUpload.push(absolutePath);
}
}
// Delete screenshots that are no longer in config
for (const screenshot of existingByFilename.values()) {
await logAsync(() => screenshot.deleteAsync(), {
pending: `Deleting screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})...`,
success: `Deleted screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})`,
failure: `Failed deleting screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})`,
});
}
// Upload new screenshots
for (const absolutePath of pathsToUpload) {
const fileName = path.basename(absolutePath);
if (!fs.existsSync(absolutePath)) {
Log.warn(chalk`{yellow Screenshot not found: ${absolutePath}}`);
continue;
}
const newScreenshot = await logAsync(
() =>
AppScreenshot.uploadAsync(localization.context, {
id: screenshotSet!.id,
filePath: absolutePath,
waitForProcessing: true,
}),
{
pending: `Uploading screenshot ${chalk.bold(fileName)} (${locale})...`,
success: `Uploaded screenshot ${chalk.bold(fileName)} (${locale})`,
failure: `Failed uploading screenshot ${chalk.bold(fileName)} (${locale})`,
}
);
screenshotIdsToKeep.push(newScreenshot.id);
}
// Reorder screenshots to match config order
if (screenshotIdsToKeep.length > 0) {
const refreshedSet = await AppScreenshotSet.infoAsync(localization.context, {
id: screenshotSet.id,
});
const refreshedScreenshots = refreshedSet.attributes.appScreenshots || [];
const screenshotsByFilename = new Map<string, AppScreenshot>();
for (const s of refreshedScreenshots) {
screenshotsByFilename.set(s.attributes.fileName, s);
}
// Build the desired order based on config paths
const orderedIds: string[] = [];
for (const relativePath of paths) {
const fileName = path.basename(relativePath);
const screenshot = screenshotsByFilename.get(fileName);
if (screenshot) {
orderedIds.push(screenshot.id);
}
}
// Only call reorder if the order actually differs from current
const currentIds = refreshedScreenshots.map(s => s.id);
if (
orderedIds.length > 0 &&
(orderedIds.length !== currentIds.length || orderedIds.some((id, i) => id !== currentIds[i]))
) {
await screenshotSet.reorderScreenshotsAsync({ appScreenshots: orderedIds });
}
}
}
/**
* Download a screenshot to the local filesystem.
* Returns the relative path to the downloaded file.
*/
async function downloadScreenshotAsync(
projectDir: string,
locale: string,
displayType: ScreenshotDisplayType,
screenshot: AppScreenshot,
index: number
): Promise<string | null> {
const imageUrl = screenshot.getImageAssetUrl({ type: 'png' });
if (!imageUrl) {
Log.warn(
chalk`{yellow Could not get download URL for screenshot ${screenshot.attributes.fileName}}`
);
return null;
}
// Create directory structure: store/apple/screenshot/{locale}/{displayType}/
const screenshotsDir = path.join(projectDir, 'store', 'apple', 'screenshot', locale, displayType);
await fs.promises.mkdir(screenshotsDir, { recursive: true });
// Use original filename for matching during sync
const fileName = screenshot.attributes.fileName || `${String(index + 1).padStart(2, '0')}.png`;
const outputPath = path.join(screenshotsDir, fileName);
const relativePath = path.relative(projectDir, outputPath);
try {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const buffer = await response.buffer();
await fs.promises.writeFile(outputPath, buffer);
Log.log(chalk`{dim Downloaded screenshot: ${relativePath}}`);
return relativePath;
} catch (error: any) {
Log.warn(chalk`{yellow Failed to download screenshot ${fileName}: ${error.message}}`);
return null;
}
}
+1 -45
View File
@@ -1,46 +1,7 @@
import type {
AgeRatingDeclarationProps,
PreviewType,
ScreenshotDisplayType,
} from '@expo/apple-utils';
import type { AgeRatingDeclarationProps } from '@expo/apple-utils';
export type AppleLocale = string;
/** Screenshot display type enum values from App Store Connect API */
export type AppleScreenshotDisplayType = `${ScreenshotDisplayType}`;
/** Preview display type enum values from App Store Connect API */
export type ApplePreviewType = `${PreviewType}`;
/**
* Screenshots organized by display type.
* Key is the display type (e.g., 'APP_IPHONE_67'), value is array of file paths.
* @example { "APP_IPHONE_67": ["./screenshots/home.png", "./screenshots/profile.png"] }
*/
export type AppleScreenshots = Partial<Record<AppleScreenshotDisplayType, string[]>>;
/**
* Video preview configuration - either a simple path string or an object with options.
* @example "./previews/demo.mp4"
* @example { path: "./previews/demo.mp4", previewFrameTimeCode: "00:05:00" }
*/
export type ApplePreviewConfig =
| string
| {
/** Video file path (relative to project root) */
path: string;
/** Optional preview frame time code (e.g., '00:05:00' for 5 seconds) */
previewFrameTimeCode?: string;
};
/**
* Video previews organized by display type.
* Key is the display type (e.g., 'IPHONE_67'), value is the preview config.
* @example { "IPHONE_67": "./previews/demo.mp4" }
* @example { "IPHONE_67": { path: "./previews/demo.mp4", previewFrameTimeCode: "00:05:00" } }
*/
export type ApplePreviews = Partial<Record<ApplePreviewType, ApplePreviewConfig>>;
export interface AppleMetadata {
version?: string;
copyright?: string;
@@ -48,7 +9,6 @@ export interface AppleMetadata {
categories?: AppleCategory;
release?: AppleRelease;
advisory?: AppleAdvisory;
/** @deprecated Use screenshots/previews in AppleInfo instead */
preview?: Record<string, string[]>;
review?: AppleReview;
}
@@ -80,10 +40,6 @@ export interface AppleInfo {
privacyPolicyText?: string;
privacyChoicesUrl?: string;
supportUrl?: string;
/** Screenshots for this locale, organized by display type */
screenshots?: AppleScreenshots;
/** Video previews for this locale, organized by display type */
previews?: ApplePreviews;
}
export interface AppleReview {
+1 -1
View File
@@ -77,7 +77,7 @@ export async function downloadMetadataAsync({
const errors: Error[] = [];
const config = createAppleWriter();
const tasks = createAppleTasks();
const taskCtx = { app, projectDir };
const taskCtx = { app };
for (const task of tasks) {
try {
+1 -1
View File
@@ -69,7 +69,7 @@ export async function uploadMetadataAsync({
version: config.getVersion()?.versionString,
});
const taskCtx = { app, projectDir };
const taskCtx = { app };
for (const task of tasks) {
try {
@@ -2,11 +2,7 @@ import { Platform, Workflow } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { resolveVcsClient } from '../../vcs';
import {
hasIgnoredIosProjectAsync,
resolveWorkflowAsync,
resolveWorkflowPerPlatformAsync,
} from '../workflow';
import { resolveWorkflowAsync, resolveWorkflowPerPlatformAsync } from '../workflow';
jest.mock('fs');
@@ -90,21 +86,3 @@ describe(resolveWorkflowPerPlatformAsync, () => {
});
});
});
describe(hasIgnoredIosProjectAsync, () => {
beforeEach(() => {
vol.reset();
});
test('returns true when ios project is ignored', async () => {
vol.fromJSON(
{
'./ios/helloworld.xcodeproj/project.pbxproj': 'fake',
'./.easignore': 'ios/\n',
},
projectDir
);
await expect(hasIgnoredIosProjectAsync(projectDir, vcsClient)).resolves.toBe(true);
});
});
@@ -137,21 +137,81 @@ describe(ensureApplicationIdIsDefinedForManagedProjectAsync, () => {
})
).rejects.toThrowError(/we can't update this file programmatically/);
});
it('throws an error in non-interactive mode', async () => {
it('auto-generates application id in non-interactive mode when suggestion is available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp' } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.jester.myapp');
expect(promptAsync).not.toHaveBeenCalled();
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
expect(appJson).toMatchObject({
expo: { android: { package: 'com.jester.myapp' } },
});
});
it('uses ios.bundleIdentifier as application id in non-interactive mode when available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
vol.fromJSON(
{
'app.json': '{ "blah": {} }',
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp', ios: { bundleIdentifier: 'com.example.myapp' } } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.example.myapp');
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
await expect(
ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '',
exp: {} as any,
projectId: '1234',
exp: { slug: '' } as any,
vcsClient,
nonInteractive: true,
})
@@ -148,15 +148,8 @@ async function configureApplicationIdAsync({
exp: ExpoConfig;
nonInteractive: boolean;
}): Promise<string> {
if (nonInteractive) {
throw new Error(
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#package'
)}`
);
}
const paths = getConfigFilePaths(projectDir);
// we can't automatically update app.config.js
if (paths.dynamicConfigPath) {
throw new Error(
@@ -166,25 +159,50 @@ async function configureApplicationIdAsync({
assert(paths.staticConfigPath, 'app.json must exist');
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
let packageName: string;
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
const { packageName } = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
if (nonInteractive) {
let suggestedAndroidApplicationId: string | undefined;
try {
suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
} catch {
// If we can't get a suggestion, we'll throw the non-interactive error below
}
if (!suggestedAndroidApplicationId) {
throw new Error(
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#package'
)}`
);
}
packageName = suggestedAndroidApplicationId;
Log.log(`Using automatically generated Android application id: ${chalk.bold(packageName)}`);
} else {
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
const result = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
packageName = result.packageName;
}
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
@@ -115,20 +115,81 @@ describe(ensureBundleIdentifierIsDefinedForManagedProjectAsync, () => {
})
).rejects.toThrowError(/we can't update this file programmatically/);
});
it('throws an error in non-interactive mode', async () => {
it('auto-generates bundle identifier in non-interactive mode when suggestion is available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
vol.fromJSON(
{
'app.json': '{ "blah": {} }',
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp' } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.jester.myapp');
expect(promptAsync).not.toHaveBeenCalled();
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
expect(appJson).toMatchObject({
expo: { ios: { bundleIdentifier: 'com.jester.myapp' } },
});
});
it('uses android.package as bundle identifier in non-interactive mode when available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp', android: { package: 'com.example.myapp' } } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.example.myapp');
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
await expect(
ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: {} as any,
exp: { slug: '' } as any,
vcsClient,
nonInteractive: true,
})
@@ -7,23 +7,6 @@ import { ensureNonExemptEncryptionIsDefinedForManagedProjectAsync } from '../exe
jest.mock('fs');
jest.mock('../../../prompts');
// @expo/require-utils uses require() to load JS configs, which bypasses the
// mocked fs. Override loadModuleSync to read from the mocked fs and evaluate.
jest.mock('@expo/require-utils', () => {
const actual = jest.requireActual('@expo/require-utils');
return {
...actual,
loadModuleSync: (filename: string) => {
const fs = require('fs');
const content = fs.readFileSync(filename, 'utf-8');
const mod = { exports: {} as any };
// eslint-disable-next-line no-new-func
new Function('module', 'exports', content)(mod, mod.exports);
return mod.exports;
},
};
});
beforeEach(async () => {
jest.resetAllMocks();
vol.reset();
@@ -137,15 +137,8 @@ async function configureBundleIdentifierAsync({
exp: ExpoConfig;
nonInteractive: boolean;
}): Promise<string> {
if (nonInteractive) {
throw new Error(
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
)}`
);
}
const paths = getConfigFilePaths(projectDir);
// we can't automatically update app.config.js
if (paths.dynamicConfigPath) {
throw new Error(
@@ -155,26 +148,51 @@ async function configureBundleIdentifierAsync({
assert(paths.staticConfigPath, 'app.json must exist');
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
let bundleIdentifier: string;
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
if (nonInteractive) {
let suggestedBundleIdentifier: string | undefined;
try {
suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
} catch {
// If we can't get a suggestion, we'll throw the non-interactive error below
}
if (!suggestedBundleIdentifier) {
throw new Error(
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
)}`
);
}
bundleIdentifier = suggestedBundleIdentifier;
Log.log(`Using automatically generated iOS bundle identifier: ${chalk.bold(bundleIdentifier)}`);
} else {
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
const { bundleIdentifier } = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value => (isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE),
});
const result = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value =>
isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE,
});
bundleIdentifier = result.bundleIdentifier;
}
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
+1 -1
View File
@@ -55,7 +55,7 @@ export async function hasIgnoredIosProjectAsync(
try {
const pbxProjectPath = IOSConfig.Paths.getPBXProjectPath(projectDir);
return await vcsClient.isFileIgnoredAsync(path.relative(vcsRootPath, pbxProjectPath));
} catch {
} finally {
return false;
}
}
+2 -2
View File
@@ -20,8 +20,8 @@
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "18.4.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/config-plugins": "8.0.8",
"@expo/config-types": "52.0.1",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.4.0",
"@expo/logger": "18.0.1",
+3 -2
View File
@@ -1,3 +1,4 @@
import { PRECOMPILED_MODULES_PATH } from '@expo/build-tools';
import { Platform, Workflow } from '@expo/eas-build-job';
import config from '../config';
@@ -34,7 +35,7 @@ describe(getBuildEnv.name, () => {
expect(env.EAS_CLI_SENTRY_DSN).toBe(config.sentry.dsn);
});
it('passes through EXPO_USE_PRECOMPILED_MODULES for flagged iOS jobs', () => {
it('enables precompiled modules env vars for flagged iOS jobs', () => {
const env = getBuildEnv({
job: {
platform: Platform.IOS,
@@ -55,6 +56,6 @@ describe(getBuildEnv.name, () => {
});
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBe(PRECOMPILED_MODULES_PATH);
});
});
+8 -1
View File
@@ -1,4 +1,10 @@
import { Artifacts, BuildContext, Builders, runGenericJobAsync } from '@expo/build-tools';
import {
Artifacts,
BuildContext,
Builders,
maybeStartPreparingPrecompiledModules,
runGenericJobAsync,
} from '@expo/build-tools';
import {
Android,
BuildJob,
@@ -41,6 +47,7 @@ export async function build({
{ job: omit(ctx.job, 'secrets', 'projectArchive') },
'Builder is ready, starting build'
);
maybeStartPreparingPrecompiledModules(ctx, config);
},
{ doNotMarkStart: true }
);
+4
View File
@@ -98,6 +98,10 @@ export default {
transform: createBase64EnvTransformer('cocoapodsCacheUrl'),
defaultValue: null,
}),
precompiledModulesUrls: [
'https://storage.googleapis.com/turtle-v2/precompiled-modules/db65b4afac835ff71269ce53937fb20627b133c0/xcframeworks-Debug.zip',
'https://storage.googleapis.com/turtle-v2/precompiled-modules/db65b4afac835ff71269ce53937fb20627b133c0/xcframeworks-Release.zip',
],
runMetricsServer: env<boolean | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('runMetricsServer'),
defaultValue: null,
+3 -1
View File
@@ -1,3 +1,4 @@
import { PRECOMPILED_MODULES_PATH, shouldUsePrecompiledDependencies } from '@expo/build-tools';
import { Env, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { spawnSync } from 'child_process';
import micromatch from 'micromatch';
@@ -53,8 +54,9 @@ export function getBuildEnv({
if (runnerPlatform === Platform.IOS) {
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', config.cocoapodsCacheUrl);
setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO');
if (job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES === '1') {
if (shouldUsePrecompiledDependencies(job.builderEnvironment?.env ?? {})) {
setEnv(env, 'EXPO_USE_PRECOMPILED_MODULES', '1');
setEnv(env, 'EXPO_PRECOMPILED_MODULES_PATH', PRECOMPILED_MODULES_PATH);
}
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
+4 -13
View File
@@ -82,7 +82,6 @@ export async function logProjectDependenciesAsync(
});
} catch (error: any) {
sentry.handleError('Failed to report project dependencies metrics', error, {
level: 'warning',
tags: {
errorCode: 'FAILED_TO_REPORT_PROJECT_DEPENDENCIES_EVENT',
},
@@ -134,15 +133,10 @@ async function resolveNewArchEnabled(
}
if (
(appConfig?.[job.platform] as unknown as Record<string, unknown>)?.newArchEnabled !==
undefined ||
(appConfig as unknown as Record<string, unknown>).newArchEnabled !== undefined
appConfig?.[job.platform]?.newArchEnabled !== undefined ||
appConfig.newArchEnabled !== undefined
) {
return (
((appConfig?.[job.platform] as unknown as Record<string, unknown>)
?.newArchEnabled as boolean) ??
((appConfig as unknown as Record<string, unknown>).newArchEnabled as boolean)
);
return appConfig?.[job.platform]?.newArchEnabled ?? appConfig.newArchEnabled;
}
if (job.type === Workflow.GENERIC) {
@@ -226,10 +220,7 @@ async function getPackageManagerVersion(
}
): Promise<string | undefined> {
try {
const { stdout } = await spawnAsync(packageManager, ['--version'], {
cwd,
env,
});
const { stdout } = await spawnAsync(packageManager, ['--version'], { cwd, env });
return stdout.toString().trim();
} catch {
// Some package managers can fail version checks in project dirs with mismatched packageManager fields.
Generated
+108 -260
View File
@@ -844,17 +844,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.20.0, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0":
version: 7.29.0
resolution: "@babel/code-frame@npm:7.29.0"
dependencies:
"@babel/helper-validator-identifier": "npm:^7.28.5"
js-tokens: "npm:^4.0.0"
picocolors: "npm:^1.1.1"
checksum: 10c0/d34cc504e7765dfb576a663d97067afb614525806b5cad1a5cc1a7183b916fec8ff57fa233585e3926fd5a9e6b31aae6df91aa81ae9775fb7a28f658d3346f0d
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.22.13":
version: 7.22.13
resolution: "@babel/code-frame@npm:7.22.13"
@@ -922,13 +911,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/compat-data@npm:^7.28.6":
version: 7.29.0
resolution: "@babel/compat-data@npm:7.29.0"
checksum: 10c0/08f348554989d23aa801bf1405aa34b15e841c0d52d79da7e524285c77a5f9d298e70e11d91cc578d8e2c9542efc586d50c5f5cf8e1915b254a9dcf786913a94
languageName: node
linkType: hard
"@babel/core@npm:7.24.7":
version: 7.24.7
resolution: "@babel/core@npm:7.24.7"
@@ -1044,29 +1026,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/core@npm:^7.25.2":
version: 7.29.0
resolution: "@babel/core@npm:7.29.0"
dependencies:
"@babel/code-frame": "npm:^7.29.0"
"@babel/generator": "npm:^7.29.0"
"@babel/helper-compilation-targets": "npm:^7.28.6"
"@babel/helper-module-transforms": "npm:^7.28.6"
"@babel/helpers": "npm:^7.28.6"
"@babel/parser": "npm:^7.29.0"
"@babel/template": "npm:^7.28.6"
"@babel/traverse": "npm:^7.29.0"
"@babel/types": "npm:^7.29.0"
"@jridgewell/remapping": "npm:^2.3.5"
convert-source-map: "npm:^2.0.0"
debug: "npm:^4.1.0"
gensync: "npm:^1.0.0-beta.2"
json5: "npm:^2.2.3"
semver: "npm:^6.3.1"
checksum: 10c0/5127d2e8e842ae409e11bcbb5c2dff9874abf5415e8026925af7308e903f4f43397341467a130490d1a39884f461bc2b67f3063bce0be44340db89687fd852aa
languageName: node
linkType: hard
"@babel/generator@npm:^7.14.0, @babel/generator@npm:^7.16.5":
version: 7.16.5
resolution: "@babel/generator@npm:7.16.5"
@@ -1148,19 +1107,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/generator@npm:^7.29.0":
version: 7.29.1
resolution: "@babel/generator@npm:7.29.1"
dependencies:
"@babel/parser": "npm:^7.29.0"
"@babel/types": "npm:^7.29.0"
"@jridgewell/gen-mapping": "npm:^0.3.12"
"@jridgewell/trace-mapping": "npm:^0.3.28"
jsesc: "npm:^3.0.2"
checksum: 10c0/349086e6876258ef3fb2823030fee0f6c0eb9c3ebe35fc572e16997f8c030d765f636ddc6299edae63e760ea6658f8ee9a2edfa6d6b24c9a80c917916b973551
languageName: node
linkType: hard
"@babel/generator@npm:^7.7.2":
version: 7.15.0
resolution: "@babel/generator@npm:7.15.0"
@@ -1270,19 +1216,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-compilation-targets@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/helper-compilation-targets@npm:7.28.6"
dependencies:
"@babel/compat-data": "npm:^7.28.6"
"@babel/helper-validator-option": "npm:^7.27.1"
browserslist: "npm:^4.24.0"
lru-cache: "npm:^5.1.1"
semver: "npm:^6.3.1"
checksum: 10c0/3fcdf3b1b857a1578e99d20508859dbd3f22f3c87b8a0f3dc540627b4be539bae7f6e61e49d931542fe5b557545347272bbdacd7f58a5c77025a18b745593a50
languageName: node
linkType: hard
"@babel/helper-create-class-features-plugin@npm:^7.12.1":
version: 7.12.1
resolution: "@babel/helper-create-class-features-plugin@npm:7.12.1"
@@ -1435,16 +1368,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-module-imports@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/helper-module-imports@npm:7.28.6"
dependencies:
"@babel/traverse": "npm:^7.28.6"
"@babel/types": "npm:^7.28.6"
checksum: 10c0/b49d8d8f204d9dbfd5ac70c54e533e5269afb3cea966a9d976722b13e9922cc773a653405f53c89acb247d5aebdae4681d631a3ae3df77ec046b58da76eda2ac
languageName: node
linkType: hard
"@babel/helper-module-transforms@npm:^7.12.1":
version: 7.12.1
resolution: "@babel/helper-module-transforms@npm:7.12.1"
@@ -1538,19 +1461,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-module-transforms@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/helper-module-transforms@npm:7.28.6"
dependencies:
"@babel/helper-module-imports": "npm:^7.28.6"
"@babel/helper-validator-identifier": "npm:^7.28.5"
"@babel/traverse": "npm:^7.28.6"
peerDependencies:
"@babel/core": ^7.0.0
checksum: 10c0/6f03e14fc30b287ce0b839474b5f271e72837d0cafe6b172d759184d998fbee3903a035e81e07c2c596449e504f453463d58baa65b6f40a37ded5bec74620b2b
languageName: node
linkType: hard
"@babel/helper-optimise-call-expression@npm:^7.10.4":
version: 7.10.4
resolution: "@babel/helper-optimise-call-expression@npm:7.10.4"
@@ -1588,13 +1498,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-plugin-utils@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/helper-plugin-utils@npm:7.28.6"
checksum: 10c0/3f5f8acc152fdbb69a84b8624145ff4f9b9f6e776cb989f9f968f8606eb7185c5c3cfcf3ba08534e37e1e0e1c118ac67080610333f56baa4f7376c99b5f1143d
languageName: node
linkType: hard
"@babel/helper-replace-supers@npm:^7.12.1":
version: 7.12.5
resolution: "@babel/helper-replace-supers@npm:7.12.5"
@@ -1857,16 +1760,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/helpers@npm:^7.28.6":
version: 7.29.2
resolution: "@babel/helpers@npm:7.29.2"
dependencies:
"@babel/template": "npm:^7.28.6"
"@babel/types": "npm:^7.29.0"
checksum: 10c0/dab0e65b9318b2502a62c58bc0913572318595eec0482c31f0ad416b72636e6698a1d7c57cd2791d4528eb8c548bca88d338dc4d2a55a108dc1f6702f9bc5512
languageName: node
linkType: hard
"@babel/highlight@npm:^7.10.4":
version: 7.10.4
resolution: "@babel/highlight@npm:7.10.4"
@@ -2029,17 +1922,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0":
version: 7.29.2
resolution: "@babel/parser@npm:7.29.2"
dependencies:
"@babel/types": "npm:^7.29.0"
bin:
parser: ./bin/babel-parser.js
checksum: 10c0/e5a4e69e3ac7acdde995f37cf299a68458cfe7009dff66bd0962fd04920bef287201169006af365af479c08ff216bfefbb595e331f87f6ae7283858aebbc3317
languageName: node
linkType: hard
"@babel/plugin-proposal-class-properties@npm:^7.0.0":
version: 7.12.1
resolution: "@babel/plugin-proposal-class-properties@npm:7.12.1"
@@ -2396,18 +2278,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/plugin-transform-modules-commonjs@npm:^7.24.8":
version: 7.28.6
resolution: "@babel/plugin-transform-modules-commonjs@npm:7.28.6"
dependencies:
"@babel/helper-module-transforms": "npm:^7.28.6"
"@babel/helper-plugin-utils": "npm:^7.28.6"
peerDependencies:
"@babel/core": ^7.0.0-0
checksum: 10c0/7c45992797c6150644c8552feff4a016ba7bd6d59ff2b039ed969a9c5b20a6804cd9d21db5045fc8cca8ca7f08262497e354e93f8f2be6a1cdf3fbfa8c31a9b6
languageName: node
linkType: hard
"@babel/plugin-transform-object-super@npm:^7.0.0":
version: 7.12.1
resolution: "@babel/plugin-transform-object-super@npm:7.12.1"
@@ -2587,17 +2457,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/template@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/template@npm:7.28.6"
dependencies:
"@babel/code-frame": "npm:^7.28.6"
"@babel/parser": "npm:^7.28.6"
"@babel/types": "npm:^7.28.6"
checksum: 10c0/66d87225ed0bc77f888181ae2d97845021838c619944877f7c4398c6748bcf611f216dfd6be74d39016af502bca876e6ce6873db3c49e4ac354c56d34d57e9f5
languageName: node
linkType: hard
"@babel/traverse@npm:^7.12.1, @babel/traverse@npm:^7.12.5, @babel/traverse@npm:^7.14.0, @babel/traverse@npm:^7.16.5, @babel/traverse@npm:^7.16.7, @babel/traverse@npm:^7.16.8, @babel/traverse@npm:^7.17.10, @babel/traverse@npm:^7.17.3":
version: 7.23.2
resolution: "@babel/traverse@npm:7.23.2"
@@ -2649,21 +2508,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0":
version: 7.29.0
resolution: "@babel/traverse@npm:7.29.0"
dependencies:
"@babel/code-frame": "npm:^7.29.0"
"@babel/generator": "npm:^7.29.0"
"@babel/helper-globals": "npm:^7.28.0"
"@babel/parser": "npm:^7.29.0"
"@babel/template": "npm:^7.28.6"
"@babel/types": "npm:^7.29.0"
debug: "npm:^4.3.1"
checksum: 10c0/f63ef6e58d02a9fbf3c0e2e5f1c877da3e0bc57f91a19d2223d53e356a76859cbaf51171c9211c71816d94a0e69efa2732fd27ffc0e1bbc84b636e60932333eb
languageName: node
linkType: hard
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.10.4, @babel/types@npm:^7.10.5, @babel/types@npm:^7.11.0, @babel/types@npm:^7.12.1, @babel/types@npm:^7.12.5, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3":
version: 7.12.6
resolution: "@babel/types@npm:7.12.6"
@@ -2779,7 +2623,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.8.3":
"@babel/types@npm:^7.8.3":
version: 7.29.0
resolution: "@babel/types@npm:7.29.0"
dependencies:
@@ -2847,12 +2691,12 @@ __metadata:
languageName: node
linkType: hard
"@expo/apple-utils@npm:2.1.16":
version: 2.1.16
resolution: "@expo/apple-utils@npm:2.1.16"
"@expo/apple-utils@npm:2.1.15":
version: 2.1.15
resolution: "@expo/apple-utils@npm:2.1.15"
bin:
apple-utils: bin.js
checksum: 10c0/9d3915f3f25c32e1f1ab323676cabccc2998046be6f30deebc68a0706836bd50bc5145662fa13441e0283f82c56e50f55002d50dcc43aa78e9fa7327d1676a54
checksum: 10c0/956abdb77d1d3bcb09d42b70745705305d23b9aa4ed50e3da795e93e50dc3ada126cc16f73a55b49538f0d793ccdc455bad0caf7aaadbadd5ee7a3d0f3c69465
languageName: node
linkType: hard
@@ -2860,8 +2704,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config": "npm:10.0.6"
"@expo/config-plugins": "npm:9.0.12"
"@expo/downloader": "npm:18.0.1"
"@expo/eas-build-job": "npm:18.4.0"
"@expo/env": "npm:^0.4.0"
@@ -2948,24 +2792,48 @@ __metadata:
languageName: node
linkType: hard
"@expo/config-plugins@npm:55.0.7, @expo/config-plugins@npm:~55.0.7":
version: 55.0.7
resolution: "@expo/config-plugins@npm:55.0.7"
"@expo/config-plugins@npm:8.0.8":
version: 8.0.8
resolution: "@expo/config-plugins@npm:8.0.8"
dependencies:
"@expo/config-types": "npm:^55.0.5"
"@expo/json-file": "npm:~10.0.12"
"@expo/plist": "npm:^0.5.2"
"@expo/config-types": "npm:^51.0.0-unreleased"
"@expo/json-file": "npm:~8.3.0"
"@expo/plist": "npm:^0.1.0"
"@expo/sdk-runtime-versions": "npm:^1.0.0"
chalk: "npm:^4.1.2"
debug: "npm:^4.3.5"
getenv: "npm:^2.0.0"
glob: "npm:^13.0.0"
debug: "npm:^4.3.1"
find-up: "npm:~5.0.0"
getenv: "npm:^1.0.0"
glob: "npm:7.1.6"
resolve-from: "npm:^5.0.0"
semver: "npm:^7.5.4"
slash: "npm:^3.0.0"
slugify: "npm:^1.6.6"
xcode: "npm:^3.0.1"
xml2js: "npm:0.6.0"
checksum: 10c0/46cea118d9a780ac367862ef136aafa65063a3bc9a9864ece2a18d4b9e66e64d2bb19f0c0972e07f1106a58203f65671208bebcf5e207953f54217210807746c
checksum: 10c0/4d403ce9df0cd694cd30f1a914c4e4529c529e9b5f572ddc3cdf88f80d4c47a8ba8336da78ae3f9c67966a7fa1e02d87ea2dff95102a0a337004d118ebc32d63
languageName: node
linkType: hard
"@expo/config-plugins@npm:9.0.12":
version: 9.0.12
resolution: "@expo/config-plugins@npm:9.0.12"
dependencies:
"@expo/config-types": "npm:^52.0.0"
"@expo/json-file": "npm:~9.0.0"
"@expo/plist": "npm:^0.2.0"
"@expo/sdk-runtime-versions": "npm:^1.0.0"
chalk: "npm:^4.1.2"
debug: "npm:^4.3.5"
getenv: "npm:^1.0.0"
glob: "npm:^10.4.2"
resolve-from: "npm:^5.0.0"
semver: "npm:^7.5.4"
slash: "npm:^3.0.0"
slugify: "npm:^1.6.6"
xcode: "npm:^3.0.1"
xml2js: "npm:0.6.0"
checksum: 10c0/a27f085caf3d9ce29ca8b532425dd409054d8ef449b5d5338db2a2b1ead8051073e4f155691f6c2dc40b46816229fffbd09ef51c84eb87aa95eefaea9c810d40
languageName: node
linkType: hard
@@ -2991,7 +2859,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/config-plugins@npm:~9.0.0, @expo/config-plugins@npm:~9.0.17":
"@expo/config-plugins@npm:~9.0.0, @expo/config-plugins@npm:~9.0.10, @expo/config-plugins@npm:~9.0.17":
version: 9.0.17
resolution: "@expo/config-plugins@npm:9.0.17"
dependencies:
@@ -3013,10 +2881,17 @@ __metadata:
languageName: node
linkType: hard
"@expo/config-types@npm:55.0.5, @expo/config-types@npm:^55.0.5":
version: 55.0.5
resolution: "@expo/config-types@npm:55.0.5"
checksum: 10c0/24ce0481cc465ddd3b53cfdde099ef4e899b1f8fff224a0f249b88c93e6c98930e99a55f3929eb53d08138b1b66102ece7b76e16f4e5fadcdf5bbac26c9c3d7e
"@expo/config-types@npm:52.0.1":
version: 52.0.1
resolution: "@expo/config-types@npm:52.0.1"
checksum: 10c0/f06287eb20d599fbc422bf58dc97a803d8f9242699ce43188b90bfd92d54da27f83ca1225bf1ec61a5c30e74c57e47e7bfba2ef3cada38983622657658fafe4e
languageName: node
linkType: hard
"@expo/config-types@npm:^51.0.0-unreleased":
version: 51.0.3
resolution: "@expo/config-types@npm:51.0.3"
checksum: 10c0/bd87a729da985b0097ab29367c0473a2bced5ff211d416f342729e1b2631a7c00d62878e05cdee49ece7e3b65d3296957917f24380d57e2faef2cf220194fdec
languageName: node
linkType: hard
@@ -3034,6 +2909,27 @@ __metadata:
languageName: node
linkType: hard
"@expo/config@npm:10.0.6":
version: 10.0.6
resolution: "@expo/config@npm:10.0.6"
dependencies:
"@babel/code-frame": "npm:~7.10.4"
"@expo/config-plugins": "npm:~9.0.10"
"@expo/config-types": "npm:^52.0.0"
"@expo/json-file": "npm:^9.0.0"
deepmerge: "npm:^4.3.1"
getenv: "npm:^1.0.0"
glob: "npm:^10.4.2"
require-from-string: "npm:^2.0.2"
resolve-from: "npm:^5.0.0"
resolve-workspace-root: "npm:^2.0.0"
semver: "npm:^7.6.0"
slugify: "npm:^1.3.4"
sucrase: "npm:3.35.0"
checksum: 10c0/1fdf47ea0e268b475e30e02ebdc1f6739c0b426a4c6c695a7b4ee680109258b4ccfbd3010d3bd031a6efcd7a861670be84d10cd0019d0f5d14dc669a5039b860
languageName: node
linkType: hard
"@expo/config@npm:11.0.10":
version: 11.0.10
resolution: "@expo/config@npm:11.0.10"
@@ -3055,25 +2951,6 @@ __metadata:
languageName: node
linkType: hard
"@expo/config@npm:55.0.10":
version: 55.0.10
resolution: "@expo/config@npm:55.0.10"
dependencies:
"@expo/config-plugins": "npm:~55.0.7"
"@expo/config-types": "npm:^55.0.5"
"@expo/json-file": "npm:^10.0.12"
"@expo/require-utils": "npm:^55.0.3"
deepmerge: "npm:^4.3.1"
getenv: "npm:^2.0.0"
glob: "npm:^13.0.0"
resolve-from: "npm:^5.0.0"
resolve-workspace-root: "npm:^2.0.0"
semver: "npm:^7.6.0"
slugify: "npm:^1.3.4"
checksum: 10c0/a2e0a100c8d472e56e9ad649a8852eddca76ab64503475c803b4fc372df08e303169243b6a782fe150c54212ba02f47c40c4f0e365c13e77e70f5d777aa8d704
languageName: node
linkType: hard
"@expo/config@npm:~10.0.4":
version: 10.0.11
resolution: "@expo/config@npm:10.0.11"
@@ -3197,7 +3074,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/json-file@npm:8.3.3":
"@expo/json-file@npm:8.3.3, @expo/json-file@npm:~8.3.0":
version: 8.3.3
resolution: "@expo/json-file@npm:8.3.3"
dependencies:
@@ -3208,16 +3085,6 @@ __metadata:
languageName: node
linkType: hard
"@expo/json-file@npm:^10.0.12, @expo/json-file@npm:~10.0.12":
version: 10.0.12
resolution: "@expo/json-file@npm:10.0.12"
dependencies:
"@babel/code-frame": "npm:^7.20.0"
json5: "npm:^2.2.3"
checksum: 10c0/52131a6426e96208ff1b295d580fc70eebb8e292b29fde1db016b2f21a0942a7521feec96b3f58efe5b32dcc1642d569b4211d651146fcdb9bf7e5f08b635878
languageName: node
linkType: hard
"@expo/json-file@npm:^10.0.8":
version: 10.0.8
resolution: "@expo/json-file@npm:10.0.8"
@@ -3259,7 +3126,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/json-file@npm:~9.0.2":
"@expo/json-file@npm:~9.0.0, @expo/json-file@npm:~9.0.2":
version: 9.0.2
resolution: "@expo/json-file@npm:9.0.2"
dependencies:
@@ -3338,6 +3205,17 @@ __metadata:
languageName: node
linkType: hard
"@expo/plist@npm:^0.1.0":
version: 0.1.3
resolution: "@expo/plist@npm:0.1.3"
dependencies:
"@xmldom/xmldom": "npm:~0.7.7"
base64-js: "npm:^1.2.3"
xmlbuilder: "npm:^14.0.0"
checksum: 10c0/134315260a7828bc1ce4563e2af67499b498feae46c39c5c2cab9d72082402a42d3b7575f13e269022bcf3c28668948ea960dd4943bd38f52f9c01154317aac5
languageName: node
linkType: hard
"@expo/plist@npm:^0.2.0, @expo/plist@npm:^0.2.2":
version: 0.2.2
resolution: "@expo/plist@npm:0.2.2"
@@ -3360,17 +3238,6 @@ __metadata:
languageName: node
linkType: hard
"@expo/plist@npm:^0.5.2":
version: 0.5.2
resolution: "@expo/plist@npm:0.5.2"
dependencies:
"@xmldom/xmldom": "npm:^0.8.8"
base64-js: "npm:^1.5.1"
xmlbuilder: "npm:^15.1.1"
checksum: 10c0/19adae2a365ac1a12db93682fb310ff8be03c711f9173bebe5841cbe60cdfb749247bc1a95fa0977b5bac3aa6a078a0fceeafe4ff6c66d1ed67cce496679e310
languageName: node
linkType: hard
"@expo/plugin-help@npm:5.1.23":
version: 5.1.23
resolution: "@expo/plugin-help@npm:5.1.23"
@@ -3427,22 +3294,6 @@ __metadata:
languageName: node
linkType: hard
"@expo/require-utils@npm:^55.0.3":
version: 55.0.3
resolution: "@expo/require-utils@npm:55.0.3"
dependencies:
"@babel/code-frame": "npm:^7.20.0"
"@babel/core": "npm:^7.25.2"
"@babel/plugin-transform-modules-commonjs": "npm:^7.24.8"
peerDependencies:
typescript: ^5.0.0 || ^5.0.0-0
peerDependenciesMeta:
typescript:
optional: true
checksum: 10c0/562a2dc71f983fba2215295bbcc376d6911217c3a98b96484331112ff98c7a2e979fb1904ac293008ac557114c6658c1deb9b2f441bd246764b507103d2560cd
languageName: node
linkType: hard
"@expo/results@npm:1.0.0, @expo/results@npm:^1.0.0":
version: 1.0.0
resolution: "@expo/results@npm:1.0.0"
@@ -3555,8 +3406,8 @@ __metadata:
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:18.4.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/config-plugins": "npm:8.0.8"
"@expo/config-types": "npm:52.0.1"
"@expo/downloader": "npm:18.0.1"
"@expo/eas-build-job": "npm:18.4.0"
"@expo/logger": "npm:18.0.1"
@@ -5202,7 +5053,7 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5":
"@jridgewell/gen-mapping@npm:^0.3.12":
version: 0.3.13
resolution: "@jridgewell/gen-mapping@npm:0.3.13"
dependencies:
@@ -5223,16 +5074,6 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/remapping@npm:^2.3.5":
version: 2.3.5
resolution: "@jridgewell/remapping@npm:2.3.5"
dependencies:
"@jridgewell/gen-mapping": "npm:^0.3.5"
"@jridgewell/trace-mapping": "npm:^0.3.24"
checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194
languageName: node
linkType: hard
"@jridgewell/resolve-uri@npm:^3.0.3":
version: 3.0.7
resolution: "@jridgewell/resolve-uri@npm:3.0.7"
@@ -10998,10 +10839,10 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli@workspace:packages/eas-cli"
dependencies:
"@expo/apple-utils": "npm:2.1.16"
"@expo/apple-utils": "npm:2.1.15"
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config": "npm:10.0.6"
"@expo/config-plugins": "npm:9.0.12"
"@expo/eas-build-job": "npm:18.4.0"
"@expo/eas-json": "npm:18.4.0"
"@expo/env": "npm:^1.0.0"
@@ -11960,7 +11801,7 @@ __metadata:
languageName: node
linkType: hard
"find-up@npm:^5.0.0":
"find-up@npm:^5.0.0, find-up@npm:~5.0.0":
version: 5.0.0
resolution: "find-up@npm:5.0.0"
dependencies:
@@ -12432,13 +12273,6 @@ __metadata:
languageName: node
linkType: hard
"getenv@npm:^2.0.0":
version: 2.0.0
resolution: "getenv@npm:2.0.0"
checksum: 10c0/397ff641dd70cd78e414430258651e9a2228d3c5553a8cf15ae7840f75d3f10dfcb83f668f84829e84ea665b0fce2f08a9eddda3c9dcd7faa2d3da1c182c1854
languageName: node
linkType: hard
"git-hooks-list@npm:^3.0.0":
version: 3.2.0
resolution: "git-hooks-list@npm:3.2.0"
@@ -12543,6 +12377,20 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:7.1.6":
version: 7.1.6
resolution: "glob@npm:7.1.6"
dependencies:
fs.realpath: "npm:^1.0.0"
inflight: "npm:^1.0.4"
inherits: "npm:2"
minimatch: "npm:^3.0.4"
once: "npm:^1.3.0"
path-is-absolute: "npm:^1.0.0"
checksum: 10c0/2575cce9306ac534388db751f0aa3e78afedb6af8f3b529ac6b2354f66765545145dba8530abf7bff49fb399a047d3f9b6901c38ee4c9503f592960d9af67763
languageName: node
linkType: hard
"glob@npm:^10.2.2":
version: 10.3.10
resolution: "glob@npm:10.3.10"